Compare commits

...
585 Commits
Author SHA1 Message Date
Timothy Jaeryang Baek 63533c9e3a Merge pull request #12524 from open-webui/dev
0.6.2
2025-04-06 20:41:10 -07:00
Timothy Jaeryang Baek 8e8bc3125e doc: changelog 2025-04-06 20:41:03 -07:00
Timothy Jaeryang Baek 2c083f58d9 chore: format 2025-04-06 20:36:24 -07:00
Timothy Jaeryang Baek 93962f01cc doc: changelog 2025-04-06 20:34:47 -07:00
Timothy Jaeryang Baek 40d019ff50 refac 2025-04-06 20:23:23 -07:00
Timothy Jaeryang Baek 3885ea4a15 Merge pull request #12526 from gaby/dependabot-update
chore: Dependabot update
2025-04-06 19:46:03 -07:00
Juan Calderon-Perez bd28173de6 Track backend/requirements.txt updates 2025-04-06 22:33:34 -04:00
Timothy Jaeryang Baek 06820e701c refac: citations modal 2025-04-06 19:14:24 -07:00
Timothy Jaeryang Baek 2be08f27ea revert 2025-04-06 19:12:08 -07:00
Timothy Jaeryang Baek fabe5876c0 Merge pull request #12525 from TiancongLx/dev
i18n: update zh-TW
2025-04-06 18:57:19 -07:00
Tiancong Li f1a4afeb8f i18n: update zh-TW 2025-04-07 09:28:20 +08:00
Timothy Jaeryang Baek 914eb49767 chore: include accelerate dependency 2025-04-06 17:44:05 -07:00
Timothy Jaeryang Baek a8bc0d65a0 chore: format 2025-04-06 17:36:16 -07:00
Timothy Jaeryang Baek cbe2056587 fix: audio file upload response issue 2025-04-06 17:31:50 -07:00
Timothy Jaeryang Baek 65ed76abe1 refac: embedding prefix 2025-04-06 17:17:24 -07:00
Timothy Jaeryang Baek bc17511a05 refac 2025-04-06 17:10:09 -07:00
Timothy Jaeryang Baek 3413dd8ae1 refac 2025-04-06 17:02:39 -07:00
Timothy Jaeryang Baek 03759e79a3 refac 2025-04-06 17:00:46 -07:00
Timothy Jaeryang Baek 20211a489f refac 2025-04-06 16:57:36 -07:00
Timothy Jaeryang Baek 5d2e725f81 refac 2025-04-06 16:53:30 -07:00
Timothy Jaeryang Baek 2722613cde refac 2025-04-06 16:43:58 -07:00
Timothy Jaeryang Baek f243e523a6 refac 2025-04-06 15:52:38 -07:00
Timothy Jaeryang Baek 155dbd5a66 refac 2025-04-06 15:45:48 -07:00
Timothy Jaeryang Baek 9825d03602 Merge pull request #12507 from Ithanil/fix_web_result_collection_source_ids
fix: fix web results all getting the same source id when using embedding and retrieval
2025-04-06 15:43:21 -07:00
Timothy Jaeryang Baek 66cd40f959 chore: format 2025-04-06 15:37:46 -07:00
Timothy Jaeryang Baek dc2f8ec8c6 refac 2025-04-06 15:37:27 -07:00
Timothy Jaeryang Baek bf118718b3 Merge pull request #12521 from gaby/optimize-workflow
chore: Only run GitHub workflows when needed
2025-04-06 15:24:20 -07:00
Timothy Jaeryang Baek 64a0b28dab refac 2025-04-06 15:23:40 -07:00
Timothy Jaeryang Baek 6751d68034 Merge pull request #12506 from Ithanil/fix_web_result_source_ids
fix: fix web results all getting the same source id when bypassing embedding and retrieval
2025-04-06 15:21:14 -07:00
Timothy Jaeryang Baek 6274f4d0d3 Merge pull request #12520 from gaby/bump-docker
chore: Bump Python Docker image to python3.12
2025-04-06 15:17:27 -07:00
Juan Calderon-Perez 624c5250b1 Optimize GitHub actions 2025-04-06 18:15:56 -04:00
Timothy Jaeryang Baek c1ff69721a refac 2025-04-06 15:14:57 -07:00
Timothy Jaeryang Baek 1e98ae7608 Merge pull request #12431 from gaby/fix-12237
feat: Allow making content optional when listing all files
2025-04-06 15:13:13 -07:00
Timothy Jaeryang Baek 1bde36e072 Merge pull request #12510 from newnol/patch-1
i18n language pack vietnamese
2025-04-06 15:12:09 -07:00
Juan Calderon-Perez 89e7913ff2 Bump Python base Docker image to 3.12 2025-04-06 18:10:38 -04:00
Timothy Jaeryang Baek 51bf9eaa88 Merge pull request #12493 from TiancongLx/dev
i18n: update zh-TW
2025-04-06 15:09:21 -07:00
Timothy Jaeryang Baek b8cf9b738d Merge pull request #12511 from panda44312/patch-11
i18n: zh-cn
2025-04-06 15:09:13 -07:00
Timothy Jaeryang Baek 635c08a5ab Merge pull request #12517 from Ithanil/only_keep_retrieved_urls
fix: only keep URLs as sources for which the content was actually retrieved
2025-04-06 15:08:47 -07:00
Timothy Jaeryang Baek 6323b9f4f0 Merge pull request #12515 from gaby/fix-dependabot
chore: Fix dependabot configuration
2025-04-06 13:41:18 -07:00
Juan Calderon-Perez 94453839ba Update index.ts 2025-04-06 16:02:16 -04:00
Jan Kessler a506a1a61e only keep URLs as sources for which the content could actually be retrieved 2025-04-06 20:31:12 +02:00
Juan Calderon-Perez d266490862 Fix dependabot configuration 2025-04-06 13:58:17 -04:00
Juan Calderon-Perez ff1d454bdd Fix formatting 2025-04-06 13:32:51 -04:00
Juan Calderon-Perez eda3eba084 Merge branch 'open-webui:main' into fix-12237 2025-04-06 13:30:37 -04:00
Panda 60c6e00d9f i18n: fix 2025-04-06 16:47:06 +02:00
Panda 246c839ee6 i18n: zh-cn 2025-04-06 16:42:09 +02:00
Ngô Tấn Tài 2994c58fd9 Update translation.json 2025-04-06 21:26:58 +07:00
Jan Kessler 4476060044 fix web results all getting the same source id when using embedding and retrieval 2025-04-06 15:51:05 +02:00
Jan Kessler 2729d8aab4 fix web results all getting the same source id when bypassing embedding and retrieval 2025-04-06 13:37:42 +02:00
Tiancong Li 26e1bfc772 Merge remote-tracking branch 'upstream/dev' into dev
* upstream/dev:
  refac
  Fallback from desc to summary to placeholder
2025-04-06 10:33:01 +08:00
Timothy Jaeryang Baek 9ea6cea0ba refac 2025-04-05 14:31:22 -07:00
Timothy Jaeryang Baek 71625663c0 Merge pull request #12496 from dan-sullivan/fix-12479/add-desc-to-openapi-tools
fix: openapi tools should pass description to model not summary
2025-04-05 15:30:19 -06:00
Tiancong Li 33451cf66d i18n: update zh-TW 2025-04-06 04:46:31 +08:00
Timothy Jaeryang Baek 41aca84913 Merge pull request #12491 from open-webui/dev
refac
2025-04-05 10:24:32 -07:00
Timothy Jaeryang Baek 5296ee0f71 refac 2025-04-05 11:24:05 -06:00
Timothy Jaeryang Baek da94835165 Merge pull request #12373 from open-webui/dev
0.6.1
2025-04-05 10:15:32 -07:00
Timothy Jaeryang Baek 13dfca4e76 chore: bump 2025-04-05 11:13:30 -06:00
Timothy Jaeryang Baek d5063f47f2 doc: changelog 2025-04-05 11:11:37 -06:00
Timothy Jaeryang Baek ef787e4a79 Merge pull request #12486 from FabioPolito24/text-file-handling-docling
fix: text file handling with docling
2025-04-05 09:55:51 -07:00
Timothy Jaeryang Baek 48d690c55d Merge pull request #12481 from gaby/generic-errors
fix: Improve auth error messages
2025-04-05 09:47:52 -07:00
Timothy Jaeryang Baek 69c68df34b Merge pull request #12480 from Kylapaallikko/dev
i18n: Update Finnish Translation
2025-04-05 09:47:39 -07:00
Timothy Jaeryang Baek b3110ca291 Merge branch 'dev' into dev 2025-04-05 09:47:30 -07:00
Fabio Polito cd0a1b4852 fix: fix for text file handling with docling 2025-04-05 16:44:08 +00:00
Timothy Jaeryang Baek 32f309b41d chore: format 2025-04-05 10:42:21 -06:00
Timothy Jaeryang Baek d23f757c1f refac 2025-04-05 10:41:46 -06:00
Juan Calderon-Perez 324550423c Fix formatting issues 2025-04-05 10:03:24 -04:00
Juan Calderon-Perez b4277c7d2e Make auth error messages generic 2025-04-05 09:56:11 -04:00
Kylapaallikko 84788b73ce Update translation.json 2025-04-05 16:37:54 +03:00
dan-sullivan 09344bbb80 Fallback from desc to summary to placeholder 2025-04-05 14:18:14 +01:00
Timothy Jaeryang Baek fe4f760b4a refac 2025-04-05 05:57:25 -06:00
Timothy Jaeryang Baek 807b208418 refac 2025-04-05 05:55:56 -06:00
Timothy Jaeryang Baek ae484e8edf refac 2025-04-05 05:45:32 -06:00
Timothy Jaeryang Baek 56dc7c5640 refac 2025-04-05 05:38:46 -06:00
Timothy Jaeryang Baek e570a98bf7 refac: substandard codebase overhauled 2025-04-05 05:31:22 -06:00
Timothy Jaeryang Baek 93bb77ede3 refac 2025-04-05 05:03:15 -06:00
Timothy Jaeryang Baek 66db2e1515 refac: tools removed UNNECESSARY CODE 2025-04-05 04:59:11 -06:00
Timothy Jaeryang Baek c9e9ce931b refac 2025-04-05 04:49:07 -06:00
Timothy Jaeryang Baek 9747a0e1f1 refac: tool servers 2025-04-05 04:40:01 -06:00
Timothy Jaeryang Baek 61778c8f71 Merge pull request #12478 from theredcat/main
Improve FR translation
2025-04-05 03:22:54 -07:00
Timothy Jaeryang Baek 579aca65bb Merge pull request #12477 from Ithanil/improved_rag_template
fix: more coherent default RAG template
2025-04-05 03:22:41 -07:00
Timothy Jaeryang Baek ee44383c83 refac 2025-04-05 04:12:57 -06:00
Timothy Jaeryang Baek 0c0505e1cd refac 2025-04-05 04:05:52 -06:00
Nathan DELHAYE 1b8557ce86 i18n: improve fr translation 2025-04-05 12:04:12 +02:00
Jan Kessler e0da6003f1 tweak default rag template to be more coherent and improve consistency with citations 2025-04-05 11:40:48 +02:00
Timothy Jaeryang Baek 0f310b3509 Merge pull request #12476 from Phlogi/dev-hybrid-search
fix: Avoid secondary error when collection is empty in parallel hybrid search
2025-04-05 02:10:29 -07:00
Phlogi 8cf8121812 Update utils.py
Avoid running any tasks for collections that failed to fetch data (have assigned None)
2025-04-05 10:41:21 +02:00
Timothy Jaeryang Baek a2f2203325 Merge pull request #12426 from silentoplayz/fix-feedback
fix: Conditionally hide export buttons
2025-04-05 01:30:51 -07:00
Timothy Jaeryang Baek 193a927aba fix: temp chat 2025-04-05 02:16:35 -06:00
Timothy Jaeryang Baek 4ad10f0c6e chore: format 2025-04-05 01:31:45 -06:00
Timothy Jaeryang Baek 5e06b6d3f4 refac: styling 2025-04-05 01:24:32 -06:00
Timothy Jaeryang Baek cb94a87cb0 refac: tools styling 2025-04-05 01:05:47 -06:00
Timothy Jaeryang Baek 793aa307ef Merge pull request #12433 from gaby/fix-allowed-endpoints
fix: API Key Endpoint Restrictions for Dynamic Paths
2025-04-04 08:24:10 -07:00
Timothy Jaeryang Baek 7e46cc4562 Merge pull request #12439 from narutopden/i18n-add-Tibetan-translation
i18n: add Tibetan language
2025-04-04 08:20:25 -07:00
Timothy Jaeryang Baek b612af29e9 refac 2025-04-04 09:17:19 -06:00
Timothy Jaeryang Baek feaf434d4e refac: input prompt 2025-04-04 09:11:54 -06:00
Timothy Jaeryang Baek 77fa11098a enh: openapi tool server custom path 2025-04-04 08:31:13 -06:00
Timothy Jaeryang Baek a32bb850fa Merge pull request #12450 from mahenning/fix-missing-parameter-rag
Fix: Add missing parameter for query_doc_with_hybrid_search
2025-04-04 07:00:16 -07:00
Timothy Jaeryang Baek 64b68b3641 Merge pull request #12447 from floriankick/fix-openai-api-empty-message-after-tool-call
fix: make content parameter optional in OpenAI chat completion API endpoint
2025-04-04 06:59:12 -07:00
Marko Henning 3b2b6e183d Added missing parameter for query_doc_with_hybrid_search. 2025-04-04 15:30:57 +02:00
Juan Calderon-Perez 138e985d66 Rename field to include_content 2025-04-04 08:38:24 -04:00
Florian Kick ec3435de00 make content parameter optional in OpenAI chat completion API endpoint 2025-04-04 13:30:51 +02:00
narutopden a25d87607e i18n: add Tibetan language 2025-04-04 14:55:49 +08:00
Juan Calderon-Perez 1c57e3e02c Fix API_KEY_ALLOWED_ENDPOINTS 2025-04-03 23:52:10 -04:00
Silentoplayz 41b9ff9e00 fix: funcs, models, prompts, & tools conditional export button 2025-04-03 23:33:30 -04:00
Juan Calderon-Perez 6d5cb6b04e Add query param to remove content from GET /api/v1/files 2025-04-03 23:02:24 -04:00
silentoplayz 1366822f99 Update Feedbacks.svelte
fix
2025-04-04 02:38:03 +00:00
Silentoplayz 2b0d9c26eb fix: don't show export button if nothing to export 2025-04-03 22:20:40 -04:00
Timothy Jaeryang Baek be20e6dec0 refac: message edit 2025-04-03 13:43:45 -06:00
Timothy Jaeryang Baek a1f3300767 fix: tls cert requirement 2025-04-03 10:01:03 -07:00
Timothy Jaeryang Baek dfdde8bb1f refac 2025-04-03 09:40:26 -07:00
Timothy Jaeryang Baek ba77a72925 refac: styling 2025-04-03 01:00:40 -07:00
Timothy Jaeryang Baek bcf0a8791e refac: styling 2025-04-03 01:00:04 -07:00
Timothy Jaeryang Baek 100f5a57e7 refac 2025-04-02 23:56:15 -07:00
Timothy Jaeryang Baek 911321878f refac 2025-04-02 23:49:42 -07:00
Timothy Jaeryang Baek faa68fcdaa enh: image tool response 2025-04-02 23:46:39 -07:00
Timothy Jaeryang Baek 561b2c0b1a refac: styling 2025-04-02 21:23:45 -07:00
Timothy Jaeryang Baek 436e3ff7de refac 2025-04-02 20:42:23 -07:00
Timothy Jaeryang Baek c0711ba0c9 refac 2025-04-02 20:07:09 -07:00
Timothy Jaeryang Baek 5c5160c294 refac: remove None params 2025-04-02 19:56:39 -07:00
Timothy Jaeryang Baek 2277566ce1 fix: tool server api key not being sent 2025-04-02 19:52:12 -07:00
Timothy Jaeryang Baek 959995c715 refac: use selected model for merge response 2025-04-02 19:33:20 -07:00
Timothy Jaeryang Baek 7a1e10f3a7 refac: rm OAUTH_USE_PICTURE_CLAIM 2025-04-02 19:23:24 -07:00
Timothy Jaeryang Baek b15bf0dc70 refac 2025-04-02 19:17:26 -07:00
Timothy Jaeryang Baek 506950b03a Merge pull request #12376 from MushroomLamp-COB/main
feat: Add option to disable OAuth profile pictures
2025-04-02 19:16:36 -07:00
Timothy Jaeryang Baek 94353458a5 refac: settings ui styling 2025-04-02 19:01:39 -07:00
Timothy Jaeryang Baek 7eea95a48c feat: direct tools user permissions 2025-04-02 18:36:03 -07:00
Timothy Jaeryang Baek 94bf49440d enh: unload hybrid model if set to False 2025-04-02 18:15:14 -07:00
Timothy Jaeryang Baek 0644abe402 fix: admin folder deletion issue 2025-04-02 17:55:24 -07:00
Timothy Jaeryang Baek 9036945361 Merge pull request #12355 from silentoplayz/logging-fix
fix: update datetime and logging imports
2025-04-02 17:44:30 -07:00
silentoplayz dd5bafe8af Update env.py
Revert
2025-04-03 00:36:16 +00:00
CityOfBunbury 548c7f17d7 Added OAUTH_USE_PICTURE_CLAIM env var
Added OAUTH_USE_PICTURE_CLAIM to config.py

Added check to oauth.py on OAUTH_USE_PICTURE_CLAIM, to decide whether to user the profile picture in the claim or the default user.png
2025-04-03 08:24:14 +08:00
Timothy Jaeryang Baek bb7f4d408a Merge pull request #12364 from silentoplayz/archive-chats-option
feat: Added Archived Chats option in Chats settings section
2025-04-02 17:12:34 -07:00
Timothy Jaeryang Baek 6ee30042e7 Merge pull request #12353 from SadmL/dev
[i18n] Russian localization update
2025-04-02 15:47:44 -07:00
Timothy Jaeryang Baek 0554bbb1cb Merge pull request #12307 from paddy313/feature/mistral_ocr
feat: Added support for Mistral OCR for Content Extraction
2025-04-02 15:47:33 -07:00
Timothy Jaeryang Baek 517a57b7ca Merge pull request #12331 from panda44312/patch-10
i18n: zh-cn fix
2025-04-02 15:46:42 -07:00
Silentoplayz ee68c9ea55 Update Chats.svelte
Added Archived Chats option in Chats settings section
2025-04-02 14:25:03 -04:00
Silentoplayz d65471c420 fix
my dev environment works again!
2025-04-02 11:28:45 -04:00
SadmL de8f94b008 [i18n] Russian localization update
Fixed typos and translated some lines
2025-04-02 18:13:52 +03:00
Patrick Wachter 0ac00b9256 refactor: update import path for MistralLoader 2025-04-02 13:56:10 +02:00
Timothy Jaeryang Baek d0db4756a6 Merge pull request #12310 from landerrosette/fix_filter_priority
fix: Filter function’s priority valve has no effect
2025-04-02 04:41:00 -07:00
Panda adaa614986 fix 2025-04-02 10:35:05 +02:00
Timothy Jaeryang Baek 5ab783001d Merge pull request #12304 from OriginalSimon/main
18n: Update Ukrainian translation
2025-04-01 16:12:59 -07:00
Timothy Jaeryang Baek 2a163996f9 Merge pull request #12294 from Xelaph/dev
**i18n**: Update Dutch translations
2025-04-01 16:12:48 -07:00
Timothy Jaeryang Baek 1b37bdcec8 Merge pull request #12278 from Saidoua/main
**i18n** Standard Arabic Translation
2025-04-01 16:11:54 -07:00
Timothy Jaeryang Baek 39b0c06630 Merge pull request #12299 from lowlyocean/rpi_onnxruntime
fix: Allow v0.6.0 to run on Raspberry Pi
2025-04-01 16:02:05 -07:00
landerrosette 2b7dd6e399 refactor: standardize filter valve retrieval logic 2025-04-02 02:58:02 +08:00
Patrick Wachter c5a8d2f857 refactor: update MistralLoader documentation and adjust parameters for signed URL retrieval 2025-04-01 20:14:34 +02:00
Patrick Wachter 93d7702e8c refactor: move MistralLoader to a separate module and just use the requests package instead of mistralai 2025-04-01 20:14:34 +02:00
Simon 0447d90951 Update translation.json 2025-04-01 20:01:08 +02:00
Daniel Nowak 8f8c34471e Pin onnxruntime to 1.20.1 to address SIGILL on certain arm64 hosts 2025-04-01 12:03:09 -04:00
Alex 883ad55f5c Update translation.json 2025-04-01 16:12:31 +02:00
Alex 82e5a6410c Update translation.json 2025-04-01 16:09:17 +02:00
Alex 88b93241db Update translation.json 2025-04-01 15:20:04 +02:00
Alex b652b8e5f3 Update translation.json 2025-04-01 14:25:04 +02:00
Patrick Wachter 1ac6879268 Add Mistral OCR integration and configuration support 2025-04-01 14:24:33 +02:00
Said Ouhdachi 825becceb0 Arabic Translation 2025-04-01 13:10:59 +02:00
Timothy Jaeryang Baek fffa793d6d Merge pull request #12270 from TiancongLx/dev
i18n: update zh-TW
2025-04-01 02:29:00 -07:00
Timothy Jaeryang Baek 289d49dbe7 Merge pull request #12269 from panda44312/patch-9
i18n: zh-cn
2025-04-01 02:28:51 -07:00
Tiancong Li fa72c277db i18n: update zh-TW 2025-04-01 17:10:32 +08:00
Panda 8799ff9575 fix 2025-04-01 10:52:05 +02:00
Panda 85c8a9bd68 i18n: zh-cn 2025-04-01 10:47:52 +02:00
Timothy Jaeryang Baek 15f6e3af12 Merge pull request #12259 from weisser-dev/dev
[FEAT] adjust Translations for temporary chat
2025-04-01 00:59:27 -07:00
Timothy Jaeryang Baek c79fa4214f Merge pull request #12262 from lukasz-pekala/fb/fixes-to-polish-translation
i18n: fixes to polish translation
2025-04-01 00:59:08 -07:00
Łukasz Pękala 29d5745335 fix: update Polish translation for "Capture" to improve accuracy 2025-04-01 09:14:29 +02:00
Erik 70bb0562f3 Merge branch 'open-webui:dev' into dev 2025-04-01 09:09:33 +02:00
Erik c8210d4043 upated like in PR discussed 2025-04-01 09:06:15 +02:00
Łukasz Pękala 9f470a4ff5 fix: update Polish translations to sound more natural 2025-04-01 08:53:12 +02:00
Timothy Jaeryang Baek cea5c1a63b Merge pull request #12254 from open-webui/dependabot/pip/backend/dev/einops-0.8.1
build(deps): bump einops from 0.8.0 to 0.8.1 in /backend
2025-03-31 23:45:23 -07:00
Timothy Jaeryang Baek 4ce05ad279 Merge pull request #12255 from open-webui/dependabot/pip/backend/dev/pypandoc-1.15
build(deps): bump pypandoc from 1.13 to 1.15 in /backend
2025-03-31 23:45:15 -07:00
Timothy Jaeryang Baek f3c2614b6c Merge pull request #12253 from open-webui/dependabot/pip/backend/dev/python-multipart-0.0.20
build(deps): bump python-multipart from 0.0.18 to 0.0.20 in /backend
2025-03-31 23:44:40 -07:00
Timothy Jaeryang Baek 79cca06e0c Merge pull request #12260 from weisser-dev/feat-default-value-for-enforce-temp-chat
[improvement] default permission for new groups is false for enforce …
2025-03-31 23:43:08 -07:00
Łukasz Pękala b60beb6ff6 fix: improve Polish translations for clarity and accuracy 2025-04-01 08:39:36 +02:00
Erik 295c7eb4c7 [improvement] default permission for new groups is false for enforce temp chat 2025-04-01 08:27:29 +02:00
Łukasz Pękala 361584b4fd fix: update Polish translations for clarity and accuracy 2025-04-01 08:26:21 +02:00
Erik abba7c128c [FEAT]-Adjust Translations for temporary chat 2025-04-01 08:21:03 +02:00
Timothy Jaeryang Baek e0ec2cdeb0 refac: $user 2025-03-31 20:32:12 -07:00
dependabot[bot] bb0e11bb97 build(deps): bump pypandoc from 1.13 to 1.15 in /backend
Bumps [pypandoc](https://github.com/JessicaTegner/pypandoc) from 1.13 to 1.15.
- [Release notes](https://github.com/JessicaTegner/pypandoc/releases)
- [Changelog](https://github.com/JessicaTegner/pypandoc/blob/master/release.md)
- [Commits](https://github.com/JessicaTegner/pypandoc/compare/v1.13...v1.15)

---
updated-dependencies:
- dependency-name: pypandoc
  dependency-version: '1.15'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 02:22:16 +00:00
dependabot[bot] fc5c8dbe3f build(deps): bump einops from 0.8.0 to 0.8.1 in /backend
Bumps [einops](https://github.com/arogozhnikov/einops) from 0.8.0 to 0.8.1.
- [Release notes](https://github.com/arogozhnikov/einops/releases)
- [Commits](https://github.com/arogozhnikov/einops/compare/v0.8.0...v0.8.1)

---
updated-dependencies:
- dependency-name: einops
  dependency-version: 0.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 02:22:13 +00:00
dependabot[bot] 1d30d8a13a build(deps): bump python-multipart from 0.0.18 to 0.0.20 in /backend
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.18 to 0.0.20.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.18...0.0.20)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 02:22:11 +00:00
Timothy Jaeryang Baek 04799f1f95 Merge pull request #11728 from open-webui/dev
0.6
2025-03-31 18:47:18 -07:00
Timothy Jaeryang Baek 1b7c125f00 refac 2025-03-31 18:47:08 -07:00
Timothy Jaeryang Baek 13f7a4cf56 doc: wording 2025-03-31 18:43:27 -07:00
Timothy Jaeryang Baek 8f8d8ba27d doc: changelog 2025-03-31 18:40:49 -07:00
Timothy Jaeryang Baek dca68871e6 doc: wording 2025-03-31 18:39:30 -07:00
Timothy Jaeryang Baek da561c50d0 doc: wording 2025-03-31 18:36:08 -07:00
Timothy Jaeryang Baek 3662ecdeab doc: changelog 2025-03-31 18:34:42 -07:00
Timothy Jaeryang Baek 391dd33da3 chore: format 2025-03-31 17:59:21 -07:00
Timothy Jaeryang Baek 0bc5441d72 refac: styling 2025-03-31 17:58:48 -07:00
Timothy Jaeryang Baek 5f792d2771 feat: enforced temporary chat 2025-03-31 17:58:43 -07:00
Timothy Jaeryang BaekandTaylor Wilsdon 580965df17 feat: public sharing permissions
Co-Authored-By: Taylor Wilsdon <6508528+taylorwilsdon@users.noreply.github.com>
2025-03-31 17:28:25 -07:00
Timothy Jaeryang Baek 50b3f47f81 feat: public sharing permissions 2025-03-31 17:15:51 -07:00
Timothy Jaeryang Baek 3ba12e7a43 Merge pull request #12239 from Phlogi/dev-threads-on-hybrid
perf: parallelize hybrid search
2025-03-31 17:06:32 -07:00
Timothy Jaeryang Baek def5444ea3 feat: hide base model 2025-03-31 16:51:42 -07:00
Timothy Jaeryang Baek cafc5413f5 refac 2025-03-31 14:13:27 -07:00
Timothy Jaeryang Baek 3dc40030a1 Merge pull request #12236 from Phlogi/fix_prefix
Fix middleware.py missing prefix on embedding function
2025-03-31 14:02:49 -07:00
Timothy Jaeryang Baek efb63fb13f chore: format 2025-03-31 14:02:02 -07:00
Timothy Jaeryang Baek 598222b76f Merge pull request #12238 from rgaricano/dev
PR: Translation (es-ES) - Spanish v0.6.0
2025-03-31 14:01:27 -07:00
Timothy Jaeryang Baek fac848ad33 refac: rm log info 2025-03-31 14:00:40 -07:00
_00_ 098c794de3 Update translation.json
fix X typo
lines 4 & 304
2025-03-31 19:52:15 +02:00
_00_ 1571e3dc16 FIX: Translation (es-ES) - Spanish
Complete Revision of Spanish Translation (es-ES)

Revised spanish translation.
Standardization of terminology, vocabulary, and syntax.
Revision of incorrectly explained or defined terms.
2025-03-31 17:12:55 +02:00
_00_ a65c426e1d FIX: Translation (es-ES) - Spanish rev.
Complete Revision of Spanish Translation (es-ES)

Revised spanish translation.
Standardization of terminology, vocabulary, and syntax.
Revision of incorrectly explained or defined terms.

(added two lost recently updated entries)
2025-03-31 16:59:05 +02:00
Phlogi 9c64310db5 Run hybrid_search in parallel 2025-03-31 16:43:37 +02:00
_00_ 3c507e829f FIX: Translation (es-ES) - Spanish
Complete Revision of Spanish Translation (es-ES)

Revised spanish translation.
Standardization of terminology, vocabulary, and syntax.
Revision of incorrectly explained or defined terms.
2025-03-31 16:42:56 +02:00
Phlogi bc64b2a67f Fix middleware.py missing prefix on embedding function 2025-03-31 15:19:07 +02:00
Timothy Jaeryang Baek 6ac153c989 refac: styling 2025-03-31 03:45:36 -07:00
Timothy Jaeryang Baek 5ce6c8ced3 refac 2025-03-31 01:45:22 -07:00
Timothy Jaeryang Baek 9809ffb095 refac 2025-03-31 01:30:08 -07:00
Timothy Jaeryang Baek a5dbd2e8dd refac: knowledge file ac 2025-03-31 01:10:18 -07:00
Timothy Jaeryang Baek 021e25ade1 Merge pull request #11087 from tarmst/fix-files-access-control
fix: Add access control usage to files APIs
2025-03-31 01:05:08 -07:00
Timothy Jaeryang Baek 05dfb9cecd refac: floating buttons behaviour in shared chat 2025-03-31 00:57:16 -07:00
Timothy Jaeryang Baek effa87638d refac: prompt command behaviour 2025-03-31 00:48:09 -07:00
Timothy Jaeryang Baek 300b7dfcc0 fix: model import/export 2025-03-31 00:39:20 -07:00
Timothy Jaeryang Baek fb875c574c Merge pull request #12218 from aleixdorca/dev
Update catalan translation.json
2025-03-31 00:19:28 -07:00
Aleix Dorca 6e190bebe8 Update catalan translation.json 2025-03-31 09:12:48 +02:00
Timothy Jaeryang Baek e4cc9f2b4f refac: folders 2025-03-30 23:36:15 -07:00
Timothy Jaeryang Baek 337df80c47 refac: styling 2025-03-30 23:17:06 -07:00
Timothy Jaeryang Baek d542881ee4 refac 2025-03-30 21:55:20 -07:00
Timothy Jaeryang Baek 4b75966401 refac: embedding prefix var naming 2025-03-30 21:55:15 -07:00
Timothy Jaeryang Baek 433b5bddc1 Merge pull request #8594 from jayteaftw/main
feat: Support for instruct/prefixing embeddings
2025-03-30 21:54:44 -07:00
Timothy Jaeryang Baek 12101fd26d Merge pull request #11307 from wangtunan/fix/stop_task_func_need_use_async_await_syntax
fix: stopTask func need use async/await syntax
2025-03-30 21:45:32 -07:00
Timothy Jaeryang Baek 33f93371dc feat: user webhooks system settings 2025-03-30 21:31:16 -07:00
Timothy Jaeryang Baek d55735dc1e refac: rm profile image from feedback user object 2025-03-30 21:23:42 -07:00
Timothy Jaeryang Baek d41844c308 feat: prompt autocompletion user setting 2025-03-30 21:19:09 -07:00
Timothy Jaeryang Baek 50b8dec3ac fix/refac: hybrid search 2025-03-30 20:48:22 -07:00
Timothy Jaeryang Baek ce0d82b55f Merge pull request #12132 from Phlogi/dev-fetch-documents-once
Avoid multiple data fetching
2025-03-30 20:44:32 -07:00
Timothy Jaeryang Baek febc85361f Merge pull request #12191 from rgaricano/dev
Fix alignament buttons text
2025-03-30 20:42:21 -07:00
Timothy Jaeryang Baek d9bbdd8dca revert 2025-03-30 20:38:47 -07:00
Timothy Jaeryang Baek 9c00f3caf3 Merge pull request #12192 from Ithanil/avatar_compression
feat: adjust jpeg compression quality to 0.15 for avatar uploads
2025-03-30 20:35:31 -07:00
Timothy Jaeryang Baek ee9ff96c7d Merge pull request #12194 from TiancongLx/dev
i18n: update zh-TW
2025-03-30 17:19:23 -07:00
Tiancong Li 50a0751418 i18n: update zh-TW 2025-03-30 23:24:27 +08:00
Jan Kessler 7f5dc22464 adjust jpeg compression quality to 0.15 for avatar uploads 2025-03-30 14:14:14 +02:00
_00_ 5b8317270e Fix alignament buttons text
Fix alignament buttons text

Fixed align tag on user & admin settings

- correction of some align tag assigned to buttons that are right and
have to be left, this means that although it is not noticeable in
English, in other languages, especially if several lines appear, it is
obvious.
2025-03-30 13:36:07 +02:00
Timothy Jaeryang Baek b5909ffe97 enh: tool args parse json fallback 2025-03-30 02:39:23 -07:00
Timothy Jaeryang Baek 03e61e7a4a refac: styling 2025-03-30 00:47:45 -07:00
Timothy Jaeryang Baek 215f991326 refac 2025-03-30 00:44:09 -07:00
Timothy Jaeryang Baek 2cee5a4a03 chore: format 2025-03-30 00:21:00 -07:00
Timothy Jaeryang Baek 3be626bef3 feat: goto message 2025-03-29 17:48:57 -07:00
Timothy Jaeryang Baek c700126c17 refac: html rendering 2025-03-29 16:52:49 -07:00
Timothy Jaeryang Baek feaa01dc77 fix: ollama requests 2025-03-29 16:52:42 -07:00
Timothy Jaeryang Baek 34607736c6 Merge pull request #12170 from JunaidPinjari/fix/csv-autodetect-encoding
Fix: CSV loader encoding issue using autodetect_encoding=True
2025-03-29 15:23:55 -07:00
Timothy Jaeryang Baek 5b9b802793 refac 2025-03-29 15:23:02 -07:00
Timothy Jaeryang Baek da5ea7acb8 Merge pull request #12176 from JunaidPinjari/feat/file-upload-download-options
Support skipping ingestion and downloading files as attachments
2025-03-29 15:20:47 -07:00
Timothy Jaeryang Baek 08ff1d8d88 refac 2025-03-29 15:03:10 -07:00
Timothy Jaeryang Baek c2ff73c3e1 refac 2025-03-29 14:59:03 -07:00
Junaid Pinjari a805e033a5 Feat: Add optional ingest_file and as_attachment params to upload and download endpoints 2025-03-29 15:42:11 +05:30
Junaid Pinjari e782e7d3a7 Fix: CSV loader encoding issue using autodetect_encoding=True 2025-03-29 13:14:53 +05:30
Timothy Jaeryang Baek 66015bb341 Merge pull request #12168 from yak1ex/fix_inconsistent_value_check_in_onMessageHandler_in_Chat_svelte
fix: Inconsistent value check in onMessageHandler() in Chat.svelte
2025-03-28 22:12:57 -07:00
Timothy Jaeryang Baek 737f41dd2e refac 2025-03-28 13:18:44 -07:00
Timothy Jaeryang Baek 402d32ccfd refac 2025-03-28 13:17:43 -07:00
Timothy Jaeryang Baek 0413c747a9 refac: hide hybrid option with full context mode 2025-03-28 13:16:56 -07:00
Timothy Jaeryang Baek acd5b07fb4 enh: tool servers info modal 2025-03-28 12:40:56 -07:00
Timothy Jaeryang Baek 0a49c366f0 refac 2025-03-28 12:25:59 -07:00
Timothy Jaeryang Baek af2c5f3a18 refac 2025-03-28 12:23:25 -07:00
Timothy Jaeryang Baek 115f34069a refac: tool call display 2025-03-28 12:18:27 -07:00
Timothy Jaeryang Baek 1ac87c55ff chore: format 2025-03-28 11:47:14 -07:00
Timothy Jaeryang Baek b8c1bb0ec5 refac 2025-03-28 02:30:13 -07:00
Timothy Jaeryang Baek f8835d78d9 refac: tool call block display 2025-03-28 02:27:40 -07:00
Timothy Jaeryang Baek fd9641dcd1 refac 2025-03-28 01:20:45 -07:00
Timothy Jaeryang Baek 5656f030c4 refac: error handling 2025-03-28 00:52:13 -07:00
Timothy Jaeryang Baek 2fc8ace46d fix: error message persistence issue 2025-03-28 00:25:00 -07:00
Timothy Jaeryang Baek 2070d1096b refac 2025-03-28 00:07:00 -07:00
Timothy Jaeryang Baek 5f9679253e refac: openapi tool server support 2025-03-27 23:59:16 -07:00
Timothy Jaeryang Baek 359e6f2fd2 Merge pull request #12149 from TiancongLx/dev
i18n: update zh-TW
2025-03-27 23:49:56 -07:00
Tiancong Li f647e86346 i18n: update zh-TW 2025-03-28 14:38:43 +08:00
Timothy Jaeryang Baek 7fce3e2a9c Merge pull request #11148 from Ithanil/redis_sentinel
feat: Support for Redis Sentinel
2025-03-27 20:36:53 -07:00
Timothy Jaeryang Baek c137d3ad17 enh: allow json schema for format 2025-03-27 20:27:16 -07:00
Timothy Jaeryang Baek 424f007b10 fix: ldap certificate path should not be required 2025-03-27 19:55:41 -07:00
Timothy Jaeryang Baek 5e720cd84b refac: ollama response_format support 2025-03-27 19:26:08 -07:00
Timothy Jaeryang Baek 7845b2fbef Merge pull request #12122 from panda44312/patch-8
i18n: zh-cn
2025-03-27 14:48:45 -07:00
Phlogi 04bf9ddab2 Avoid multiple data fetching 2025-03-27 19:05:20 +01:00
Panda 72bef8798f i18n: zh-cn 2025-03-27 11:05:57 +01:00
Timothy Jaeryang Baek 4547453141 refac 2025-03-27 02:57:02 -07:00
Timothy Jaeryang Baek fc6d432c2f refac 2025-03-27 02:53:24 -07:00
Timothy Jaeryang Baek 038df1131e refac 2025-03-27 02:50:53 -07:00
Timothy Jaeryang Baek d1bc2cfa2f refac: external tools server support 2025-03-27 02:27:56 -07:00
Jan Kessler 0615c11a53 fix sentinel connection being attempted for non-sentinel redis 2025-03-27 10:23:35 +01:00
Timothy Jaeryang Baek 69dee19568 refac 2025-03-27 01:42:33 -07:00
Timothy Jaeryang Baek 4a79320253 chore: format 2025-03-27 01:40:28 -07:00
Timothy Jaeryang Baek d9b6d78d5c feat: external tool server support frontend 2025-03-27 01:38:35 -07:00
Jan Kessler d0b13cf388 prefix sentinel envs with redis_ 2025-03-27 09:22:11 +01:00
Timothy Jaeryang Baek 3514a6c5ed Merge pull request #12050 from mahenning/fix-db-order
Fix: Normalize all database distances to score in [0, 1] (needs testing for different DBs)
2025-03-26 20:56:02 -07:00
Timothy Jaeryang Baek 7490bc9100 Merge branch 'dev' into fix-db-order 2025-03-26 20:55:42 -07:00
Timothy Jaeryang Baek eaba9d8a67 Merge pull request #11497 from mahenning/k_reranker
feat: Added new k_reranker parameter
2025-03-26 20:50:43 -07:00
Timothy Jaeryang Baek 9d834a8e90 Merge branch 'dev' into k_reranker 2025-03-26 20:50:31 -07:00
Timothy Jaeryang Baek 79e4f8a7bc refac 2025-03-26 20:47:52 -07:00
Timothy Jaeryang Baek 5119cc47fc refac 2025-03-26 20:46:55 -07:00
Timothy Jaeryang Baek 42636347e2 refac: start source citation from 1 2025-03-26 20:39:39 -07:00
Timothy Jaeryang Baek dd2d757270 Merge pull request #12090 from panda44312/patch-7
i18n: zh-cn
2025-03-26 20:37:27 -07:00
Timothy Jaeryang Baek 08cd749160 refac 2025-03-26 20:28:41 -07:00
Panda f2bc2f704e i18n: zh-cn 2025-03-26 15:14:16 +01:00
Timothy Jaeryang Baek a650d77af4 refac 2025-03-26 01:25:01 -07:00
Timothy Jaeryang Baek c062353a3f refac 2025-03-26 01:17:10 -07:00
Timothy Jaeryang Baek c1a02ec7d8 refac 2025-03-26 01:11:12 -07:00
Timothy Jaeryang Baek 1be6ad1250 feat: /messages chat endpoint support 2025-03-26 01:10:27 -07:00
Timothy Jaeryang Baek 7dcdb1b404 refac 2025-03-26 00:43:51 -07:00
Timothy Jaeryang Baek 5a7efad59c refac: tools 2025-03-26 00:40:24 -07:00
Timothy Jaeryang Baek 82ca88105c Merge pull request #12076 from TiancongLx/dev
i18n: update zh-TW
2025-03-25 23:50:27 -07:00
Tiancong Li ec08043f23 i18n: update zh-TW 2025-03-26 13:34:53 +08:00
Timothy Jaeryang Baek 823d9f0475 Merge pull request #12043 from Espinaydelfin/main
i18n: Agregar idioma Galego (Add Galician Language)
2025-03-25 21:02:16 -07:00
Timothy Jaeryang Baek 1a04028507 Merge pull request #12051 from PeterDaveHello/Update-zh-TW-locale
i18n: minor zh-TW Traditional Chinese translation fix
2025-03-25 21:01:12 -07:00
Timothy Jaeryang Baek 8fe4c51e26 Merge pull request #12054 from philipp-horstenkamp/main
i18n: Fixed typo in german translation
2025-03-25 21:00:57 -07:00
Timothy Jaeryang Baek 9ef5868226 Merge pull request #12056 from ivanbaldo/fix-tika-spaces
**fix**: Tika 3.1.0.0 sends a lot of blank lines which degrades the RAG results and don't look good on the UI, strip them.
2025-03-25 21:00:49 -07:00
Marko Henning 7531b7dcaa Satisfy github format check 2025-03-25 19:09:17 +01:00
Iván Baldo 115e46a6a2 Fix: Tika 3.1.0.0 sends a lot of blank lines which degrades the RAG results, strip them. 2025-03-25 14:53:14 -03:00
Philipp Horstenkamp ee263b2f2b Fixed typo in german translation 2025-03-25 17:19:35 +01:00
Peter Dave Hello ca50e339cd i18n: minor zh-TW Traditional Chinese translation fix 2025-03-26 00:04:33 +08:00
Marko Henning 94d9d3d590 Fix: Normalze all database distances to score in [0, 1] 2025-03-25 16:46:14 +01:00
Espinaydelfin 5cf730bba7 Agregar idioma Galego 2025-03-25 13:31:37 +01:00
Timothy Jaeryang Baek 4906af9319 refac: styling 2025-03-25 00:12:23 -07:00
Timothy Jaeryang Baek 0192b4b468 refac: styling 2025-03-25 00:10:09 -07:00
Timothy Jaeryang Baek 57d1527ce1 refac 2025-03-25 00:07:04 -07:00
Timothy Jaeryang Baek 48b89322fd Merge pull request #12009 from PeterDaveHello/Dockerfile
build: add one missing `--no-cache-dir` for `pip3 install` in Dockerfile
2025-03-24 11:44:25 -07:00
Timothy Jaeryang Baek 16018b75a3 Merge pull request #12016 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-03-24 11:43:24 -07:00
Simon 0418416355 Update translation.json 2025-03-24 19:41:55 +01:00
Simon 1db46c31aa Merge branch 'open-webui:dev' into dev 2025-03-24 19:41:07 +01:00
Simon 4f06771559 Update translation.json 2025-03-24 19:40:30 +01:00
Timothy Jaeryang Baek 6ed617e9f5 Merge pull request #12006 from PeterDaveHello/Update-zh-TW-locale
i18n: Improve zh-TW Traditional Chinese translation
2025-03-24 11:36:54 -07:00
Timothy Jaeryang Baek f5590b2fc0 Merge pull request #12014 from PeterDaveHello/FixPullRequestTemplate
docs: fix spelling and grammatical issues in PR template
2025-03-24 11:36:40 -07:00
Timothy Jaeryang Baek 38d524f6a0 chore: format 2025-03-24 11:35:32 -07:00
Peter Dave Hello ed2ecca475 docs: fix spelling and grammatical issues in PR template 2025-03-25 00:26:21 +08:00
Peter Dave Hello aaf47486da build: add one missing --no-cache-dir for pip3 install in Dockerfile 2025-03-24 20:45:16 +08:00
Peter Dave Hello c1f1af97c1 i18n: Improve zh-TW Traditional Chinese translation 2025-03-24 04:27:14 +08:00
Timothy Jaeryang Baek d81d1333ba Merge pull request #11950 from shirayu/feat_auth_redirect
feat: Adding auth redirect parameter
2025-03-23 11:53:45 -07:00
Timothy Jaeryang Baek 9de3e6d716 Merge pull request #11971 from shirayu/remove_ENABLE_AUDIT_LOGS
Remove `ENABLE_AUDIT_LOGS`
2025-03-23 11:53:12 -07:00
Timothy Jaeryang Baek e5b7188379 refac: ollama only param 2025-03-23 11:50:40 -07:00
Timothy Jaeryang Baek a00e5a0c0e Merge pull request #11988 from binxn/dev
Updated middleware.py to add OpenRouter compatibility
2025-03-23 11:48:21 -07:00
Timothy Jaeryang Baek c94a1ec8f8 Merge pull request #11989 from MaxJa4/dev
i18n: Translation for de-DE German
2025-03-23 11:47:15 -07:00
Timothy Jaeryang Baek c714bd8739 refac 2025-03-23 11:45:55 -07:00
MaxJa4 f2866ed858 Adjust naming 2025-03-23 17:35:28 +01:00
MaxJa4 137f16a160 Rename chats 2025-03-23 17:35:23 +01:00
MaxJa4 73715538ed Fix spelling error 2025-03-23 17:35:23 +01:00
MaxJa4 e4078a6aee Add new translations 2025-03-23 17:35:23 +01:00
binxn efd86e2cb4 Updated middleware.py to add OpenRouter compatibility 2025-03-23 17:14:20 +01:00
Yak! c1f189a602 Fix inconsistent value check. 2025-03-23 17:52:48 +09:00
Yuta Hayashibe d144592660 chore: Remove ENABLE_AUDIT_LOGS and set the AUDIT_LOG_LEVEL NONE 2025-03-23 11:34:04 +09:00
Timothy Jaeryang Baek 75b18f92b9 refac 2025-03-22 14:01:07 -07:00
Timothy Jaeryang Baek 07a15494e5 Merge pull request #11955 from jfbloom22/dev
fix: Improve error handling in ChromaDB delete function for non-existent collections
2025-03-22 12:46:01 -07:00
Jonathan Flower bdd236fa3a improved error handling for deleting collections that do not exist in chromadb 2025-03-22 09:59:06 -04:00
Yuta Hayashibe 966940cb00 feat: Added redirect parameter to /auth 2025-03-22 15:04:46 +09:00
Timothy Jaeryang Baek f872bda19b Merge pull request #11936 from softue/auth-redirect
fix: redirection for users already logged in
2025-03-21 12:33:52 -07:00
Timothy Jaeryang Baek 22f6e0f2f4 refac 2025-03-21 08:08:15 -07:00
Timothy Jaeryang Baek 8aa6dade41 Merge pull request #11876 from mahenning/fix--rag-sorting
Fix: wrong citation order for chromadb, wrong order for hybrid search
2025-03-20 17:54:22 -07:00
Timothy Jaeryang Baek d047eb46cc refac 2025-03-20 17:54:13 -07:00
Timothy Jaeryang Baek 87a06a1976 fix: file delete from knowledge not working with bypass embedding 2025-03-20 17:46:11 -07:00
Timothy Jaeryang Baek a28436237c refac 2025-03-20 17:42:50 -07:00
Timothy Jaeryang Baek 558295f728 Merge pull request #11862 from hurxxxx/feat/auto-edit-new-folder
feat: Automatically enter edit mode when creating a new folder
2025-03-20 17:40:37 -07:00
Timothy Jaeryang Baek ca43a4e97a Merge pull request #11926 from djismgaming/spanish-translation-20250320
i18n: update Spanish translation
2025-03-20 17:24:36 -07:00
djismgaming cbd11cffa0 chore: update translation.json 2025-03-20 18:11:33 -04:00
Timothy Jaeryang Baek 1813bb509b Merge pull request #11830 from hurxxxx/feat/chat-title-rename-enter-submit
feat: submit chat title rename with Enter, cancel with ESC
2025-03-20 14:03:59 -07:00
Timothy Jaeryang Baek 1d305e7b2f chore: format 2025-03-20 14:02:33 -07:00
Timothy Jaeryang Baek 9b20ef4922 refac 2025-03-20 14:01:47 -07:00
Timothy Jaeryang Baek f066eea92e Merge pull request #11899 from genjuro214/set-playwright-timeout
perf: set shorter timeout for playwright and make it configurable
2025-03-20 14:00:59 -07:00
Timothy Jaeryang Baek b96557c46e refac: styling 2025-03-20 13:55:13 -07:00
Timothy Jaeryang Baek 13220991a4 Merge pull request #11846 from hurxxxx/feat/sidebar-chat-search-clear-button
feat: add clear ("X") button to sidebar chat search input
2025-03-20 13:50:58 -07:00
Timothy Jaeryang Baek 132a332ac7 Merge pull request #11915 from Alluuu/i18n-add-estonian-language
i18n: Add Estonian et-EE translations
2025-03-20 13:50:23 -07:00
Timothy Jaeryang Baek 42f3e40065 Merge pull request #11911 from diwakar-s-maurya/patch-1
fix: error message propagation from filter-pipeline
2025-03-20 13:48:36 -07:00
Rômulo Mendes Figueiredo c4c6e02b4c fix: redirection for users already logged in 2025-03-20 16:05:26 -03:00
Alluuu 5b276471b3 Added Estonian language translations.
Tried to organize language list, following existing pattern as best, as I could tell: English first, Alphabetical languages, finished by Chinese and Doge
2025-03-20 18:42:45 +02:00
Diwakar 2bdf77a726 Fix error message propagate from pipelines
Error message returned from pipelines was not being shown on UI. It showed "Connection closed". With this fix it will show the error message on the UI from the pipeline properly.
2025-03-20 22:20:27 +07:00
genjuro 07098c6352 perf: set shorter timeout for playwright and make it configurable 2025-03-20 15:28:09 +08:00
Marko Henning 5f48af5b91 Revert the ordering change with chromadb, not necessary with reranker results 2025-03-19 17:04:45 +01:00
Timothy Jaeryang Baek 70550e41fc enh: user groups/permissions endpoint 2025-03-19 08:47:31 -07:00
Timothy Jaeryang Baek c69d1c86fe enh: apply file size limit to knowledge 2025-03-19 08:36:41 -07:00
Timothy Jaeryang Baek f806ab0bd2 refac 2025-03-19 08:32:31 -07:00
hurxxxx 18a8a375ab fix: Enter edit mode with text pre-selected 2025-03-20 00:27:53 +09:00
Marko Henning ec8fc727b8 Fix wrong order for chromadb 2025-03-19 16:06:10 +01:00
Timothy Jaeryang Baek 6ace00df24 Merge pull request #11839 from tcgumus/patch-2
Turkish language pack updates
2025-03-19 06:24:44 -07:00
Timothy Jaeryang Baek b4c00b2eb2 Merge pull request #11833 from aleixdorca/dev
Update Catalan translation.json
2025-03-19 06:24:23 -07:00
Timothy Jaeryang Baek 4b10051326 Merge pull request #11832 from TiancongLx/dev
i18n: update zh-TW
2025-03-19 06:24:15 -07:00
Timothy Jaeryang Baek 01c236ee59 Merge pull request #11826 from panda44312/patch-6
i18n: zh-cn
2025-03-19 06:24:09 -07:00
Timothy Jaeryang Baek f28bec876e Merge pull request #11853 from genjuro214/fix-issue-11847
fix: correct incorrect usage of log.exception method
2025-03-19 06:22:56 -07:00
hurxxxx 11f2aaf7b1 feat: Automatically enter edit mode when creating a new folder 2025-03-19 21:55:15 +09:00
leilibj 3e8546135d fix: correct incorrect usage of log.exception method 2025-03-19 13:04:34 +08:00
hurxxxx 70610f8343 feat: add clear ("X") button to sidebar chat search input 2025-03-19 11:25:16 +09:00
Timothy Jaeryang Baek 6c4352de07 fix: table cells format 2025-03-18 17:53:11 -07:00
Tuna Çağlar Gümüş d68a6227ad Turkish language pack updates 2025-03-19 00:15:39 +03:00
Aleix Dorca bda5e0af74 Update Catalan translation.json 2025-03-18 20:08:16 +01:00
Tiancong Li 05fa67ae8f i18n: update zh-TW 2025-03-19 02:54:51 +08:00
hurxxxx 2b687e2c06 Use consistent function names. 2025-03-19 02:23:30 +09:00
hurxxxx 3b624f35ac feat: submit chat title rename with Enter, cancel with ESC 2025-03-19 02:00:58 +09:00
Marko Henning 5ab789e83e Add documentation on chroma special case 2025-03-18 16:44:58 +01:00
Marko Henning ba676b7ed6 Use k_reranker also for result merge, and add special sorting use case for ChromaDB 2025-03-18 16:25:24 +01:00
Panda 87a3a893e2 i18n: zh-cn 2025-03-18 15:54:03 +01:00
Timothy Jaeryang Baek 62e63cffe7 Merge pull request #11804 from djismgaming/spanish-translation-20250317
i18n: translated new language pairs to Spanish
2025-03-18 13:44:45 +00:00
Timothy Jaeryang Baek 20128e6aa8 Merge branch 'dev' into spanish-translation-20250317 2025-03-18 13:44:32 +00:00
Timothy Jaeryang Baek 9a9a8a4b28 Merge pull request #11808 from FarisHijazi/dev
FIX #5682 dates not surviving importing
2025-03-18 13:43:41 +00:00
Timothy Jaeryang Baek b609b9d297 chore: format 2025-03-18 06:39:42 -07:00
Timothy Jaeryang Baek 3186aeac08 chore: format 2025-03-18 06:39:37 -07:00
Marko Henning f13948d805 Fixed typo 2025-03-18 12:14:59 +01:00
Marko Henning c877b59cbc Address edge case with k < k_reranker, sort results for cutting off 2025-03-18 11:31:17 +01:00
Jan Kessler e68cd9b671 Redis Sentinel support for AppConfig 2025-03-18 09:30:18 +01:00
Jan Kessler 9167a8bef0 refac as prep for sentinel support in AppConfig 2025-03-18 08:44:50 +01:00
Jan Kessler 9bf663934a fixes & add envs for Sentinel implementation 2025-03-18 07:49:57 +01:00
Jan Kessler 4370332e32 second part of adding Redis Sentinel support 2025-03-18 07:49:57 +01:00
Jan Kessler 3b357746d8 first part of adding Redis Sentinel support 2025-03-18 07:49:56 +01:00
Faris Hijazi eff1a23c19 fixed 5682 dates not surviving importing
Simply replaced the frontend svelte code that does the importing. One of
the keys were wrong, it was `convo['timestamp']` instead of
`convo['create_time']`
2025-03-18 09:23:52 +03:00
djismgaming e2f6cdcf31 translated new languague pairs to Spanish 2025-03-17 23:18:24 -04:00
Timothy Jaeryang Baek f5c5a4119f Merge pull request #11785 from SadmL/dev
[i18n] Almost full Russian localization (rebased)
2025-03-18 01:28:02 +00:00
Timothy Jaeryang Baek 47d5e21061 Merge pull request #11786 from taylorwilsdon/scroll_enh
fix: Better visibility for scroll bar in webkit browsers
2025-03-18 01:25:58 +00:00
SadmL 9a55071d65 Update translation.json 2025-03-17 20:02:22 +03:00
Taylor Wilsdon 1119b06678 Widen scrollbar so that it can be clicked and dragged with a mouse or trackpad 2025-03-17 13:00:55 -04:00
Timothy Jaeryang Baek 54d5281256 refac 2025-03-15 18:41:04 +00:00
Timothy Jaeryang Baek 0777ab7011 refac 2025-03-15 18:28:02 +00:00
Timothy Jaeryang Baek d8eade736f refac 2025-03-15 18:23:40 +00:00
Timothy Jaeryang Baek b6e56c0e5a refac 2025-03-15 18:00:18 +00:00
Timothy Jaeryang Baek cfb2319923 refac: disable autocompletion by default 2025-03-15 17:40:39 +00:00
Timothy Jaeryang Baek 7de88cc4eb refac 2025-03-15 17:19:22 +00:00
Timothy Jaeryang Baek 10b9fa6ed0 refac 2025-03-15 17:16:19 +00:00
Timothy Jaeryang Baek 4f5aba8be7 refac 2025-03-15 17:13:40 +00:00
Timothy Jaeryang Baek d93828e923 enh: client-side pdf generation 2025-03-15 17:11:14 +00:00
Timothy Jaeryang Baek c57db1828f fix 2025-03-15 16:25:21 +00:00
Timothy Jaeryang Baek b5e74f4eae refac 2025-03-15 16:15:37 +00:00
Timothy Jaeryang Baek c8d02aafd9 Merge pull request #11697 from foraxe/fix-keep-alive-top-level
fix: Promote keep_alive to top-level in payload for Ollama API (dev branch)
2025-03-15 14:43:27 +00:00
Timothy Jaeryang Baek f1a84f45c8 Merge pull request #11699 from shaun-gallagher-octane/markdown-alert-renderer
feat: Markdown alert renderer
2025-03-15 02:12:16 +00:00
Timothy Jaeryang Baek 0551b90d47 Merge pull request #11611 from nikes/feat/pip-options
feat: pip options in tools/functions install requirements
2025-03-15 02:07:31 +00:00
Timothy Jaeryang Baek 2de03494a6 Merge pull request #11647 from kaytwo/multiline-valves
feat: allow string valves to include newlines
2025-03-15 02:05:56 +00:00
Timothy Jaeryang Baek aa37866798 chore: pyodide bump 2025-03-15 02:04:18 +00:00
Timothy Jaeryang Baek 7723705707 enh: always collapse code block 2025-03-15 02:01:59 +00:00
Timothy Jaeryang Baek dbf051ff4e enh: always expand details option 2025-03-15 01:56:04 +00:00
Shaun Gallagher 2065580a5f feat: Use AlertRenderer in blockquote evaluation 2025-03-14 15:22:56 -04:00
Shaun Gallagher 8bc797fe6a feat: Add renderer for Markdown alerts 2025-03-14 15:22:27 -04:00
foraxe 04046ed6c8 Alternative Temp Fix: Uncomment to Keep Alive Forever 2025-03-15 00:27:20 +08:00
foraxe 53448b3f0a Fix: Promote keep_alive to top-level in payload for Ollama API on dev branch 2025-03-15 00:08:05 +08:00
Timothy Jaeryang Baek 74da9c4e54 fix: firefox input height issue 2025-03-14 12:50:33 +00:00
Timothy Jaeryang Baek 4b1899bfca chore: dependency 2025-03-14 12:35:33 +00:00
Timothy Jaeryang Baek 8588b52fd6 Merge pull request #11605 from melroy89/update_fake_useragent
dependency: Update fake-useragent to v2.1.0
2025-03-14 11:52:30 +00:00
Timothy Jaeryang Baek 4025d61c33 Merge pull request #11636 from OliveiraHermogenes/fix-api-audio-mimetype-check
fix: audio api endpoint filetype check
2025-03-14 11:52:12 +00:00
Chris Kanich 53292e7d12 allow string valves to include newlines 2025-03-13 12:04:13 -05:00
Nikes b6664f5ae8 feat: pip options in tools/functions install requirements 2025-03-12 23:15:26 +02:00
Melroy van den Berg b02e6c6734 Deps: Update fake-useragent to v2.1.0 2025-03-12 20:41:51 +01:00
Timothy Jaeryang Baek d84e7d13d0 refac 2025-03-11 20:49:14 +00:00
Timothy Jaeryang Baek c309412980 enh: connection tags 2025-03-11 20:37:30 +00:00
Timothy Jaeryang Baek b427f506f6 fix 2025-03-11 20:12:34 +00:00
Timothy Jaeryang Baek ad51b0166c refac 2025-03-11 20:12:28 +00:00
Timothy Jaeryang Baek e1168139a6 refac 2025-03-11 19:30:09 +00:00
Timothy Jaeryang Baek 9e2fb73440 Merge pull request #11550 from silentoplayz/highlight-selected
feat: improve model dropdown highlight
2025-03-11 18:57:20 +00:00
Timothy Jaeryang Baek adfa67d9d2 Merge pull request #11507 from OrenZhang/feat_ot
feat(trace): opentelemetry instrument
2025-03-11 18:55:56 +00:00
Timothy Jaeryang Baek 396c28817c refac 2025-03-11 18:55:30 +00:00
Timothy Jaeryang Baek 7e0c5a44e3 Merge pull request #11552 from icsy7867/dev
Fixed knowledgebase view for decodeURIComponent
2025-03-11 18:53:17 +00:00
root 6fc9c69b9f Fixed knowledgebase view for decodeURIComponent 2025-03-11 14:44:04 -04:00
Timothy Jaeryang Baek 9adc1ebcc8 refac 2025-03-11 18:38:13 +00:00
Timothy Jaeryang Baek 6399aaf4b2 Merge pull request #11515 from davizucon/main
fix: url value when response contains url tag
2025-03-11 18:37:05 +00:00
Timothy Jaeryang Baek 16c383ab44 Merge pull request #11498 from tcgumus/patch-2
Add SSL Integration Comments and WebSocket Auto-Fix in start_windows.bat
2025-03-11 18:34:54 +00:00
Timothy Jaeryang Baek 8905d9e63b Merge pull request #11526 from rragundez/exlude-images-from-embeddings
Exlude images from embeddings
2025-03-11 18:32:38 +00:00
Timothy Jaeryang Baek 64fa37c15d Merge pull request #11545 from hurxxxx/fix/reset-taskId-after-conversation
fix: Reset taskId after completion to prevent incorrect cancellation behavior
2025-03-11 18:29:59 +00:00
Silentoplayz 2c99366543 feat: improve model dropdown highlight 2025-03-11 13:57:09 -04:00
Davi S. Zucon b1224b6839 fix code format 2025-03-11 12:40:31 -03:00
Davi S. Zucon 453200ca1e fix code format
fix code format
2025-03-11 12:39:15 -03:00
hurxxxx 1160e21d45 fix: reset taskId after conversation to prevent incorrect cancellation behavior 2025-03-11 21:18:53 +09:00
Timothy Jaeryang Baek 0e7164b4f5 Merge pull request #11520 from icsy7867/main
Added decodeURIcomponent function to several locations.
2025-03-11 10:33:41 +00:00
Rodrigo Agundez ac321f4b64 Add check if images and avoid trying to calculate embeddings 2025-03-11 12:50:44 +08:00
orenzhang 7bfda6652f feat(trace): optimize for trace env and instrument hooks 2025-03-11 11:53:03 +08:00
root 3fb477618e Added decodeURIcomponent function to several locations. 2025-03-10 17:40:40 -04:00
Tuna Çağlar Gümüş 9e7193b3d2 Update start_windows.bat 2025-03-10 22:37:57 +03:00
Davi S. Zucon 846509bf9f fix url value when response contains url tag
When the response contains b64 json, the "url" tag still exists but comes as null, the logic is to resolve this point where it contains the url tag, but empty, thus directing to b64_json
2025-03-10 15:59:08 -03:00
orenzhang c761e4fd08 feat(trace): opentelemetry instrument 2025-03-10 22:27:31 +08:00
Marko Henning 8b5b3f165a Add translation (partially) 2025-03-10 12:35:44 +01:00
Timothy Jaeryang Baek c2046b9247 refac 2025-03-10 11:16:30 +00:00
Tuna Çağlar Gümüş c5efeb2499 Add SSL Integration Comments and WebSocket Auto-Fix in start_windows.bat
This PR enhances the start_windows.bat script by:

Adding comments for SSL integration, making it easier for users to enable SSL when needed.
Including the -ws auto flag to address WebSocket-related issues, ensuring a smoother experience for users facing connection problems.
These improvements make the script more user-friendly and help troubleshoot common setup challenges.
2025-03-10 13:39:59 +03:00
Timothy Jaeryang Baek 116e0559f6 refac: oauth 2025-03-10 09:42:59 +00:00
Timothy Jaeryang Baek 428d89784b Merge pull request #11484 from xinhai-ai/mobile_touch
fix: mobile touch
2025-03-10 06:27:35 -03:00
星海 077c805880 fix: mobile touch 2025-03-10 15:43:53 +08:00
Timothy Jaeryang Baek 352ed67667 refac: prompt template behaviour 2025-03-10 01:15:45 +00:00
Timothy Jaeryang Baek ec29772068 refac: styling 2025-03-09 23:58:48 +00:00
Timothy Jaeryang Baek 3cc44b9de5 Merge pull request #11460 from silentoplayz/horizontal-scrol-tags2
feat: Horizontal scroll for tags in model dropdown selector
2025-03-09 20:58:05 -03:00
Timothy Jaeryang Baek f8ac44cfbd Merge pull request #11464 from FabioPolito24/docling_context_extraction_engine
feat: Docling context extraction engine
2025-03-09 20:57:48 -03:00
Timothy Jaeryang Baek 0c5a1e84e3 Merge pull request #11472 from thiswillbeyourgithub/better_frontmatter_pip_install3
fix: install all the requirements at the same time
2025-03-09 20:57:06 -03:00
Timothy Jaeryang Baek 1fe4d49ac6 Merge pull request #11466 from captn-hook/dev
fix: opensearch environment variables fix
2025-03-09 20:56:34 -03:00
Timothy Jaeryang Baek 41a83f1dc5 refac: code update behaviour 2025-03-09 23:49:26 +00:00
thiswillbeyourgithub 9e41b8b024 fix: install all the requirements at the same time
Signed-off-by: thiswillbeyourgithub <26625900+thiswillbeyourgithub@users.noreply.github.com>
2025-03-09 23:38:23 +01:00
Tristan H 4c98508c0b Merge pull request #3 from open-webui/dev
Dev
2025-03-09 09:51:31 -07:00
Tristan H 084e2caf6f Update config.py to correctly handle environment strings
As seen in line 1536 just above this, strings need to be converted to bools in order to work.
2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek a75f82755d Update bug_report.yaml 2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek c1d2e0d999 refac 2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek 165e861a1d Create config.yaml 2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek fc056b1397 Update bug_report.yaml 2025-03-09 09:48:35 -07:00
Fabio Polito de0f158b04 Merge remote-tracking branch 'upstream/dev' into docling_context_extraction_engine
merge upstream dev
2025-03-09 16:12:43 +00:00
Fabio Polito 9d6743824e fix: fix params DoclingLoader 2025-03-09 16:12:14 +00:00
Silentoplayz ae1f0f0a3c feat: horizontal scroll for model dropdown tags 2025-03-09 11:41:14 -04:00
Timothy Jaeryang Baek cdb5eae43a refac 2025-03-09 15:05:13 +00:00
Timothy Jaeryang Baek 26980fb859 refac 2025-03-09 15:01:36 +00:00
Timothy Jaeryang Baekandrecrudesce 53a2acd541 enh: local, external, direct model list filter
Co-Authored-By: recrudesce <6450799+recrudesce@users.noreply.github.com>
2025-03-09 15:00:13 +00:00
Timothy Jaeryang Baek c58f0844a3 refac 2025-03-09 14:48:11 +00:00
Timothy Jaeryang Baek 5cc43ae880 enh: image panzoom 2025-03-09 14:39:54 +00:00
Timothy Jaeryang Baek 1c211e32b3 refac 2025-03-09 11:18:28 +00:00
Timothy Jaeryang Baek 0dce81f646 Merge pull request #11423 from silentoplayz/confirmation-prompts
feat: add more confirmation prompts
2025-03-09 07:40:36 -03:00
Timothy Jaeryang Baek b8863545d5 Merge pull request #11428 from silentoplayz/sticky-tags
feat: Sticky tags
2025-03-09 07:32:05 -03:00
Timothy Jaeryang Baek 36dd630057 Merge pull request #11444 from open-webui/main
dev
2025-03-09 07:31:02 -03:00
Silentoplayz 1de142f435 sticky tags 2025-03-08 18:00:06 -05:00
Hermógenes Oliveira e936d7b53d fix: audio api endpoint filetype check
RFC2046 allows the Content-Type field to have additional parameters
after the main type/subtype information (Section 1).

Following RFC4281, many applications put codec information inside
parameters in the Content-Type. This is especially common for formats
that support many codecs, such as Ogg (RFC5334, Section 4).

The `/api/audio/transcriptions` endpoint is currently rejecting files
that contain parameters in the Content-Type field with a bad request
error.

This commit changes the current check in order to accept any
Content-Type field that begins with a supported type/subtype as listed
in the `supported_filetypes` tuple.

Since Content-Type here is provided by the user, I believe this check
is meant to prevent honest mistakes, like posting a PDF to an audio
processing endpoint, not as a security measure against possibly
malicious use. Therefore, I think it's OK not to validate the rest of
the field.
2025-03-08 18:03:30 -03:00
Fabio Polito 0aa42615f9 Merge remote-tracking branch 'upstream/dev' into docling_context_extraction_engine
merge upstream
2025-03-08 18:52:51 +00:00
Silentoplayz d669932137 add confirmation prompts 2025-03-08 13:42:24 -05:00
Timothy Jaeryang Baek 6b806ae12f Merge pull request #11413 from hurxxxx/fix/global-filter-cannot-disable
fix: Prevent modification of global filter settings in "Admin panel" - "Settings" - "Models"
2025-03-08 14:15:47 -04:00
Timothy Jaeryang Baek b03fc97e28 Update bug_report.yaml 2025-03-08 18:13:28 +00:00
Timothy Jaeryang Baek d7bfa395b0 refac 2025-03-08 18:10:34 +00:00
Timothy Jaeryang Baek 67cd4f2ab1 Create config.yaml 2025-03-08 18:08:51 +00:00
Timothy Jaeryang Baek dc78d2f758 Update bug_report.yaml 2025-03-08 18:06:08 +00:00
Timothy Jaeryang Baek d40e696468 enh: multiple replica app state sync 2025-03-08 17:59:15 +00:00
Timothy Jaeryang Baek 324cd94c53 refac: user message delete 2025-03-08 17:03:08 +00:00
Timothy Jaeryang Baek 4e36b9794f fix: logit bias issue 2025-03-08 16:50:30 +00:00
Timothy Jaeryang Baek 58ff221d1f refac 2025-03-08 16:48:32 +00:00
Timothy Jaeryang Baek 1e36282708 refac 2025-03-08 16:23:20 +00:00
Timothy Jaeryang Baek 22b88f9593 Merge pull request #11324 from kela4/main
fix: opensearch vector db query structures, result mapping, filters, bulk query actions, knn_vector usage
2025-03-08 12:19:38 -04:00
Timothy Jaeryang Baek e4a79a0b0e Merge pull request #11394 from dannyl1u/fix/logit-bias-workspace-models
fix: logit_bias bug in workspace models
2025-03-08 12:11:53 -04:00
Timothy Jaeryang Baek 8375c2c32d Merge pull request #11397 from luke3butler/tavily-extract-depth
feat: Add environment variable to configure tavily extract depth
2025-03-08 12:11:37 -04:00
Timothy Jaeryang Baek e7013d45c5 Merge pull request #11409 from chengscott/patch-1
[feat] anonymous bind when LDAP_APP_DN is not specified
2025-03-08 12:11:13 -04:00
Timothy Jaeryang Baek ac507720c8 Merge pull request #11401 from TiancongLx/dev
i18n: update zh-TW
2025-03-08 12:10:58 -04:00
Timothy Jaeryang Baek d59433bf7e refac 2025-03-08 15:48:09 +00:00
hurxxxx 40257afeed fix: Using disabled attribute for elements is common 2025-03-09 00:12:37 +09:00
hurxxxx 851931fdf6 fix: Global filter cannot be modified in the model (changes have no effect) 2025-03-08 23:24:52 +09:00
Scott Cheng 8bd527be6a anonymous bind when LDAP_APP_DN is not specified 2025-03-08 21:19:21 +08:00
Tiancong Li e5b02f732b i18n: update zh-TW 2025-03-08 16:18:22 +08:00
Luke 7c4a9c2aea fix: correct type handling for TAVILY_EXTRACT_DEPTH configuration 2025-03-08 01:07:06 -05:00
Luke 7917128ed3 enh: enable configuration for tavily extract depth 2025-03-08 00:43:02 -05:00
dannyl1u 3c6a834ff4 fix: logit_bias in workspace models 2025-03-07 19:54:06 -08:00
Timothy Jaeryang Baek dc3abc2bcb refac 2025-03-08 00:13:58 +00:00
Timothy Jaeryang Baek 3ab917cdd0 Merge pull request #11325 from luke3butler/dev
feat: Add `tavily` as a `RAG_WEB_LOADER_ENGINE` option via extract API
2025-03-07 20:11:39 -04:00
Timothy Jaeryang Baek e3116aac76 Merge pull request #11361 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-03-07 19:59:42 -04:00
Timothy Jaeryang Baek e85d69fe60 Merge branch 'dev' into dev 2025-03-07 19:59:33 -04:00
Timothy Jaeryang Baek 027c5c4b72 Merge pull request #11383 from Kylapaallikko/dev
i18n: Update Finnish Translation
2025-03-07 19:59:03 -04:00
Timothy Jaeryang Baek 88303093c0 Merge pull request #11384 from Peter-De-Ath/hotfix/tool-calls-params
fix: bug where default params in tools calls always used
2025-03-07 19:58:05 -04:00
Peter De-Ath 7e3be60f0e formating 2025-03-07 20:37:22 +00:00
Peter De-Ath 3b39807377 fix: updatepost_response_handler nativ tools handler to use parameters from properties fixing defaults always used 2025-03-07 20:07:36 +00:00
Kylapaallikko 17a4360566 Update translation.json 2025-03-07 19:44:56 +02:00
Timothy Jaeryang Baek 7a1cf1095a Merge pull request #11358 from OrenZhang/i18n_translation
i18n(common): add i18n translation
2025-03-07 13:33:59 -04:00
Timothy Jaeryang Baek ab92737a9a refac 2025-03-07 17:24:52 +00:00
Simon 27a6d1917d Update translation.json 2025-03-07 13:48:56 +01:00
Simon 25544b38bd Merge branch 'open-webui:dev' into dev 2025-03-07 13:42:46 +01:00
Simon cf449d1c7e Update translation.json 2025-03-07 13:42:05 +01:00
Simon 20826e15b3 Update translation.json 2025-03-07 13:31:56 +01:00
Timothy Jaeryang Baek d7a0437438 refac: model sort list 2025-03-07 12:16:16 +00:00
orenzhang b61ea393b4 refactor(lint): code lint 2025-03-07 20:03:53 +08:00
orenzhang dc04123330 Merge branch 'dev' into i18n_translation 2025-03-07 20:03:33 +08:00
orenzhang 72ea6dd9f1 refactor(lint): code lint 2025-03-07 19:59:09 +08:00
Timothy Jaeryang Baek f8f54237c6 Merge pull request #11290 from panda44312/patch-5
i18n: Update zh-cn
2025-03-07 06:06:40 -04:00
Timothy Jaeryang Baek 04921be9f1 Merge pull request #11287 from Youggls/dev
fix(chat): resolve duplicate collapsible IDs causing citation modal failures
2025-03-07 06:05:13 -04:00
Fabio Polito 1a066c0357 fix: fixed lock and pyproject 2025-03-07 00:32:02 +00:00
Fabio Polito 09ccd000d8 fix: fixing lock and pyproject 2025-03-07 00:27:59 +00:00
Fabio Polito e3eef58310 feat: merge with dev 2025-03-07 00:22:47 +00:00
Fabio Polito 17dbb3ec57 fix: fix typo 2025-03-07 00:19:26 +00:00
Peter De-Ath d862295a05 fix: update chat completion tools handler to use parameters from spec fixing defaults always used 2025-03-06 23:27:52 +00:00
Luke 987954c817 feat: Add Tavily extract web loader integration 2025-03-06 18:15:18 -05:00
Katharina 6cb0c0339a fix: opensearch vector db query structures, result mapping, filters, bulk query actions, knn_vector usage 2025-03-06 23:49:54 +01:00
wangtunan 7d1a094f9b fix: stopTask func need use async/await syntax 2025-03-07 00:07:04 +08:00
wangtunan 533c99e384 fix: stopTask func need use async/await syntax 2025-03-06 23:56:41 +08:00
orenzhang 92fb1109b6 i18n(common): add i18n translation 2025-03-06 20:16:34 +08:00
Fabio Polito 98857184ff Merge remote-tracking branch 'upstream/dev' into docling_context_extraction_engine
merge with dev branch
2025-03-06 12:12:50 +00:00
Panda 98376fbbce i18n: zh-cn 2025-03-06 13:08:35 +01:00
Panda 561f4d5d69 i18n: zh-cn 2025-03-06 11:26:04 +01:00
Perry Li 9cc9df3018 fix(chat): resolve duplicate collapsible IDs causing citation modal failures
Fix an issue where clicking inline citations in subsequent chat messages failed to open the citation modal when multiple collapsible sections are present.

The root cause was duplicate "collapsible-sources" IDs assigned to all Collapsible components. This led document.getElementById() to always return the first instance, preventing subsequent messages from opening their CitationModal.

Changes:
- Modify Collapsible ID generation in Citations.svelte to use unique IDs with "collapsible-${message.id}" pattern
- Update ResponseMessage.svelte's onSourceClick handler to reference the dynamic collapsible IDs
- Ensure proper citation modal binding for each chat message's sources

Affected components:
- Collapsible (expandable content sections)
- CitationsModal (citation detail popup)

This ensures each chat message's sources are independently collapsible and maintains proper citation modal binding throughout message history.
2025-03-06 10:10:53 +00:00
Marko Henning 41a4cf7106 Added new k_reranker parameter 2025-03-06 10:47:57 +01:00
Timothy Jaeryang Baek 3b70cd64d7 Merge pull request #11257 from open-webui/dev
0.5.20
2025-03-05 19:44:45 -08:00
Timothy Jaeryang Baek 1173459eee doc: changelog 2025-03-05 19:42:41 -08:00
Timothy Jaeryang Baek c0ecff9d70 fix: pinyin keyboard 2025-03-05 19:36:30 -08:00
Timothy Jaeryang Baek d4fca9dabf chore: format 2025-03-05 19:17:41 -08:00
Timothy Jaeryang Baek aaaebfabbe chore: format 2025-03-05 19:16:59 -08:00
Timothy Jaeryang Baek 6eccf8668e fix: non-root container issue 2025-03-05 19:14:43 -08:00
Timothy Jaeryang Baekandrecrudesce 5e78a5c510 enh: enable_code_execution toggle
Co-Authored-By: recrudesce <6450799+recrudesce@users.noreply.github.com>
2025-03-05 19:06:28 -08:00
Timothy Jaeryang Baek dce24225e0 Merge pull request #11249 from TiancongLx/dev
i18n: update zh-TW
2025-03-05 18:52:34 -08:00
Timothy Jaeryang Baek f9b44c6bf2 refac 2025-03-05 18:39:32 -08:00
Timothy Jaeryang Baek f573a90a56 Merge pull request #11254 from open-webui/main
dev
2025-03-05 18:11:18 -08:00
Timothy Jaeryang Baek 23bb0d927f chore: format 2025-03-05 18:10:24 -08:00
Fabio Polito 2982893d0d fix: format fixes 2025-03-06 00:39:00 +00:00
Tiancong Li 6b0af942db i18n: update zh-TW 2025-03-06 07:23:55 +08:00
Fabio Polito 0716f96da8 style: change style in DoclingLoader 2025-03-05 23:15:55 +00:00
Timothy Jaeryang Baek f95a482c24 Merge pull request #11209 from aleixdorca/dev
Update catalan translation.json
2025-03-05 14:56:37 -08:00
Timothy Jaeryang Baek edf94d732b refac 2025-03-05 14:54:48 -08:00
Fabio Polito 9aa407dbd2 feat: merge with main 2025-03-05 22:04:34 +00:00
Timothy Jaeryang Baek 73a77abd20 Merge pull request #11245 from tupe2009/kleqon.fix-es-bugs
fix: ElasticSearch indexes delete and upsert bugs(Post 0.5.19 release)
2025-03-05 13:36:55 -08:00
ofek a8f205213c fixed es bugs 2025-03-05 23:19:56 +02:00
Fabio Polito a44b35e99e fix: fix DoclingLoader input params 2025-03-05 17:53:45 +00:00
Timothy Jaeryang Baek 4770285c04 Merge pull request #11211 from open-webui/dev
fix: site.webmanifest
2025-03-05 00:45:21 -08:00
Timothy Jaeryang Baek 1639fbb544 fix: site.webmanifest 2025-03-05 00:45:04 -08:00
Aleix Dorca 2dc5f3c30c Update catalan translation.json 2025-03-05 08:42:16 +01:00
tarmst 1ad80490de Add read/write access control for files from knowledge 2025-03-03 20:03:21 +00:00
Fabio Polito 2419ef06a0 feat: docling support for document preprocessing 2025-02-14 12:08:03 +00:00
JT 40dea3fbe1 Merge branch 'dev' into main 2025-02-05 15:15:24 -08:00
jayteaftw 157c781b0a Merge branch 'main' of https://github.com/jayteaftw/open-webui 2025-02-05 14:07:59 -08:00
jayteaftw 6d2f87e904 Added server side Prefixing 2025-02-05 14:03:16 -08:00
JT 81102f4be2 Merge branch 'open-webui:main' into main 2025-02-04 13:06:04 -08:00
jvinolus 7b8e5d4e7c Fixed errors and added more support 2025-02-04 13:04:36 -08:00
Matteo Sirri 22c100bb6b feat: add contributing guide 2025-02-03 16:45:25 +00:00
Matteo Sirri e6715ce8b8 docs: fix readme 2025-02-03 16:43:59 +00:00
Matteo Sirri 65443a3a66 feat: initial commit 2025-02-03 16:35:46 +00:00
jvinolus 47b8412695 Initialize support for prefixing embeddings 2025-01-15 17:05:04 -08:00
226 changed files with 17953 additions and 4483 deletions
+5 -3
View File
@@ -27,6 +27,8 @@ body:
options:
- label: I have searched the existing issues and discussions.
required: true
- label: I am using the latest version of Open WebUI.
required: true
- type: dropdown
id: installation-method
@@ -83,9 +85,9 @@ body:
required: true
- label: I am using the latest version of **both** Open WebUI and Ollama.
required: true
- label: I have checked the browser console logs.
- label: I have included the browser console logs.
required: true
- label: I have checked the Docker container logs.
- label: I have included the Docker container logs.
required: true
- label: I have listed steps to reproduce the bug in detail.
required: true
@@ -110,7 +112,7 @@ body:
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Provide step-by-step instructions to reproduce the issue.
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.
placeholder: |
1. Go to '...'
2. Click on '...'
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+14
View File
@@ -1,12 +1,26 @@
version: 2
updates:
- package-ecosystem: uv
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
- package-ecosystem: pip
directory: '/backend'
schedule:
interval: monthly
target-branch: 'dev'
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
# Check for updates to GitHub Actions every week
interval: monthly
target-branch: 'dev'
+3 -3
View File
@@ -9,9 +9,9 @@
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Have you written and run sufficient tests for validating the changes?
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Prefix:** To cleary categorize this pull request, prefix the pull request title, using one of the following:
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
@@ -22,7 +22,7 @@
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.)
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
+12 -2
View File
@@ -5,10 +5,18 @@ on:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
@@ -17,7 +25,9 @@ jobs:
strategy:
matrix:
python-version: [3.11]
python-version:
- 3.11.x
- 3.12.x
steps:
- uses: actions/checkout@v4
@@ -25,7 +35,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: '${{ matrix.python-version }}'
- name: Install dependencies
run: |
+9 -1
View File
@@ -5,10 +5,18 @@ on:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
@@ -21,7 +29,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22' # Or specify any other version you want to use
node-version: '22'
- name: Install Dependencies
run: npm install
+102 -1
View File
@@ -5,7 +5,108 @@ 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.5.19] - 2024-03-04
## [0.6.2] - 2025-04-06
### Added
- 🌍 **Improved Global Language Support**: Expanded and refined translations across multiple languages to enhance clarity and consistency for international users.
### Fixed
- 🛠️ **Accurate Tool Descriptions from OpenAPI Servers**: External tools now use full endpoint descriptions instead of summaries when generating tool specifications—helping AI models understand tool purpose more precisely and choose the right tool more accurately in tool workflows.
- 🔧 **Precise Web Results Source Attribution**: Fixed a key issue where all web search results showed the same source ID—now each result gets its correct and distinct source, ensuring accurate citations and traceability.
- 🔍 **Clean Web Search Retrieval**: Web search now retains only results from URLs where real content was successfully fetched—improving accuracy and removing empty or broken links from citations.
- 🎵 **Audio File Upload Response Restored**: Resolved an issue where uploading audio files did not return valid responses, restoring smooth file handling for transcription and audio-based workflows.
### Changed
- 🧰 **General Backend Refactoring**: Multiple behind-the-scenes improvements streamline backend performance, reduce complexity, and ensure a more stable, maintainable system overall—making everything smoother without changing your workflow.
## [0.6.1] - 2025-04-05
### Added
- 🛠️ **Global Tool Servers Configuration**: Admins can now centrally configure global external tool servers from Admin Settings > Tools, allowing seamless sharing of tool integrations across all users without manual setup per user.
- 🔐 **Direct Tool Usage Permission for Users**: Introduced a new user-level permission toggle that grants non-admin users access to direct external tools, empowering broader team collaboration while maintaining control.
- 🧠 **Mistral OCR Content Extraction Support**: Added native support for Mistral OCR as a high-accuracy document loader, drastically improving text extraction from scanned documents in RAG workflows.
- 🖼️ **Tools Indicator UI Redesign**: Enhanced message input now smartly displays both built-in and external tools via a unified dropdown, making it simpler and more intuitive to activate tools during conversations.
- 📄 **RAG Prompt Improved and More Coherent**: Default RAG system prompt has been revised to be more clear and citation-focused—admins can leave the template field empty to use this new gold-standard prompt.
- 🧰 **Performance & Developer Improvements**: Major internal restructuring of several tool-related components, simplifying styling and merging external/internal handling logic, resulting in better maintainability and performance.
- 🌍 **Improved Translations**: Updated translations for Tibetan, Polish, Chinese (Simplified & Traditional), Arabic, Russian, Ukrainian, Dutch, Finnish, and French to improve clarity and consistency across the interface.
### Fixed
- 🔑 **External Tool Server API Key Bug Resolved**: Fixed a critical issue where authentication headers were not being sent when calling tools from external OpenAPI tool servers, ensuring full security and smooth tool operations.
- 🚫 **Conditional Export Button Visibility**: UI now gracefully hides export buttons when there's nothing to export in models, prompts, tools, or functions, improving visual clarity and reducing confusion.
- 🧪 **Hybrid Search Failure Recovery**: Resolved edge case in parallel hybrid search where empty or unindexed collections caused backend crashes—these are now cleanly skipped to ensure system stability.
- 📂 **Admin Folder Deletion Fix**: Addressed an issue where folders created in the admin workspace couldn't be deleted, restoring full organizational flexibility for admins.
- 🔐 **Improved Generic Error Feedback on Login**: Authentication errors now show simplified, non-revealing messages for privacy and improved UX, especially with federated logins.
- 📝 **Tool Message with Images Improved**: Enhanced how tool-generated messages with image outputs are shown in chat, making them more readable and consistent with the overall UI design.
- ⚙️ **Auto-Exclusion for Broken RAG Collections**: Auto-skips document collections that fail to fetch data or return "None", preventing silent errors and streamlining retrieval workflows.
- 📝 **Docling Text File Handling Fix**: Fixed file parsing inconsistency that broke docling-based RAG functionality for certain plain text files, ensuring wider file compatibility.
## [0.6.0] - 2025-03-31
### Added
- 🧩 **External Tool Server Support via OpenAPI**: Connect Open WebUI to any OpenAPI-compatible REST server instantly—offering immediate integration with thousands of developer tools, SDKs, and SaaS systems for powerful extensibility. Learn more: https://github.com/open-webui/openapi-servers
- 🛠️ **MCP Server Support via MCPO**: You can now convert and expose your internal MCP tools as interoperable OpenAPI HTTP servers within Open WebUI for seamless, plug-n-play AI toolchain creation. Learn more: https://github.com/open-webui/mcpo
- 📨 **/messages Chat API Endpoint Support**: For power users building external AI systems, new endpoints allow precise control of messages asynchronously—feed long-running external responses into Open WebUI chats without coupling with the frontend.
- 📝 **Client-Side PDF Generation**: PDF exports are now generated fully client-side for drastically improved output quality—perfect for saving conversations or documents.
- 💼 **Enforced Temporary Chats Mode**: Admins can now enforce temporary chat sessions by default to align with stringent data retention and compliance requirements.
- 🌍 **Public Resource Sharing Permission Controls**: Fine-grained user group permissions now allow enabling/disabling public sharing for models, knowledge, prompts, and tools—ideal for privacy, team control, and internal deployments.
- 📦 **Custom pip Options for Tools/Functions**: You can now specify custom pip installation options with "PIP_OPTIONS", "PIP_PACKAGE_INDEX_OPTIONS" environment variables—improving compatibility, support for private indexes, and better control over Python environments.
- 🔢 **Editable Message Counter**: You can now double-click the message count number and jump straight to editing the index—quickly navigate complex chats or regenerate specific messages precisely.
- 🧠 **Embedding Prefix Support Added**: Add custom prefixes to your embeddings for instruct-style tokens, enabling stronger model alignment and more consistent RAG performance.
- 🙈 **Ability to Hide Base Models**: Optionally hide base models from the UI, helping users streamline model visibility and limit access to only usable endpoints..
- 📚 **Docling Content Extraction Support**: Open WebUI now supports Docling as a content extraction engine, enabling smarter and more accurate parsing of complex file formats—ideal for advanced document understanding and Retrieval-Augmented Generation (RAG) workflows.
- 🗃️ **Redis Sentinel Support Added**: Enhance deployment redundancy with support for Redis Sentinel for highly available, failover-safe Redis-based caching or pub/sub.
- 📚 **JSON Schema Format for Ollama**: Added support for defining the format using JSON schema in Ollama-compatible models, improving flexibility and validation of model outputs.
- 🔍 **Chat Sidebar Search "Clear” Button**: Quickly clear search filters in chat sidebar using the new ✖️ button—streamline your chat navigation with one click.
- 🗂️ **Auto-Focus + Enter Submit for Folder Name**: When creating a new folder, the system automatically enters rename mode with name preselected—simplifying your org workflow.
- 🧱 **Markdown Alerts Rendering**: Blockquotes with syntax hinting (e.g. ⚠️, ℹ️, ✅) now render styled Markdown alert banners, making messages and documentation more visually structured.
- 🔁 **Hybrid Search Runs in Parallel Now**: Hybrid (BM25 + embedding) search components now run in parallel—dramatically reducing response times and speeding up document retrieval.
- 📋 **Cleaner UI for Tool Call Display**: Optimized the visual layout of called tools inside chat messages for better clarity and reduced visual clutter.
- 🧪 **Playwright Timeout Now Configurable**: Default timeout for Playwright processes is now shorter and adjustable via environment variables—making web scraping more robust and tunable to environments.
- 📈 **OpenTelemetry Support for Observability**: Open WebUI now integrates with OpenTelemetry, allowing you to connect with tools like Grafana, Jaeger, or Prometheus for detailed performance insights and real-time visibility—entirely opt-in and fully self-hosted. Even if enabled, no data is ever sent to us, ensuring your privacy and ownership over all telemetry data.
- 🛠 **General UI Enhancements & UX Polish**: Numerous refinements across sidebar, code blocks, modal interactions, button alignment, scrollbar visibility, and folder behavior improve overall fluidity and usability of the interface.
- 🧱 **General Backend Refactoring**: Numerous backend components have been refactored to improve stability, maintainability, and performance—ensuring a more consistent and reliable system across all features.
- 🌍 **Internationalization Language Support Updates**: Added Estonian and Galician languages, improved Spanish (fully revised), Traditional Chinese, Simplified Chinese, Turkish, Catalan, Ukrainian, and German for a more localized and inclusive interface.
### Fixed
- 🧑‍💻 **Firefox Input Height Bug**: Text input in Firefox now maintains proper height, ensuring message boxes look consistent and behave predictably.
- 🧾 **Tika Blank Line Bug**: PDFs processed with Apache Tika 3.1.0.0 no longer introduce excessive blank lines—improving RAG output quality and visual cleanliness.
- 🧪 **CSV Loader Encoding Issues**: CSV files with unknown encodings now automatically detect character sets, resolving import errors in non-UTF-8 datasets.
- ✅ **LDAP Auth Config Fix**: Path to certificate file is now optional for LDAP setups, fixing authentication trouble for users without preconfigured cert paths.
- 📥 **File Deletion in Bypass Mode**: Resolved issue where files couldn’t be deleted from knowledge when “bypass embedding” mode was enabled.
- 🧩 **Hybrid Search Result Sorting & Deduplication Fixed**: Fixed citation and sorting issues in RAG hybrid and reranker modes, ensuring retrieved documents are shown in correct order per score.
- 🧷 **Model Export/Import Broken for a Single Model**: Fixed bug where individual models couldn’t be exported or re-imported, restoring full portability.
- 📫 **Auth Redirect Fix**: Logged-in users are now routed properly without unnecessary login prompts when already authenticated.
### Changed
- 🧠 **Prompt Autocompletion Disabled By Default**: Autocomplete suggestions while typing are now disabled unless explicitly re-enabled in user preferences—reduces distractions while composing prompts for advanced users.
- 🧾 **Normalize Citation Numbering**: Source citations now properly begin from "1" instead of "0"—improving consistency and professional presentation in AI outputs.
- 📚 **Improved Error Handling from Pipelines**: Pipelines now show the actual returned error message from failed tasks rather than generic "Connection closed"—making debugging far more user-friendly.
### Removed
- 🧾 **ENABLE_AUDIT_LOGS Setting Removed**: Deprecated setting “ENABLE_AUDIT_LOGS” has been fully removed—now controlled via “AUDIT_LOG_LEVEL” instead.
## [0.5.20] - 2025-03-05
### Added
- **⚡ Toggle Code Execution On/Off**: You can now enable or disable code execution, providing more control over security, ensuring a safer and more customizable experience.
### Fixed
- **📜 Pinyin Keyboard Enter Key Now Works Properly**: Resolved an issue where the Enter key for Pinyin keyboards was not functioning as expected, ensuring seamless input for Chinese users.
- **🖼️ Web Manifest Loading Issue Fixed**: Addressed inconsistencies with 'site.webmanifest', guaranteeing proper loading and representation of the app across different browsers and devices.
- **📦 Non-Root Container Issue Resolved**: Fixed a critical issue where the UI failed to load correctly in non-root containers, ensuring reliable deployment in various environments.
## [0.5.19] - 2025-03-04
### Added
+1 -1
View File
@@ -132,7 +132,7 @@ RUN if [ "$USE_OLLAMA" = "true" ]; then \
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install uv && \
RUN pip3 install --no-cache-dir uv && \
if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
+164 -13
View File
@@ -3,6 +3,7 @@ import logging
import os
import shutil
import base64
import redis
from datetime import datetime
from pathlib import Path
@@ -17,6 +18,9 @@ from open_webui.env import (
DATA_DIR,
DATABASE_URL,
ENV,
REDIS_URL,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
FRONTEND_BUILD_DIR,
OFFLINE_MODE,
OPEN_WEBUI_DIR,
@@ -26,6 +30,7 @@ from open_webui.env import (
log,
)
from open_webui.internal.db import Base, get_db
from open_webui.utils.redis import get_redis_connection
class EndpointFilter(logging.Filter):
@@ -248,9 +253,17 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None
def __init__(self):
def __init__(
self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
):
super().__setattr__("_state", {})
if redis_url:
super().__setattr__(
"_redis",
get_redis_connection(redis_url, redis_sentinels, decode_responses=True),
)
def __setattr__(self, key, value):
if isinstance(value, PersistentConfig):
@@ -259,7 +272,31 @@ class AppConfig:
self._state[key].value = value
self._state[key].save()
if self._redis:
redis_key = f"open-webui:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
def __getattr__(self, key):
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
# If Redis is available, check for an updated value
if self._redis:
redis_key = f"open-webui:config:{key}"
redis_value = self._redis.get(redis_key)
if redis_value is not None:
try:
decoded_value = json.loads(redis_value)
# Update the in-memory value if different
if self._state[key].value != decoded_value:
self._state[key].value = decoded_value
log.info(f"Updated {key} from Redis: {decoded_value}")
except json.JSONDecodeError:
log.error(f"Invalid JSON format in Redis for {key}: {redis_value}")
return self._state[key].value
@@ -294,12 +331,14 @@ JWT_EXPIRES_IN = PersistentConfig(
# OAuth config
####################################
ENABLE_OAUTH_SIGNUP = PersistentConfig(
"ENABLE_OAUTH_SIGNUP",
"oauth.enable_signup",
os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true",
)
OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig(
"OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
"oauth.merge_accounts_by_email",
@@ -429,6 +468,7 @@ OAUTH_USERNAME_CLAIM = PersistentConfig(
os.environ.get("OAUTH_USERNAME_CLAIM", "name"),
)
OAUTH_PICTURE_CLAIM = PersistentConfig(
"OAUTH_PICTURE_CLAIM",
"oauth.oidc.avatar_claim",
@@ -593,7 +633,10 @@ for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"):
(FRONTEND_BUILD_DIR / "static")
)
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(file_path, target_path)
try:
shutil.copyfile(file_path, target_path)
except Exception as e:
logging.error(f"An error occurred: {e}")
frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png"
@@ -838,6 +881,17 @@ except Exception:
pass
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
####################################
# TOOL_SERVERS
####################################
TOOL_SERVER_CONNECTIONS = PersistentConfig(
"TOOL_SERVER_CONNECTIONS",
"tool_server.connections",
[],
)
####################################
# WEBUI
####################################
@@ -940,6 +994,35 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
@@ -960,6 +1043,16 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED", "False").lower()
== "true"
)
USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS = (
os.environ.get("USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS", "False").lower()
== "true"
)
USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
)
@@ -982,14 +1075,22 @@ DEFAULT_USER_PERMISSIONS = {
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"sharing": {
"public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING,
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
},
"features": {
"direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
@@ -1052,6 +1153,12 @@ ENABLE_MESSAGE_RATING = PersistentConfig(
os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true",
)
ENABLE_USER_WEBHOOKS = PersistentConfig(
"ENABLE_USER_WEBHOOKS",
"ui.enable_user_webhooks",
os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true",
)
def validate_cors_origins(origins):
for origin in origins:
@@ -1273,7 +1380,7 @@ Strictly return in JSON format:
ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig(
"ENABLE_AUTOCOMPLETE_GENERATION",
"task.autocomplete.enable",
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "True").lower() == "true",
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true",
)
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig(
@@ -1377,6 +1484,11 @@ Responses from models: {{responses}}"""
# Code Interpreter
####################################
ENABLE_CODE_EXECUTION = PersistentConfig(
"ENABLE_CODE_EXECUTION",
"code_execution.enable",
os.environ.get("ENABLE_CODE_EXECUTION", "True").lower() == "true",
)
CODE_EXECUTION_ENGINE = PersistentConfig(
"CODE_EXECUTION_ENGINE",
@@ -1540,8 +1652,10 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", True)
OPENSEARCH_CERT_VERIFY = os.environ.get("OPENSEARCH_CERT_VERIFY", False)
OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true"
OPENSEARCH_CERT_VERIFY = (
os.environ.get("OPENSEARCH_CERT_VERIFY", "false").lower() == "true"
)
OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None)
OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None)
@@ -1553,7 +1667,9 @@ ELASTICSEARCH_USERNAME = os.environ.get("ELASTICSEARCH_USERNAME", None)
ELASTICSEARCH_PASSWORD = os.environ.get("ELASTICSEARCH_PASSWORD", None)
ELASTICSEARCH_CLOUD_ID = os.environ.get("ELASTICSEARCH_CLOUD_ID", None)
SSL_ASSERT_FINGERPRINT = os.environ.get("SSL_ASSERT_FINGERPRINT", None)
ELASTICSEARCH_INDEX_PREFIX = os.environ.get(
"ELASTICSEARCH_INDEX_PREFIX", "open_webui_collections"
)
# Pgvector
PGVECTOR_DB_URL = os.environ.get("PGVECTOR_DB_URL", DATABASE_URL)
if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"):
@@ -1613,6 +1729,12 @@ TIKA_SERVER_URL = PersistentConfig(
os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment
)
DOCLING_SERVER_URL = PersistentConfig(
"DOCLING_SERVER_URL",
"rag.docling_server_url",
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint",
@@ -1625,6 +1747,11 @@ DOCUMENT_INTELLIGENCE_KEY = PersistentConfig(
os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""),
)
MISTRAL_OCR_API_KEY = PersistentConfig(
"MISTRAL_OCR_API_KEY",
"rag.mistral_ocr_api_key",
os.getenv("MISTRAL_OCR_API_KEY", ""),
)
BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
"BYPASS_EMBEDDING_AND_RETRIEVAL",
@@ -1636,6 +1763,11 @@ BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
RAG_TOP_K = PersistentConfig(
"RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3"))
)
RAG_TOP_K_RERANKER = PersistentConfig(
"RAG_TOP_K_RERANKER",
"rag.top_k_reranker",
int(os.environ.get("RAG_TOP_K_RERANKER", "3")),
)
RAG_RELEVANCE_THRESHOLD = PersistentConfig(
"RAG_RELEVANCE_THRESHOLD",
"rag.relevance_threshold",
@@ -1717,6 +1849,14 @@ RAG_EMBEDDING_BATCH_SIZE = PersistentConfig(
),
)
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get(
"RAG_EMBEDDING_PREFIX_FIELD_NAME", None
)
RAG_RERANKING_MODEL = PersistentConfig(
"RAG_RERANKING_MODEL",
"rag.reranking_model",
@@ -1760,7 +1900,7 @@ CHUNK_OVERLAP = PersistentConfig(
)
DEFAULT_RAG_TEMPLATE = """### Task:
Respond to the user query using the provided context, incorporating inline citations in the format [source_id] **only when the <source_id> tag is explicitly provided** in the context.
Respond to the user query using the provided context, incorporating inline citations in the format [id] **only when the <source> tag includes an explicit id attribute** (e.g., <source id="1">).
### Guidelines:
- If you don't know the answer, clearly state that.
@@ -1768,18 +1908,17 @@ Respond to the user query using the provided context, incorporating inline citat
- Respond in the same language as the user's query.
- If the context is unreadable or of poor quality, inform the user and provide the best possible answer.
- If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding.
- **Only include inline citations using [source_id] (e.g., [1], [2]) when a `<source_id>` tag is explicitly provided in the context.**
- Do not cite if the <source_id> tag is not provided in the context.
- **Only include inline citations using [id] (e.g., [1], [2]) when the <source> tag includes an id attribute.**
- Do not cite if the <source> tag does not contain an id attribute.
- Do not use XML tags in your response.
- Ensure citations are concise and directly related to the information provided.
### Example of Citation:
If the user asks about a specific topic and the information is found in "whitepaper.pdf" with a provided <source_id>, the response should include the citation like so:
* "According to the study, the proposed method increases efficiency by 20% [whitepaper.pdf]."
If no <source_id> is present, the response should omit the citation.
If the user asks about a specific topic and the information is found in a source with a provided id attribute, the response should include the citation like in the following example:
* "According to the study, the proposed method increases efficiency by 20% [1]."
### Output:
Provide a clear and direct response to the user's query, including inline citations in the format [source_id] only when the <source_id> tag is present in the context.
Provide a clear and direct response to the user's query, including inline citations in the format [id] only when the <source> tag with id attribute is present in the context.
<context>
{{CONTEXT}}
@@ -1940,6 +2079,12 @@ TAVILY_API_KEY = PersistentConfig(
os.getenv("TAVILY_API_KEY", ""),
)
TAVILY_EXTRACT_DEPTH = PersistentConfig(
"TAVILY_EXTRACT_DEPTH",
"rag.web.search.tavily_extract_depth",
os.getenv("TAVILY_EXTRACT_DEPTH", "basic"),
)
JINA_API_KEY = PersistentConfig(
"JINA_API_KEY",
"rag.web.search.jina_api_key",
@@ -2026,6 +2171,12 @@ PLAYWRIGHT_WS_URI = PersistentConfig(
os.environ.get("PLAYWRIGHT_WS_URI", None),
)
PLAYWRIGHT_TIMEOUT = PersistentConfig(
"PLAYWRIGHT_TIMEOUT",
"rag.web.loader.engine.playwright.timeout",
int(os.environ.get("PLAYWRIGHT_TIMEOUT", "10")),
)
FIRECRAWL_API_KEY = PersistentConfig(
"FIRECRAWL_API_KEY",
"firecrawl.api_key",
+33 -15
View File
@@ -105,7 +105,6 @@ for source in log_sources:
log.setLevel(SRC_LOG_LEVELS["CONFIG"])
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
if WEBUI_NAME != "Open WebUI":
WEBUI_NAME += " (Open WebUI)"
@@ -130,7 +129,6 @@ else:
except Exception:
PACKAGE_DATA = {"version": "0.0.0"}
VERSION = PACKAGE_DATA["version"]
@@ -161,7 +159,6 @@ try:
except Exception:
changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode()
# Convert markdown content to HTML
html_content = markdown.markdown(changelog_content)
@@ -192,7 +189,6 @@ for version in soup.find_all("h2"):
changelog_json[version_number] = version_data
CHANGELOG = changelog_json
####################################
@@ -209,7 +205,6 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
)
####################################
# WEBUI_BUILD_HASH
####################################
@@ -244,7 +239,6 @@ if FROM_INIT_PY:
DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data"))
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static"))
FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts"))
@@ -256,7 +250,6 @@ if FROM_INIT_PY:
os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend")
).resolve()
####################################
# Database
####################################
@@ -321,7 +314,6 @@ RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
@@ -330,7 +322,9 @@ ENABLE_REALTIME_CHAT_SAVE = (
# REDIS
####################################
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
REDIS_URL = os.environ.get("REDIS_URL", "")
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
####################################
# WEBUI_AUTH (Required for security)
@@ -387,6 +381,10 @@ WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60)
WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "":
@@ -399,18 +397,16 @@ else:
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
"AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", ""),
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
)
if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "":
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None
else:
try:
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
except Exception:
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 5
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10
####################################
# OFFLINE_MODE
@@ -424,13 +420,12 @@ if OFFLINE_MODE:
####################################
# AUDIT LOGGING
####################################
ENABLE_AUDIT_LOGS = os.getenv("ENABLE_AUDIT_LOGS", "false").lower() == "true"
# Where to store log file
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
# Maximum size of a file before rotating into a new log file
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
# METADATA | REQUEST | REQUEST_RESPONSE
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "REQUEST_RESPONSE").upper()
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
try:
MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048)
except ValueError:
@@ -442,3 +437,26 @@ AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders"
)
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
####################################
# OPENTELEMETRY
####################################
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
) # e.g. key1=val1,key2=val2
OTEL_TRACES_SAMPLER = os.environ.get(
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
).lower()
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
+3
View File
@@ -223,6 +223,9 @@ async def generate_function_chat_completion(
extra_params = {
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
"__chat_id__": metadata.get("chat_id", None),
"__session_id__": metadata.get("session_id", None),
"__message_id__": metadata.get("message_id", None),
"__task__": __task__,
"__task_body__": __task_body__,
"__files__": files,
+83 -9
View File
@@ -84,11 +84,12 @@ from open_webui.routers.retrieval import (
get_rf,
)
from open_webui.internal.db import Session
from open_webui.internal.db import Session, engine
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel, Users
from open_webui.models.chats import Chats
from open_webui.config import (
LICENSE_KEY,
@@ -104,7 +105,10 @@ from open_webui.config import (
OPENAI_API_CONFIGS,
# Direct Connections
ENABLE_DIRECT_CONNECTIONS,
# Tool Server Configs
TOOL_SERVER_CONNECTIONS,
# Code Execution
ENABLE_CODE_EXECUTION,
CODE_EXECUTION_ENGINE,
CODE_EXECUTION_JUPYTER_URL,
CODE_EXECUTION_JUPYTER_AUTH,
@@ -154,6 +158,7 @@ from open_webui.config import (
AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
PLAYWRIGHT_WS_URI,
PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
RAG_WEB_LOADER_ENGINE,
@@ -185,9 +190,12 @@ from open_webui.config import (
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
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,
@@ -211,6 +219,7 @@ from open_webui.config import (
SERPSTACK_API_KEY,
SERPSTACK_HTTPS,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
BING_SEARCH_V7_ENDPOINT,
BING_SEARCH_V7_SUBSCRIPTION_KEY,
BRAVE_SEARCH_API_KEY,
@@ -247,6 +256,7 @@ from open_webui.config import (
ENABLE_CHANNELS,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
ENABLE_EVALUATION_ARENA_MODELS,
USER_PERMISSIONS,
DEFAULT_USER_ROLE,
@@ -311,6 +321,9 @@ from open_webui.env import (
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
GLOBAL_LOG_LEVEL,
MAX_BODY_LOG_SIZE,
SAFE_MODE,
@@ -326,6 +339,7 @@ from open_webui.env import (
BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START,
OFFLINE_MODE,
ENABLE_OTEL,
)
@@ -344,6 +358,7 @@ from open_webui.utils.access_control import has_access
from open_webui.utils.auth import (
get_license_data,
get_http_authorization_cred,
decode_token,
get_admin_user,
get_verified_user,
@@ -353,6 +368,8 @@ from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.tasks import stop_task, list_tasks # Import from tasks.py
from open_webui.utils.redis import get_sentinels_from_env
if SAFE_MODE:
print("SAFE MODE ENABLED")
@@ -417,11 +434,27 @@ app = FastAPI(
oauth_manager = OAuthManager(app)
app.state.config = AppConfig()
app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
)
app.state.WEBUI_NAME = WEBUI_NAME
app.state.LICENSE_METADATA = None
########################################
#
# OPENTELEMETRY
#
########################################
if ENABLE_OTEL:
from open_webui.utils.telemetry.setup import setup as setup_opentelemetry
setup_opentelemetry(app=app, db_engine=engine)
########################################
#
# OLLAMA
@@ -448,6 +481,15 @@ app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS
app.state.OPENAI_MODELS = {}
########################################
#
# TOOL SERVERS
#
########################################
app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS
app.state.TOOL_SERVERS = []
########################################
#
# DIRECT CONNECTIONS
@@ -491,6 +533,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
@@ -534,6 +577,7 @@ 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.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE
app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT
@@ -548,8 +592,10 @@ app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
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
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
@@ -611,8 +657,10 @@ app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_
app.state.config.RAG_WEB_LOADER_ENGINE = RAG_WEB_LOADER_ENGINE
app.state.config.RAG_WEB_SEARCH_TRUST_ENV = RAG_WEB_SEARCH_TRUST_ENV
app.state.config.PLAYWRIGHT_WS_URI = PLAYWRIGHT_WS_URI
app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
app.state.EMBEDDING_FUNCTION = None
app.state.ef = None
@@ -660,6 +708,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
#
########################################
app.state.config.ENABLE_CODE_EXECUTION = ENABLE_CODE_EXECUTION
app.state.config.CODE_EXECUTION_ENGINE = CODE_EXECUTION_ENGINE
app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL
app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH
@@ -827,6 +876,10 @@ async def commit_session_after_request(request: Request, call_next):
@app.middleware("http")
async def check_url(request: Request, call_next):
start_time = int(time.time())
request.state.token = get_http_authorization_cred(
request.headers.get("Authorization")
)
request.state.enable_api_key = app.state.config.ENABLE_API_KEY
response = await call_next(request)
process_time = int(time.time()) - start_time
@@ -945,14 +998,24 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
return filtered_models
models = await get_all_models(request, user=user)
all_models = await get_all_models(request, user=user)
# Filter out filter pipelines
models = [
model
for model in models
if "pipeline" not in model or model["pipeline"].get("type", None) != "filter"
]
models = []
for model in all_models:
# Filter out filter pipelines
if "pipeline" in model and model["pipeline"].get("type", None) == "filter":
continue
model_tags = [
tag.get("name")
for tag in model.get("info", {}).get("meta", {}).get("tags", [])
]
tags = [tag.get("name") for tag in model.get("tags", [])]
tags = list(set(model_tags + tags))
model["tags"] = [{"name": tag} for tag in tags]
models.append(model)
model_order_list = request.app.state.config.MODEL_ORDER_LIST
if model_order_list:
@@ -1018,6 +1081,7 @@ async def chat_completion(
"message_id": form_data.pop("id", None),
"session_id": form_data.pop("session_id", None),
"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),
@@ -1044,6 +1108,14 @@ async def chat_completion(
except Exception as e:
log.debug(f"Error processing chat payload: {e}")
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": str(e)},
},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
@@ -1173,11 +1245,13 @@ async def get_app_config(request: Request):
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
"enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION,
"enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER,
"enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
"enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
+13 -6
View File
@@ -9,6 +9,8 @@ from open_webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -234,15 +236,18 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool:
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True
) -> bool:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return False
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
if delete_chats:
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
# Delete all children folders
def delete_children(folder):
@@ -250,9 +255,11 @@ class FolderTable:
folder.id, user_id
)
for folder_child in folder_children:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
if delete_chats:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
delete_children(folder_child)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
+76 -8
View File
@@ -20,6 +20,9 @@ from langchain_community.document_loaders import (
YoutubeLoader,
)
from langchain_core.documents import Document
from open_webui.retrieval.loaders.mistral import MistralLoader
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@@ -105,7 +108,7 @@ class TikaLoader:
if r.ok:
raw_metadata = r.json()
text = raw_metadata.get("X-TIKA:content", "<No text content found>")
text = raw_metadata.get("X-TIKA:content", "<No text content found>").strip()
if "Content-Type" in raw_metadata:
headers["Content-Type"] = raw_metadata["Content-Type"]
@@ -117,6 +120,52 @@ class TikaLoader:
raise Exception(f"Error calling Tika: {r.reason}")
class DoclingLoader:
def __init__(self, url, file_path=None, mime_type=None):
self.url = url.rstrip("/")
self.file_path = file_path
self.mime_type = mime_type
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
files = {
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
}
params = {
"image_export_mode": "placeholder",
"table_mode": "accurate",
}
endpoint = f"{self.url}/v1alpha/convert/file"
r = requests.post(endpoint, files=files, data=params)
if r.ok:
result = r.json()
document_data = result.get("document", {})
text = document_data.get("md_content", "<No text content found>")
metadata = {"Content-Type": self.mime_type} if self.mime_type else {}
log.debug("Docling extracted text: %s", text)
return [Document(page_content=text, metadata=metadata)]
else:
error_msg = f"Error calling Docling API: {r.reason}"
if r.text:
try:
error_data = r.json()
if "detail" in error_data:
error_msg += f" - {error_data['detail']}"
except Exception:
error_msg += f" - {r.text}"
raise Exception(f"Error calling Docling: {error_msg}")
class Loader:
def __init__(self, engine: str = "", **kwargs):
self.engine = engine
@@ -135,13 +184,16 @@ class Loader:
for doc in docs
]
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
return file_ext in known_source_ext or (
file_content_type and file_content_type.find("text/") >= 0
)
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 file_ext in known_source_ext or (
file_content_type and file_content_type.find("text/") >= 0
):
if self._is_text_file(file_ext, file_content_type):
loader = TextLoader(file_path, autodetect_encoding=True)
else:
loader = TikaLoader(
@@ -149,6 +201,15 @@ class Loader:
file_path=file_path,
mime_type=file_content_type,
)
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)
else:
loader = DoclingLoader(
url=self.kwargs.get("DOCLING_SERVER_URL"),
file_path=file_path,
mime_type=file_content_type,
)
elif (
self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
@@ -170,13 +231,22 @@ class Loader:
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
elif (
self.engine == "mistral_ocr"
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(
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
)
elif file_ext == "csv":
loader = CSVLoader(file_path)
loader = CSVLoader(file_path, autodetect_encoding=True)
elif file_ext == "rst":
loader = UnstructuredRSTLoader(file_path, mode="elements")
elif file_ext == "xml":
@@ -205,9 +275,7 @@ class Loader:
loader = UnstructuredPowerPointLoader(file_path)
elif file_ext == "msg":
loader = OutlookMessageLoader(file_path)
elif file_ext in known_source_ext or (
file_content_type and file_content_type.find("text/") >= 0
):
elif self._is_text_file(file_ext, file_content_type):
loader = TextLoader(file_path, autodetect_encoding=True)
else:
loader = TextLoader(file_path, autodetect_encoding=True)
@@ -0,0 +1,225 @@
import requests
import logging
import os
import sys
from typing import List, Dict, Any
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MistralLoader:
"""
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):
"""
Initializes the loader.
Args:
api_key: Your Mistral API key.
file_path: The local path to the PDF file to process.
"""
if not api_key:
raise ValueError("API key cannot be empty.")
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found at {file_path}")
self.api_key = api_key
self.file_path = file_path
self.headers = {"Authorization": f"Bearer {self.api_key}"}
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
"""Checks response status and returns JSON content."""
try:
response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
# Handle potential empty responses for certain successful requests (e.g., DELETE)
if response.status_code == 204 or not response.content:
return {} # Return empty dict if no content
return response.json()
except requests.exceptions.HTTPError as http_err:
log.error(f"HTTP error occurred: {http_err} - Response: {response.text}")
raise
except requests.exceptions.RequestException as req_err:
log.error(f"Request exception occurred: {req_err}")
raise
except ValueError as json_err: # Includes JSONDecodeError
log.error(f"JSON decode error: {json_err} - Response: {response.text}")
raise # Re-raise after logging
def _upload_file(self) -> str:
"""Uploads the file to Mistral for OCR processing."""
log.info("Uploading file to Mistral API")
url = f"{self.BASE_API_URL}/files"
file_name = os.path.basename(self.file_path)
try:
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
)
response_data = self._handle_response(response)
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
except Exception as e:
log.error(f"Failed to upload file: {e}")
raise
def _get_signed_url(self, file_id: str) -> str:
"""Retrieves a temporary signed URL for the uploaded file."""
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"}
try:
response = requests.get(url, headers=signed_url_headers, params=params)
response_data = self._handle_response(response)
signed_url = response_data.get("url")
if not signed_url:
raise ValueError("Signed URL not found in response.")
log.info("Signed URL received.")
return signed_url
except Exception as e:
log.error(f"Failed to get signed URL: {e}")
raise
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
"""Sends the signed URL to the OCR endpoint for processing."""
log.info("Processing OCR via Mistral API")
url = f"{self.BASE_API_URL}/ocr"
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,
}
try:
response = requests.post(url, headers=ocr_headers, json=payload)
ocr_response = self._handle_response(response)
log.info("OCR processing done.")
log.debug("OCR response: %s", ocr_response)
return ocr_response
except Exception as e:
log.error(f"Failed during OCR processing: {e}")
raise
def _delete_file(self, file_id: str) -> None:
"""Deletes the file from Mistral storage."""
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
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
def load(self) -> List[Document]:
"""
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
Returns:
A list of Document objects, one for each page processed.
"""
file_id = None
try:
# 1. Upload file
file_id = self._upload_file()
# 2. Get Signed URL
signed_url = self._get_signed_url(file_id)
# 3. Process OCR
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 = []
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={}
)
]
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={})]
finally:
# 5. Delete file (attempt even if prior steps failed after upload)
if file_id:
try:
self._delete_file(file_id)
except Exception as del_e:
# Log deletion error, but don't overwrite original error if one occurred
log.error(
f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}"
)
@@ -0,0 +1,93 @@
import requests
import logging
from typing import Iterator, List, Literal, 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 TavilyLoader(BaseLoader):
"""Extract web page content from URLs using Tavily Extract API.
This is a LangChain document loader that uses Tavily's Extract API to
retrieve content from web pages and return it as Document objects.
Args:
urls: URL or list of URLs to extract content from.
api_key: The Tavily API key.
extract_depth: Depth of extraction, either "basic" or "advanced".
continue_on_failure: Whether to continue if extraction of a URL fails.
"""
def __init__(
self,
urls: Union[str, List[str]],
api_key: str,
extract_depth: Literal["basic", "advanced"] = "basic",
continue_on_failure: bool = True,
) -> None:
"""Initialize Tavily Extract client.
Args:
urls: URL or list of URLs to extract content from.
api_key: The Tavily API key.
include_images: Whether to include images in the extraction.
extract_depth: Depth of extraction, either "basic" or "advanced".
advanced extraction retrieves more data, including tables and
embedded content, with higher success but may increase latency.
basic costs 1 credit per 5 successful URL extractions,
advanced costs 2 credits per 5 successful URL extractions.
continue_on_failure: Whether to continue if extraction of a URL fails.
"""
if not urls:
raise ValueError("At least one URL must be provided.")
self.api_key = api_key
self.urls = urls if isinstance(urls, list) else [urls]
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
self.api_url = "https://api.tavily.com/extract"
def lazy_load(self) -> Iterator[Document]:
"""Extract and yield documents from the URLs using Tavily Extract API."""
batch_size = 20
for i in range(0, len(self.urls), batch_size):
batch_urls = self.urls[i : i + batch_size]
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
# Use string for single URL, array for multiple URLs
urls_param = batch_urls[0] if len(batch_urls) == 1 else batch_urls
payload = {"urls": urls_param, "extract_depth": self.extract_depth}
# Make the API call
response = requests.post(self.api_url, headers=headers, json=payload)
response.raise_for_status()
response_data = response.json()
# Process successful results
for result in response_data.get("results", []):
url = result.get("url", "")
content = result.get("raw_content", "")
if not content:
log.warning(f"No content extracted from {url}")
continue
# Add URLs as metadata
metadata = {"source": url}
yield Document(
page_content=content,
metadata=metadata,
)
for failed in response_data.get("failed_results", []):
url = failed.get("url", "")
error = failed.get("error", "Unknown error")
log.error(f"Failed to extract content from {url}: {error}")
except Exception as e:
if self.continue_on_failure:
log.error(f"Error extracting content from batch {batch_urls}: {e}")
else:
raise e
+160 -67
View File
@@ -1,30 +1,35 @@
import logging
import os
import uuid
from typing import Optional, Union
import asyncio
import requests
import hashlib
from concurrent.futures import ThreadPoolExecutor
from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
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.utils.misc import get_last_user_message, calculate_sha256_string
from open_webui.models.users import UserModel
from open_webui.models.files import Files
from open_webui.retrieval.vector.main import GetResult
from open_webui.env import (
SRC_LOG_LEVELS,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_PREFIX_FIELD_NAME,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -49,7 +54,7 @@ class VectorSearchRetriever(BaseRetriever):
) -> list[Document]:
result = VECTOR_DB_CLIENT.search(
collection_name=self.collection_name,
vectors=[self.embedding_function(query)],
vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)],
limit=self.top_k,
)
@@ -102,18 +107,18 @@ def get_doc(collection_name: str, user: UserModel = None):
def query_doc_with_hybrid_search(
collection_name: str,
collection_result: GetResult,
query: str,
embedding_function,
k: int,
reranking_function,
k_reranker: int,
r: float,
) -> dict:
try:
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
bm25_retriever = BM25Retriever.from_texts(
texts=result.documents[0],
metadatas=result.metadatas[0],
texts=collection_result.documents[0],
metadatas=collection_result.metadatas[0],
)
bm25_retriever.k = k
@@ -128,7 +133,7 @@ def query_doc_with_hybrid_search(
)
compressor = RerankCompressor(
embedding_function=embedding_function,
top_n=k,
top_n=k_reranker,
reranking_function=reranking_function,
r_score=r,
)
@@ -138,10 +143,23 @@ def query_doc_with_hybrid_search(
)
result = compression_retriever.invoke(query)
distances = [d.metadata.get("score") for d in result]
documents = [d.page_content for d in result]
metadatas = [d.metadata for d in result]
# retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker
if k < k_reranker:
sorted_items = sorted(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
)
sorted_items = sorted_items[:k]
distances, documents, metadatas = map(list, zip(*sorted_items))
result = {
"distances": [[d.metadata.get("score") for d in result]],
"documents": [[d.page_content for d in result]],
"metadatas": [[d.metadata for d in result]],
"distances": [distances],
"documents": [documents],
"metadatas": [metadatas],
}
log.info(
@@ -174,12 +192,9 @@ def merge_get_results(get_results: list[dict]) -> dict:
return result
def merge_and_sort_query_results(
query_results: list[dict], k: int, reverse: bool = False
) -> dict:
def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
# Initialize lists to store combined data
combined = []
seen_hashes = set() # To store unique document hashes
combined = dict() # To store documents with unique document hashes
for data in query_results:
distances = data["distances"][0]
@@ -192,12 +207,17 @@ def merge_and_sort_query_results(
document.encode()
).hexdigest() # Compute a hash for uniqueness
if doc_hash not in seen_hashes:
seen_hashes.add(doc_hash)
combined.append((distance, document, metadata))
if doc_hash not in combined.keys():
combined[doc_hash] = (distance, document, metadata)
continue # if doc is new, no further comparison is needed
# if doc is alredy in, but new distance is better, update
if distance > combined[doc_hash][0]:
combined[doc_hash] = (distance, document, metadata)
combined = list(combined.values())
# Sort the list based on distances
combined.sort(key=lambda x: x[0], reverse=reverse)
combined.sort(key=lambda x: x[0], reverse=True)
# Slice to keep only the top k elements
sorted_distances, sorted_documents, sorted_metadatas = (
@@ -237,7 +257,7 @@ def query_collection(
) -> dict:
results = []
for query in queries:
query_embedding = embedding_function(query)
query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
for collection_name in collection_names:
if collection_name:
try:
@@ -253,12 +273,7 @@ def query_collection(
else:
pass
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
return merge_and_sort_query_results(results, k=k)
def query_collection_with_hybrid_search(
@@ -267,39 +282,69 @@ def query_collection_with_hybrid_search(
embedding_function,
k: int,
reranking_function,
k_reranker: int,
r: float,
) -> dict:
results = []
error = False
# Fetch collection data once per collection sequentially
# Avoid fetching the same data multiple times later
collection_results = {}
for collection_name in collection_names:
try:
for query in queries:
result = query_doc_with_hybrid_search(
collection_name=collection_name,
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
r=r,
)
results.append(result)
except Exception as e:
log.exception(
"Error when querying the collection with " f"hybrid_search: {e}"
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
collection_name=collection_name
)
error = True
except Exception as e:
log.exception(f"Failed to fetch collection {collection_name}: {e}")
collection_results[collection_name] = None
if error:
log.info(
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
)
def process_query(collection_name, query):
try:
result = query_doc_with_hybrid_search(
collection_name=collection_name,
collection_result=collection_results[collection_name],
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
)
return result, None
except Exception as e:
log.exception(f"Error when querying the collection with hybrid_search: {e}")
return None, e
# Prepare tasks for all collections and queries
# Avoid running any tasks for collections that failed to fetch data (have assigned None)
tasks = [
(cn, q)
for cn in collection_names
if collection_results[cn] is not None
for q in queries
]
with ThreadPoolExecutor() as executor:
future_results = [executor.submit(process_query, cn, q) for cn, q in tasks]
task_results = [future.result() for future in future_results]
for result, err in task_results:
if err is not None:
error = True
elif result is not None:
results.append(result)
if error and not results:
raise Exception(
"Hybrid search failed for all collections. Using Non hybrid search as fallback."
"Hybrid search failed for all collections. Using Non-hybrid search as fallback."
)
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
return merge_and_sort_query_results(results, k=k)
def get_embedding_function(
@@ -311,29 +356,38 @@ def get_embedding_function(
embedding_batch_size,
):
if embedding_engine == "":
return lambda query, user=None: embedding_function.encode(query).tolist()
return lambda query, prefix=None, user=None: embedding_function.encode(
query, **({"prompt": prefix} if prefix else {})
).tolist()
elif embedding_engine in ["ollama", "openai"]:
func = lambda query, user=None: generate_embeddings(
func = lambda query, prefix=None, user=None: generate_embeddings(
engine=embedding_engine,
model=embedding_model,
text=query,
prefix=prefix,
url=url,
key=key,
user=user,
)
def generate_multiple(query, user, func):
def generate_multiple(query, prefix, user, func):
if isinstance(query, list):
embeddings = []
for i in range(0, len(query), embedding_batch_size):
embeddings.extend(
func(query[i : i + embedding_batch_size], user=user)
func(
query[i : i + embedding_batch_size],
prefix=prefix,
user=user,
)
)
return embeddings
else:
return func(query, user)
return func(query, prefix, user)
return lambda query, user=None: generate_multiple(query, user, func)
return lambda query, prefix=None, user=None: generate_multiple(
query, prefix, user, func
)
else:
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
@@ -345,6 +399,7 @@ def get_sources_from_files(
embedding_function,
k,
reranking_function,
k_reranker,
r,
hybrid_search,
full_context=False,
@@ -461,6 +516,7 @@ def get_sources_from_files(
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
)
except Exception as e:
@@ -553,9 +609,14 @@ def generate_openai_batch_embeddings(
texts: list[str],
url: str = "https://api.openai.com/v1",
key: str = "",
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
json_data = {"input": texts, "model": model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
r = requests.post(
f"{url}/embeddings",
headers={
@@ -572,7 +633,7 @@ def generate_openai_batch_embeddings(
else {}
),
},
json={"input": texts, "model": model},
json=json_data,
)
r.raise_for_status()
data = r.json()
@@ -586,9 +647,18 @@ def generate_openai_batch_embeddings(
def generate_ollama_batch_embeddings(
model: str, texts: list[str], url: str, key: str = "", user: UserModel = None
model: str,
texts: list[str],
url: str,
key: str = "",
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
json_data = {"input": texts, "model": model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
r = requests.post(
f"{url}/api/embed",
headers={
@@ -605,7 +675,7 @@ def generate_ollama_batch_embeddings(
else {}
),
},
json={"input": texts, "model": model},
json=json_data,
)
r.raise_for_status()
data = r.json()
@@ -619,15 +689,34 @@ def generate_ollama_batch_embeddings(
return None
def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs):
def generate_embeddings(
engine: str,
model: str,
text: Union[str, list[str]],
prefix: Union[str, None] = None,
**kwargs,
):
url = kwargs.get("url", "")
key = kwargs.get("key", "")
user = kwargs.get("user")
if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None:
if isinstance(text, list):
text = [f"{prefix}{text_element}" for text_element in text]
else:
text = f"{prefix}{text}"
if engine == "ollama":
if isinstance(text, list):
embeddings = generate_ollama_batch_embeddings(
**{"model": model, "texts": text, "url": url, "key": key, "user": user}
**{
"model": model,
"texts": text,
"url": url,
"key": key,
"prefix": prefix,
"user": user,
}
)
else:
embeddings = generate_ollama_batch_embeddings(
@@ -636,16 +725,20 @@ def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **
"texts": [text],
"url": url,
"key": key,
"prefix": prefix,
"user": user,
}
)
return embeddings[0] if isinstance(text, str) else embeddings
elif engine == "openai":
if isinstance(text, list):
embeddings = generate_openai_batch_embeddings(model, text, url, key, user)
embeddings = generate_openai_batch_embeddings(
model, text, url, key, prefix, user
)
else:
embeddings = generate_openai_batch_embeddings(model, [text], url, key, user)
embeddings = generate_openai_batch_embeddings(
model, [text], url, key, prefix, user
)
return embeddings[0] if isinstance(text, str) else embeddings
@@ -681,9 +774,9 @@ class RerankCompressor(BaseDocumentCompressor):
else:
from sentence_transformers import util
query_embedding = self.embedding_function(query)
query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
document_embedding = self.embedding_function(
[doc.page_content for doc in documents]
[doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX
)
scores = util.cos_sim(query_embedding, document_embedding)[0]
@@ -75,10 +75,16 @@ class ChromaClient:
n_results=limit,
)
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
# https://docs.trychroma.com/docs/collections/configure cosine equation
distances: list = result["distances"][0]
distances = [2 - dist for dist in distances]
distances = [[dist / 2 for dist in distances]]
return SearchResult(
**{
"ids": result["ids"],
"distances": result["distances"],
"distances": distances,
"documents": result["documents"],
"metadatas": result["metadatas"],
}
@@ -166,12 +172,19 @@ class ChromaClient:
filter: Optional[dict] = None,
):
# Delete the items from the collection based on the ids.
collection = self.client.get_collection(name=collection_name)
if collection:
if ids:
collection.delete(ids=ids)
elif filter:
collection.delete(where=filter)
try:
collection = self.client.get_collection(name=collection_name)
if collection:
if ids:
collection.delete(ids=ids)
elif filter:
collection.delete(where=filter)
except Exception as e:
# If collection doesn't exist, that's fine - nothing to delete
log.debug(
f"Attempted to delete from non-existent collection {collection_name}. Ignoring."
)
pass
def reset(self):
# Resets the database. This will delete all collections and item entries.
@@ -10,6 +10,7 @@ from open_webui.config import (
ELASTICSEARCH_USERNAME,
ELASTICSEARCH_PASSWORD,
ELASTICSEARCH_CLOUD_ID,
ELASTICSEARCH_INDEX_PREFIX,
SSL_ASSERT_FINGERPRINT,
)
@@ -23,7 +24,7 @@ class ElasticsearchClient:
"""
def __init__(self):
self.index_prefix = "open_webui_collections"
self.index_prefix = ELASTICSEARCH_INDEX_PREFIX
self.client = Elasticsearch(
hosts=[ELASTICSEARCH_URL],
ca_certs=ELASTICSEARCH_CA_CERTS,
@@ -95,6 +96,14 @@ class ElasticsearchClient:
def _create_index(self, dimension: int):
body = {
"mappings": {
"dynamic_templates": [
{
"strings": {
"match_mapping_type": "string",
"mapping": {"type": "keyword"},
}
}
],
"properties": {
"collection": {"type": "keyword"},
"id": {"type": "keyword"},
@@ -106,7 +115,7 @@ class ElasticsearchClient:
},
"text": {"type": "text"},
"metadata": {"type": "object"},
}
},
}
}
self.client.indices.create(index=self._get_index_name(dimension), body=body)
@@ -131,12 +140,9 @@ class ElasticsearchClient:
except Exception as e:
return None
# @TODO: Make this delete a collection and not an index
def delete_colleciton(self, collection_name: str):
# TODO: fix this to include the dimension or a * prefix
# delete_collection here means delete a bunch of documents for an index.
# We are simply adapting to the norms of the other DBs.
self.client.indices.delete(index=self._get_collection_name(collection_name))
def delete_collection(self, collection_name: str):
query = {"query": {"term": {"collection": collection_name}}}
self.client.delete_by_query(index=f"{self.index_prefix}*", body=query)
# Status: works
def search(
@@ -239,34 +245,49 @@ class ElasticsearchClient:
]
bulk(self.client, actions)
# Status: should work
# Upsert documents using the update API with doc_as_upsert=True.
def upsert(self, collection_name: str, items: list[VectorItem]):
if not self._has_index(dimension=len(items[0]["vector"])):
self._create_index(collection_name, dimension=len(items[0]["vector"]))
self._create_index(dimension=len(items[0]["vector"]))
for batch in self._create_batches(items):
actions = [
{
"_index": self._get_index_name(dimension=len(items[0]["vector"])),
"_op_type": "update",
"_index": self._get_index_name(dimension=len(item["vector"])),
"_id": item["id"],
"_source": {
"doc": {
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
"doc_as_upsert": True,
}
for item in batch
]
self.client.bulk(actions)
bulk(self.client, actions)
# TODO: This currently deletes by * which is not always supported in ElasticSearch.
# Need to read a bit before changing. Also, need to delete from a specific collection
def delete(self, collection_name: str, ids: list[str]):
# Assuming ID is unique across collections and indexes
actions = [
{"delete": {"_index": f"{self.index_prefix}*", "_id": id}} for id in ids
]
self.client.bulk(body=actions)
# Delete specific documents from a collection by filtering on both collection and document IDs.
def delete(
self,
collection_name: str,
ids: Optional[list[str]] = None,
filter: Optional[dict] = None,
):
query = {
"query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}}
}
# logic based on chromaDB
if ids:
query["query"]["bool"]["filter"].append({"terms": {"_id": ids}})
elif filter:
for field, value in filter.items():
query["query"]["bool"]["filter"].append(
{"term": {f"metadata.{field}": value}}
)
self.client.delete_by_query(index=f"{self.index_prefix}*", body=query)
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}*")
@@ -64,7 +64,10 @@ class MilvusClient:
for item in match:
_ids.append(item.get("id"))
_distances.append(item.get("distance"))
# normalize milvus score from [-1, 1] to [0, 1] range
# https://milvus.io/docs/de/metric.md
_dist = (item.get("distance") + 1.0) / 2.0
_distances.append(_dist)
_documents.append(item.get("entity", {}).get("data", {}).get("text"))
_metadatas.append(item.get("entity", {}).get("metadata"))
@@ -1,4 +1,5 @@
from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
@@ -21,7 +22,13 @@ class OpenSearchClient:
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD),
)
def _get_index_name(self, collection_name: str) -> str:
return f"{self.index_prefix}_{collection_name}"
def _result_to_get_result(self, result) -> GetResult:
if not result["hits"]["hits"]:
return None
ids = []
documents = []
metadatas = []
@@ -31,9 +38,12 @@ class OpenSearchClient:
documents.append(hit["_source"].get("text"))
metadatas.append(hit["_source"].get("metadata"))
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def _result_to_search_result(self, result) -> SearchResult:
if not result["hits"]["hits"]:
return None
ids = []
distances = []
documents = []
@@ -46,34 +56,40 @@ class OpenSearchClient:
metadatas.append(hit["_source"].get("metadata"))
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
ids=[ids],
distances=[distances],
documents=[documents],
metadatas=[metadatas],
)
def _create_index(self, collection_name: str, dimension: int):
body = {
"settings": {"index": {"knn": True}},
"mappings": {
"properties": {
"id": {"type": "keyword"},
"vector": {
"type": "dense_vector",
"dims": dimension, # Adjust based on your vector dimensions
"index": true,
"type": "knn_vector",
"dimension": dimension, # Adjust based on your vector dimensions
"index": True,
"similarity": "faiss",
"method": {
"name": "hnsw",
"space_type": "ip", # Use inner product to approximate cosine similarity
"space_type": "innerproduct", # Use inner product to approximate cosine similarity
"engine": "faiss",
"ef_construction": 128,
"m": 16,
"parameters": {
"ef_construction": 128,
"m": 16,
},
},
},
"text": {"type": "text"},
"metadata": {"type": "object"},
}
}
},
}
self.client.indices.create(
index=f"{self.index_prefix}_{collection_name}", body=body
index=self._get_index_name(collection_name), body=body
)
def _create_batches(self, items: list[VectorItem], batch_size=100):
@@ -83,39 +99,45 @@ class OpenSearchClient:
def has_collection(self, collection_name: str) -> bool:
# has_collection here means has index.
# We are simply adapting to the norms of the other DBs.
return self.client.indices.exists(
index=f"{self.index_prefix}_{collection_name}"
)
return self.client.indices.exists(index=self._get_index_name(collection_name))
def delete_colleciton(self, collection_name: str):
def delete_collection(self, collection_name: str):
# delete_collection here means delete index.
# We are simply adapting to the norms of the other DBs.
self.client.indices.delete(index=f"{self.index_prefix}_{collection_name}")
self.client.indices.delete(index=self._get_index_name(collection_name))
def search(
self, collection_name: str, vectors: list[list[float]], limit: int
self, collection_name: str, vectors: list[list[float | int]], limit: int
) -> Optional[SearchResult]:
query = {
"size": limit,
"_source": ["text", "metadata"],
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.vector, 'vector') + 1.0",
"params": {
"vector": vectors[0]
}, # Assuming single query vector
},
}
},
}
try:
if not self.has_collection(collection_name):
return None
result = self.client.search(
index=f"{self.index_prefix}_{collection_name}", body=query
)
query = {
"size": limit,
"_source": ["text", "metadata"],
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0",
"params": {
"field": "vector",
"query_value": vectors[0],
}, # Assuming single query vector
},
}
},
}
return self._result_to_search_result(result)
result = self.client.search(
index=self._get_index_name(collection_name), body=query
)
return self._result_to_search_result(result)
except Exception as e:
return None
def query(
self, collection_name: str, filter: dict, limit: Optional[int] = None
@@ -129,13 +151,15 @@ class OpenSearchClient:
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append({"term": {field: value}})
query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
)
size = limit if limit else 10
try:
result = self.client.search(
index=f"{self.index_prefix}_{collection_name}",
index=self._get_index_name(collection_name),
body=query_body,
size=size,
)
@@ -146,14 +170,14 @@ class OpenSearchClient:
return None
def _create_index_if_not_exists(self, collection_name: str, dimension: int):
if not self.has_index(collection_name):
if not self.has_collection(collection_name):
self._create_index(collection_name, dimension)
def get(self, collection_name: str) -> Optional[GetResult]:
query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]}
result = self.client.search(
index=f"{self.index_prefix}_{collection_name}", body=query
index=self._get_index_name(collection_name), body=query
)
return self._result_to_get_result(result)
@@ -165,18 +189,18 @@ class OpenSearchClient:
for batch in self._create_batches(items):
actions = [
{
"index": {
"_id": item["id"],
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
"_op_type": "index",
"_index": self._get_index_name(collection_name),
"_id": item["id"],
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
for item in batch
]
self.client.bulk(actions)
bulk(self.client, actions)
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
@@ -186,26 +210,47 @@ class OpenSearchClient:
for batch in self._create_batches(items):
actions = [
{
"index": {
"_id": item["id"],
"_index": f"{self.index_prefix}_{collection_name}",
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
"_op_type": "update",
"_index": self._get_index_name(collection_name),
"_id": item["id"],
"doc": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
"doc_as_upsert": True,
}
for item in batch
]
self.client.bulk(actions)
bulk(self.client, actions)
def delete(self, collection_name: str, ids: list[str]):
actions = [
{"delete": {"_index": f"{self.index_prefix}_{collection_name}", "_id": id}}
for id in ids
]
self.client.bulk(body=actions)
def delete(
self,
collection_name: str,
ids: Optional[list[str]] = None,
filter: Optional[dict] = None,
):
if ids:
actions = [
{
"_op_type": "delete",
"_index": self._get_index_name(collection_name),
"_id": id,
}
for id in ids
]
bulk(self.client, actions)
elif filter:
query_body = {
"query": {"bool": {"filter": []}},
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
)
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
@@ -278,7 +278,9 @@ class PgvectorClient:
for row in results:
qid = int(row.qid)
ids[qid].append(row.id)
distances[qid].append(row.distance)
# normalize and re-orders pgvec distance from [2, 0] to [0, 1] score range
# https://github.com/pgvector/pgvector?tab=readme-ov-file#querying
distances[qid].append((2.0 - row.distance) / 2.0)
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
@@ -99,7 +99,8 @@ class QdrantClient:
ids=get_result.ids,
documents=get_result.documents,
metadatas=get_result.metadatas,
distances=[[point.score for point in query_response.points]],
# qdrant distance is [-1, 1], normalize to [0, 1]
distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
)
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
+172 -76
View File
@@ -24,13 +24,17 @@ from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoa
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.constants import ERROR_MESSAGES
from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH,
PLAYWRIGHT_WS_URI,
PLAYWRIGHT_TIMEOUT,
RAG_WEB_LOADER_ENGINE,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -113,7 +117,47 @@ def verify_ssl_cert(url: str) -> bool:
return False
class SafeFireCrawlLoader(BaseLoader):
class RateLimitMixin:
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
class URLProcessingMixin:
def _verify_ssl_cert(self, url: str) -> bool:
"""Verify SSL certificate for a URL."""
return verify_ssl_cert(url)
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def __init__(
self,
web_paths,
@@ -184,7 +228,7 @@ class SafeFireCrawlLoader(BaseLoader):
yield from loader.lazy_load()
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
@@ -204,47 +248,124 @@ class SafeFireCrawlLoader(BaseLoader):
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def __init__(
self,
web_paths: Union[str, List[str]],
api_key: str,
extract_depth: Literal["basic", "advanced"] = "basic",
continue_on_failure: bool = True,
requests_per_second: Optional[float] = None,
verify_ssl: bool = True,
trust_env: bool = False,
proxy: Optional[Dict[str, str]] = None,
):
"""Initialize SafeTavilyLoader with rate limiting and SSL verification support.
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
Args:
web_paths: List of URLs/paths to process.
api_key: The Tavily API key.
extract_depth: Depth of extraction ("basic" or "advanced").
continue_on_failure: Whether to continue if extraction of a URL fails.
requests_per_second: Number of requests per second to limit to.
verify_ssl: If True, verify SSL certificates.
trust_env: If True, use proxy settings from environment variables.
proxy: Optional proxy configuration.
"""
# Initialize proxy configuration if using environment variables
proxy_server = proxy.get("server") if proxy else None
if trust_env and not proxy_server:
env_proxies = urllib.request.getproxies()
env_proxy_server = env_proxies.get("https") or env_proxies.get("http")
if env_proxy_server:
if proxy:
proxy["server"] = env_proxy_server
else:
proxy = {"server": env_proxy_server}
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
# Store parameters for creating TavilyLoader instances
self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths]
self.api_key = api_key
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
self.verify_ssl = verify_ssl
self.trust_env = trust_env
self.proxy = proxy
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
# Add rate limiting
self.requests_per_second = requests_per_second
self.last_request_time = None
def lazy_load(self) -> Iterator[Document]:
"""Load documents with rate limiting support, delegating to TavilyLoader."""
valid_urls = []
for url in self.web_paths:
try:
self._safe_process_url_sync(url)
valid_urls.append(url)
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
if not self.continue_on_failure:
raise e
if not valid_urls:
if self.continue_on_failure:
log.warning("No valid URLs to process after SSL verification")
return
raise ValueError("No valid URLs to process after SSL verification")
try:
loader = TavilyLoader(
urls=valid_urls,
api_key=self.api_key,
extract_depth=self.extract_depth,
continue_on_failure=self.continue_on_failure,
)
yield from loader.lazy_load()
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error extracting content from URLs: {e}")
else:
raise e
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async version with rate limiting and SSL verification."""
valid_urls = []
for url in self.web_paths:
try:
await self._safe_process_url(url)
valid_urls.append(url)
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
if not self.continue_on_failure:
raise e
if not valid_urls:
if self.continue_on_failure:
log.warning("No valid URLs to process after SSL verification")
return
raise ValueError("No valid URLs to process after SSL verification")
try:
loader = TavilyLoader(
urls=valid_urls,
api_key=self.api_key,
extract_depth=self.extract_depth,
continue_on_failure=self.continue_on_failure,
)
async for document in loader.alazy_load():
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error loading URLs: {e}")
else:
raise e
class SafePlaywrightURLLoader(PlaywrightURLLoader):
class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin):
"""Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection.
Attributes:
@@ -256,6 +377,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
headless (bool): If True, the browser will run in headless mode.
proxy (dict): Proxy override settings for the Playwright session.
playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection.
playwright_timeout (Optional[int]): Maximum operation time in milliseconds.
"""
def __init__(
@@ -269,6 +391,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
remove_selectors: Optional[List[str]] = None,
proxy: Optional[Dict[str, str]] = None,
playwright_ws_url: Optional[str] = None,
playwright_timeout: Optional[int] = 10000,
):
"""Initialize with additional safety parameters and remote browser support."""
@@ -295,6 +418,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
self.last_request_time = None
self.playwright_ws_url = playwright_ws_url
self.trust_env = trust_env
self.playwright_timeout = playwright_timeout
def lazy_load(self) -> Iterator[Document]:
"""Safely load URLs synchronously with support for remote browser."""
@@ -311,7 +435,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
try:
self._safe_process_url_sync(url)
page = browser.new_page()
response = page.goto(url)
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f"page.goto() returned None for url {url}")
@@ -320,7 +444,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
browser.close()
@@ -342,7 +466,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
try:
await self._safe_process_url(url)
page = await browser.new_page()
response = await page.goto(url)
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f"page.goto() returned None for url {url}")
@@ -351,46 +475,11 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
await browser.close()
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
class SafeWebBaseLoader(WebBaseLoader):
"""WebBaseLoader with enhanced error handling for URLs."""
@@ -472,7 +561,7 @@ class SafeWebBaseLoader(WebBaseLoader):
yield Document(page_content=text, metadata=metadata)
except Exception as e:
# Log the error and continue with the next URL
log.exception(e, "Error loading %s", path)
log.exception(f"Error loading {path}: {e}")
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async lazy load text from the url(s) in web_path."""
@@ -499,6 +588,7 @@ RAG_WEB_LOADER_ENGINES = defaultdict(lambda: SafeWebBaseLoader)
RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader
RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader
RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader
RAG_WEB_LOADER_ENGINES["tavily"] = SafeTavilyLoader
def get_web_loader(
@@ -518,13 +608,19 @@ def get_web_loader(
"trust_env": trust_env,
}
if PLAYWRIGHT_WS_URI.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
if RAG_WEB_LOADER_ENGINE.value == "playwright":
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
if PLAYWRIGHT_WS_URI.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
if RAG_WEB_LOADER_ENGINE.value == "firecrawl":
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
if RAG_WEB_LOADER_ENGINE.value == "tavily":
web_loader_args["api_key"] = TAVILY_API_KEY.value
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
# Create the appropriate WebLoader based on the configuration
WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value]
web_loader = WebLoaderClass(**web_loader_args)
+3 -1
View File
@@ -625,7 +625,9 @@ def transcription(
):
log.info(f"file.content_type: {file.content_type}")
if file.content_type not in ["audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a"]:
supported_filetypes = ("audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a")
if not file.content_type.startswith(supported_filetypes):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
+30 -21
View File
@@ -194,8 +194,8 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
ciphers=LDAP_CIPHERS,
)
except Exception as e:
log.error(f"An error occurred on TLS: {str(e)}")
raise HTTPException(400, detail=str(e))
log.error(f"TLS configuration error: {str(e)}")
raise HTTPException(400, detail="Failed to configure TLS for LDAP connection.")
try:
server = Server(
@@ -210,7 +210,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_APP_DN,
LDAP_APP_PASSWORD,
auto_bind="NONE",
authentication="SIMPLE",
authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
)
if not connection_app.bind():
raise HTTPException(400, detail="Application account bind failed")
@@ -232,7 +232,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
email = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
if not email or email == "" or email == "[]":
raise HTTPException(400, f"User {form_data.user} does not have email.")
raise HTTPException(400, "User does not have a valid email address.")
else:
email = email.lower()
@@ -248,7 +248,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
authentication="SIMPLE",
)
if not connection_user.bind():
raise HTTPException(400, f"Authentication failed for {form_data.user}")
raise HTTPException(400, "Authentication failed.")
user = Users.get_user_by_email(email)
if not user:
@@ -276,7 +276,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
except HTTPException:
raise
except Exception as err:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
log.error(f"LDAP user creation error: {str(err)}")
raise HTTPException(
500, detail="Internal error occurred during LDAP user creation."
)
user = Auths.authenticate_user_by_trusted_header(email)
@@ -312,12 +315,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
raise HTTPException(
400,
f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}",
)
raise HTTPException(400, "User record mismatch.")
except Exception as e:
raise HTTPException(400, detail=str(e))
log.error(f"LDAP authentication error: {str(e)}")
raise HTTPException(400, detail="LDAP authentication failed.")
############################
@@ -519,7 +520,8 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
except Exception as err:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
log.error(f"Signup error: {str(err)}")
raise HTTPException(500, detail="An internal error occurred during signup.")
@router.get("/signout")
@@ -547,7 +549,11 @@ async def signout(request: Request, response: Response):
detail="Failed to fetch OpenID configuration",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
log.error(f"OpenID signout error: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to sign out from the OpenID provider.",
)
return {"status": True}
@@ -591,7 +597,10 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
except Exception as err:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
log.error(f"Add user error: {str(err)}")
raise HTTPException(
500, detail="An internal error occurred while adding the user."
)
############################
@@ -639,11 +648,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
}
@@ -654,11 +664,12 @@ class AdminConfig(BaseModel):
ENABLE_API_KEY: bool
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
API_KEY_ALLOWED_ENDPOINTS: str
ENABLE_CHANNELS: bool
DEFAULT_USER_ROLE: str
JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
ENABLE_CHANNELS: bool
ENABLE_USER_WEBHOOKS: bool
@router.post("/admin/config")
@@ -693,6 +704,8 @@ async def update_admin_config(
)
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
@@ -705,6 +718,7 @@ async def update_admin_config(
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
}
@@ -759,11 +773,6 @@ async def update_ldap_server(
if not value:
raise HTTPException(400, detail=f"Required field {key} is empty")
if form_data.use_tls and not form_data.certificate_path:
raise HTTPException(
400, detail="TLS is enabled but certificate file path is missing"
)
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
request.app.state.config.LDAP_SERVER_HOST = form_data.host
request.app.state.config.LDAP_SERVER_PORT = form_data.port
+103
View File
@@ -2,6 +2,8 @@ import json
import logging
from typing import Optional
from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import (
ChatForm,
ChatImportForm,
@@ -372,6 +374,107 @@ async def update_chat_by_id(
)
############################
# UpdateChatMessageById
############################
class MessageForm(BaseModel):
content: str
@router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse])
async def update_chat_message_by_id(
id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id(id)
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.upsert_message_to_chat_by_id_and_message_id(
id,
message_id,
{
"content": form_data.content,
},
)
event_emitter = get_event_emitter(
{
"user_id": user.id,
"chat_id": id,
"message_id": message_id,
},
False,
)
if event_emitter:
await event_emitter(
{
"type": "chat:message",
"data": {
"chat_id": id,
"message_id": message_id,
"content": form_data.content,
},
}
)
return ChatResponse(**chat.model_dump())
############################
# SendChatMessageEventById
############################
class EventForm(BaseModel):
type: str
data: dict
@router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool])
async def send_chat_message_event_by_id(
id: str, message_id: str, form_data: EventForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id(id)
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
event_emitter = get_event_emitter(
{
"user_id": user.id,
"chat_id": id,
"message_id": message_id,
}
)
try:
if event_emitter:
await event_emitter(form_data.model_dump())
else:
return False
return True
except:
return False
############################
# DeleteChatById
############################
+78 -2
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel, ConfigDict
from typing import Optional
@@ -7,6 +7,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config
from open_webui.config import BannerModel
from open_webui.utils.tools import get_tool_server_data, get_tool_servers_data
router = APIRouter()
@@ -66,10 +68,80 @@ async def set_direct_connections_config(
}
############################
# ToolServers Config
############################
class ToolServerConnection(BaseModel):
url: str
path: str
auth_type: Optional[str]
key: Optional[str]
config: Optional[dict]
model_config = ConfigDict(extra="allow")
class ToolServersConfigForm(BaseModel):
TOOL_SERVER_CONNECTIONS: list[ToolServerConnection]
@router.get("/tool_servers", response_model=ToolServersConfigForm)
async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)):
return {
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
}
@router.post("/tool_servers", response_model=ToolServersConfigForm)
async def set_tool_servers_config(
request: Request,
form_data: ToolServersConfigForm,
user=Depends(get_admin_user),
):
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
]
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
request.app.state.config.TOOL_SERVER_CONNECTIONS
)
return {
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
}
@router.post("/tool_servers/verify")
async def verify_tool_servers_config(
request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)
):
"""
Verify the connection to the tool server.
"""
try:
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
url = f"{form_data.url}/{form_data.path}"
return await get_tool_server_data(token, url)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to connect to the tool server: {str(e)}",
)
############################
# CodeInterpreterConfig
############################
class CodeInterpreterConfigForm(BaseModel):
ENABLE_CODE_EXECUTION: bool
CODE_EXECUTION_ENGINE: str
CODE_EXECUTION_JUPYTER_URL: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH: Optional[str]
@@ -89,6 +161,7 @@ class CodeInterpreterConfigForm(BaseModel):
@router.get("/code_execution", response_model=CodeInterpreterConfigForm)
async def get_code_execution_config(request: Request, user=Depends(get_admin_user)):
return {
"ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION,
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
@@ -111,6 +184,8 @@ async def set_code_execution_config(
request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user)
):
request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION
request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE
request.app.state.config.CODE_EXECUTION_JUPYTER_URL = (
form_data.CODE_EXECUTION_JUPYTER_URL
@@ -153,6 +228,7 @@ async def set_code_execution_config(
)
return {
"ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION,
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
+16 -2
View File
@@ -56,8 +56,19 @@ async def update_config(
}
class FeedbackUserReponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserModel] = None
user: Optional[FeedbackUserReponse] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
@@ -65,7 +76,10 @@ async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return [
FeedbackUserResponse(
**feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id)
**feedback.model_dump(),
user=FeedbackUserReponse(
**Users.get_user_by_id(feedback.user_id).model_dump()
),
)
for feedback in feedbacks
]
+176 -44
View File
@@ -5,7 +5,16 @@ from pathlib import Path
from typing import Optional
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Request,
UploadFile,
status,
Query,
)
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
@@ -15,6 +24,9 @@ from open_webui.models.files import (
FileModelResponse,
Files,
)
from open_webui.models.knowledge import Knowledges
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.audio import transcribe
from open_webui.storage.provider import Storage
@@ -27,6 +39,39 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# Check if the current user has access to a file through any knowledge bases the user may be in.
############################
def has_access_to_file(
file_id: Optional[str], access_type: str, user=Depends(get_verified_user)
) -> bool:
file = Files.get_file_by_id(file_id)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
has_access = False
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
has_access = True
break
return has_access
############################
# Upload File
############################
@@ -38,6 +83,7 @@ def upload_file(
file: UploadFile = File(...),
user=Depends(get_verified_user),
file_metadata: dict = {},
process: bool = Query(True),
):
log.info(f"file.content_type: {file.content_type}")
try:
@@ -66,34 +112,35 @@ def upload_file(
}
),
)
if process:
try:
if file.content_type in [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
]:
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path)
try:
if file.content_type in [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
]:
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,
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"]:
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file_item.id}")
file_item = FileModelResponse(
**{
**file_item.model_dump(),
"error": str(e.detail) if hasattr(e, "detail") else str(e),
}
)
else:
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file_item.id}")
file_item = FileModelResponse(
**{
**file_item.model_dump(),
"error": str(e.detail) if hasattr(e, "detail") else str(e),
}
)
if file_item:
return file_item
@@ -117,11 +164,16 @@ def upload_file(
@router.get("/", response_model=list[FileModelResponse])
async def list_files(user=Depends(get_verified_user)):
async def list_files(user=Depends(get_verified_user), content: bool = Query(True)):
if user.role == "admin":
files = Files.get_files()
else:
files = Files.get_files_by_user_id(user.id)
if not content:
for file in files:
del file.data["content"]
return files
@@ -160,7 +212,17 @@ async def delete_all_files(user=Depends(get_admin_user)):
async def get_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
return file
else:
raise HTTPException(
@@ -178,7 +240,17 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
return {"content": file.data.get("content", "")}
else:
raise HTTPException(
@@ -202,7 +274,17 @@ async def update_file_data_content_by_id(
):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
):
try:
process_file(
request,
@@ -228,9 +310,22 @@ async def update_file_data_content_by_id(
@router.get("/{id}/content")
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), attachment: bool = Query(False)
):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
@@ -246,17 +341,22 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
encoded_filename = quote(filename)
headers = {}
if content_type == "application/pdf" or filename.lower().endswith(
".pdf"
):
headers["Content-Disposition"] = (
f"inline; filename*=UTF-8''{encoded_filename}"
)
content_type = "application/pdf"
elif content_type != "text/plain":
if attachment:
headers["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
else:
if content_type == "application/pdf" or filename.lower().endswith(
".pdf"
):
headers["Content-Disposition"] = (
f"inline; filename*=UTF-8''{encoded_filename}"
)
content_type = "application/pdf"
elif content_type != "text/plain":
headers["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
return FileResponse(file_path, headers=headers, media_type=content_type)
@@ -282,7 +382,18 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/content/html")
async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
@@ -314,7 +425,17 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
file_path = file.path
# Handle Unicode filenames
@@ -365,7 +486,18 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.delete("/{id}")
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
):
# We should add Chroma cleanup here
result = Files.delete_file_by_id(id)
+16 -2
View File
@@ -20,11 +20,13 @@ from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -228,7 +230,19 @@ async def update_folder_is_expanded_by_id(
@router.delete("/{id}")
async def delete_folder_by_id(id: str, user=Depends(get_verified_user)):
async def delete_folder_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
)
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
try:
+2 -4
View File
@@ -517,10 +517,8 @@ async def image_generations(
images = []
for image in res["data"]:
if "url" in image:
image_data, content_type = load_url_image_data(
image["url"], headers
)
if image_url := image.get("url", None):
image_data, content_type = load_url_image_data(image_url, headers)
else:
image_data, content_type = load_b64_image_data(image["b64_json"])
+17 -7
View File
@@ -437,14 +437,24 @@ def remove_file_from_knowledge_by_id(
)
# Remove content from the vector database
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
try:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
try:
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
+8 -4
View File
@@ -57,7 +57,9 @@ async def add_memory(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user),
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user=user
),
"metadata": {"created_at": memory.created_at},
}
],
@@ -82,7 +84,7 @@ async def query_memory(
):
results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}",
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user)],
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
limit=form_data.k,
)
@@ -105,7 +107,9 @@ async def reset_memory_from_vector_db(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user),
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user=user
),
"metadata": {
"created_at": memory.created_at,
"updated_at": memory.updated_at,
@@ -161,7 +165,7 @@ async def update_memory_by_id(
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user
memory.content, user=user
),
"metadata": {
"created_at": memory.created_at,
+27 -13
View File
@@ -295,7 +295,7 @@ async def update_config(
}
@cached(ttl=3)
@cached(ttl=1)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
@@ -336,6 +336,7 @@ async def get_all_models(request: Request, user: UserModel = None):
)
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
model_ids = api_config.get("model_ids", [])
if len(model_ids) != 0 and "models" in response:
@@ -350,6 +351,10 @@ async def get_all_models(request: Request, user: UserModel = None):
for model in response.get("models", []):
model["model"] = f"{prefix_id}.{model['model']}"
if tags:
for model in response.get("models", []):
model["tags"] = tags
def merge_models_lists(model_lists):
merged_models = {}
@@ -460,18 +465,27 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
if request.app.state.config.ENABLE_OLLAMA_API:
if url_idx is None:
# returns lowest version
request_tasks = [
send_get_request(
f"{url}/api/version",
request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
url, {}
), # Legacy support
)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
enable = api_config.get("enable", True)
key = api_config.get("key", None)
if enable:
request_tasks.append(
send_get_request(
f"{url}/api/version",
key,
)
)
responses = await asyncio.gather(*request_tasks)
responses = list(filter(lambda x: x is not None, responses))
@@ -1164,7 +1178,7 @@ async def generate_chat_completion(
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
# payload["keep_alive"] = -1 # keep alive forever
return await send_post_request(
url=f"{url}/api/chat",
payload=json.dumps(payload),
@@ -1183,7 +1197,7 @@ class OpenAIChatMessageContent(BaseModel):
class OpenAIChatMessage(BaseModel):
role: str
content: Union[str, list[OpenAIChatMessageContent]]
content: Union[Optional[str], list[OpenAIChatMessageContent]]
model_config = ConfigDict(extra="allow")
+32 -13
View File
@@ -36,6 +36,9 @@ from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
)
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
@@ -350,6 +353,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
)
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
if prefix_id:
for model in (
@@ -357,6 +361,12 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
):
model["id"] = f"{prefix_id}.{model['id']}"
if tags:
for model in (
response if isinstance(response, list) else response.get("data", [])
):
model["tags"] = tags
log.debug(f"get_all_models:responses() {responses}")
return responses
@@ -374,7 +384,7 @@ async def get_filtered_models(models, user):
return filtered_models
@cached(ttl=3)
@cached(ttl=1)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()")
@@ -396,6 +406,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
for idx, models in enumerate(model_lists):
if models is not None and "error" not in models:
merged_list.extend(
[
{
@@ -406,18 +417,21 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
"urlIdx": idx,
}
for model in models
if "api.openai.com"
not in request.app.state.config.OPENAI_API_BASE_URLS[idx]
or not any(
name in model["id"]
for name in [
"babbage",
"dall-e",
"davinci",
"embedding",
"tts",
"whisper",
]
if (model.get("id") or model.get("name"))
and (
"api.openai.com"
not in request.app.state.config.OPENAI_API_BASE_URLS[idx]
or not any(
name in model["id"]
for name in [
"babbage",
"dall-e",
"davinci",
"embedding",
"tts",
"whisper",
]
)
)
]
)
@@ -666,6 +680,11 @@ async def generate_chat_completion(
del payload["max_tokens"]
# Convert the modified body back to JSON
if "logit_bias" in payload:
payload["logit_bias"] = json.loads(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
payload = json.dumps(payload)
r = None
+2 -2
View File
@@ -90,8 +90,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
headers=headers,
json=request_data,
) as response:
response.raise_for_status()
payload = await response.json()
response.raise_for_status()
except aiohttp.ClientResponseError as e:
res = (
await response.json()
@@ -139,8 +139,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
headers=headers,
json=request_data,
) as response:
response.raise_for_status()
payload = await response.json()
response.raise_for_status()
except aiohttp.ClientResponseError as e:
try:
res = (
+83 -30
View File
@@ -74,7 +74,6 @@ from open_webui.utils.misc import (
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import (
ENV,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
@@ -83,6 +82,8 @@ from open_webui.config import (
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
UPLOAD_DIR,
DEFAULT_LOCALE,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_QUERY_PREFIX,
)
from open_webui.env import (
SRC_LOG_LEVELS,
@@ -123,7 +124,7 @@ def get_ef(
def get_rf(
reranking_model: str,
reranking_model: Optional[str] = None,
auto_update: bool = False,
):
rf = None
@@ -149,8 +150,8 @@ def get_rf(
device=DEVICE_TYPE,
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
)
except:
log.error("CrossEncoder error")
except Exception as e:
log.error(f"CrossEncoder: {e}")
raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error"))
return rf
@@ -173,7 +174,7 @@ class ProcessUrlForm(CollectionNameForm):
url: str
class SearchForm(CollectionNameForm):
class SearchForm(BaseModel):
query: str
@@ -358,10 +359,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL,
"docling_server_url": request.app.state.config.DOCLING_SERVER_URL,
"document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
},
"mistral_ocr_config": {
"api_key": request.app.state.config.MISTRAL_OCR_API_KEY,
},
},
"chunk": {
"text_splitter": request.app.state.config.TEXT_SPLITTER,
@@ -425,10 +430,16 @@ class DocumentIntelligenceConfigForm(BaseModel):
key: str
class MistralOCRConfigForm(BaseModel):
api_key: str
class ContentExtractionConfig(BaseModel):
engine: str = ""
tika_server_url: Optional[str] = None
docling_server_url: Optional[str] = None
document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None
mistral_ocr_config: Optional[MistralOCRConfigForm] = None
class ChunkParamUpdateForm(BaseModel):
@@ -540,6 +551,9 @@ async def update_rag_config(
request.app.state.config.TIKA_SERVER_URL = (
form_data.content_extraction.tika_server_url
)
request.app.state.config.DOCLING_SERVER_URL = (
form_data.content_extraction.docling_server_url
)
if form_data.content_extraction.document_intelligence_config is not None:
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.content_extraction.document_intelligence_config.endpoint
@@ -547,6 +561,10 @@ async def update_rag_config(
request.app.state.config.DOCUMENT_INTELLIGENCE_KEY = (
form_data.content_extraction.document_intelligence_config.key
)
if form_data.content_extraction.mistral_ocr_config is not None:
request.app.state.config.MISTRAL_OCR_API_KEY = (
form_data.content_extraction.mistral_ocr_config.api_key
)
if form_data.chunk is not None:
request.app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter
@@ -648,10 +666,14 @@ async def update_rag_config(
"content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL,
"docling_server_url": request.app.state.config.DOCLING_SERVER_URL,
"document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
},
"mistral_ocr_config": {
"api_key": request.app.state.config.MISTRAL_OCR_API_KEY,
},
},
"chunk": {
"text_splitter": request.app.state.config.TEXT_SPLITTER,
@@ -713,6 +735,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)):
"status": True,
"template": request.app.state.config.RAG_TEMPLATE,
"k": request.app.state.config.TOP_K,
"k_reranker": request.app.state.config.TOP_K_RERANKER,
"r": request.app.state.config.RELEVANCE_THRESHOLD,
"hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
}
@@ -720,6 +743,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)):
class QuerySettingsForm(BaseModel):
k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None
template: Optional[str] = None
hybrid: Optional[bool] = None
@@ -731,16 +755,21 @@ async def update_query_settings(
):
request.app.state.config.RAG_TEMPLATE = form_data.template
request.app.state.config.TOP_K = form_data.k if form_data.k else 4
request.app.state.config.TOP_K_RERANKER = form_data.k_reranker or 4
request.app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = (
form_data.hybrid if form_data.hybrid else False
)
if not request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
request.app.state.rf = None
return {
"status": True,
"template": request.app.state.config.RAG_TEMPLATE,
"k": request.app.state.config.TOP_K,
"k_reranker": request.app.state.config.TOP_K_RERANKER,
"r": request.app.state.config.RELEVANCE_THRESHOLD,
"hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
}
@@ -881,7 +910,9 @@ def save_docs_to_vector_db(
)
embeddings = embedding_function(
list(map(lambda x: x.replace("\n", " "), texts)), user=user
list(map(lambda x: x.replace("\n", " "), texts)),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
)
items = [
@@ -927,7 +958,7 @@ def process_file(
if form_data.content:
# Update the content in the file
# Usage: /files/{file_id}/data/content/update
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
try:
# /files/{file_id}/data/content/update
@@ -990,9 +1021,11 @@ def process_file(
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
)
docs = loader.load(
file.filename, file.meta.get("content_type"), file_path
@@ -1431,12 +1464,6 @@ async def process_web_search(
log.debug(f"web_results: {web_results}")
try:
collection_name = form_data.collection_name
if collection_name == "" or collection_name is None:
collection_name = f"web-search-{calculate_sha256_string(form_data.query)}"[
:63
]
urls = [result.link for result in web_results]
loader = get_web_loader(
urls,
@@ -1445,6 +1472,9 @@ async def process_web_search(
trust_env=request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
)
docs = await loader.aload()
urls = [
doc.metadata["source"] for doc in docs
] # only keep URLs which could be retrieved
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
@@ -1461,18 +1491,26 @@ async def process_web_search(
"loaded_count": len(docs),
}
else:
await run_in_threadpool(
save_docs_to_vector_db,
request,
docs,
collection_name,
overwrite=True,
user=user,
)
collection_names = []
for doc_idx, doc in enumerate(docs):
collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[
:63
]
collection_names.append(collection_name)
await run_in_threadpool(
save_docs_to_vector_db,
request,
[doc],
collection_name,
overwrite=True,
user=user,
)
return {
"status": True,
"collection_name": collection_name,
"collection_names": collection_names,
"filenames": urls,
"loaded_count": len(docs),
}
@@ -1488,6 +1526,7 @@ class QueryDocForm(BaseModel):
collection_name: str
query: str
k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None
hybrid: Optional[bool] = None
@@ -1500,14 +1539,21 @@ def query_doc_handler(
):
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
collection_results = {}
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
collection_name=form_data.collection_name
)
return query_doc_with_hybrid_search(
collection_name=form_data.collection_name,
collection_result=collection_results[form_data.collection_name],
query=form_data.query,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=(
form_data.r
if form_data.r
@@ -1519,7 +1565,7 @@ def query_doc_handler(
return query_doc(
collection_name=form_data.collection_name,
query_embedding=request.app.state.EMBEDDING_FUNCTION(
form_data.query, user=user
form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
user=user,
@@ -1536,6 +1582,7 @@ class QueryCollectionsForm(BaseModel):
collection_names: list[str]
query: str
k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None
hybrid: Optional[bool] = None
@@ -1551,11 +1598,13 @@ def query_collection_handler(
return query_collection_with_hybrid_search(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=(
form_data.r
if form_data.r
@@ -1566,8 +1615,8 @@ def query_collection_handler(
return query_collection(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
)
@@ -1644,7 +1693,11 @@ if ENV == "dev":
@router.get("/ef/{text}")
async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"):
return {"result": request.app.state.EMBEDDING_FUNCTION(text)}
return {
"result": request.app.state.EMBEDDING_FUNCTION(
text, prefix=RAG_EMBEDDING_QUERY_PREFIX
)
}
class BatchProcessFilesForm(BaseModel):
+1 -12
View File
@@ -653,17 +653,6 @@ async def generate_moa_response(
detail="Model not found",
)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f"generating MOA model {task_model_id} for user {user.email} ")
template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE
content = moa_response_generation_template(
@@ -673,7 +662,7 @@ async def generate_moa_response(
)
payload = {
"model": task_model_id,
"model": model_id,
"messages": [{"role": "user", "content": content}],
"stream": form_data.get("stream", False),
"metadata": {
+48 -5
View File
@@ -1,6 +1,7 @@
import logging
from pathlib import Path
from typing import Optional
import time
from open_webui.models.tools import (
ToolForm,
@@ -18,6 +19,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.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"])
@@ -30,11 +33,51 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(user=Depends(get_verified_user)):
if user.role == "admin":
tools = Tools.get_tools()
else:
tools = Tools.get_tools_by_user_id(user.id, "read")
async def get_tools(request: Request, user=Depends(get_verified_user)):
if not request.app.state.TOOL_SERVERS:
# If the tool servers are not set, we need to set them
# This is done only once when the server starts
# This is done to avoid loading the tool servers every time
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
request.app.state.config.TOOL_SERVER_CONNECTIONS
)
tools = Tools.get_tools()
for idx, server in enumerate(request.app.state.TOOL_SERVERS):
tools.append(
ToolUserResponse(
**{
"id": f"server:{server['idx']}",
"user_id": f"server:{server['idx']}",
"name": server["openapi"]
.get("info", {})
.get("title", "Tool Server"),
"meta": {
"description": server["openapi"]
.get("info", {})
.get("description", ""),
},
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
idx
]
.get("config", {})
.get("access_control", None),
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
)
if user.role != "admin":
tools = [
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control)
]
return tools
+26 -5
View File
@@ -2,6 +2,7 @@ import logging
from typing import Optional
from open_webui.models.auths import Auths
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
UserModel,
@@ -17,7 +18,10 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -45,7 +49,7 @@ async def get_users(
@router.get("/groups")
async def get_user_groups(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id)
return Groups.get_groups_by_member_id(user.id)
############################
@@ -54,8 +58,12 @@ async def get_user_groups(user=Depends(get_verified_user)):
@router.get("/permissions")
async def get_user_permissisions(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id)
async def get_user_permissisions(request: Request, user=Depends(get_verified_user)):
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return user_permissions
############################
@@ -68,15 +76,24 @@ class WorkspacePermissions(BaseModel):
tools: bool = False
class SharingPermissions(BaseModel):
public_models: bool = True
public_knowledge: bool = True
public_prompts: bool = True
public_tools: bool = True
class ChatPermissions(BaseModel):
controls: bool = True
file_upload: bool = True
delete: bool = True
edit: bool = True
temporary: bool = True
temporary_enforced: bool = False
class FeaturesPermissions(BaseModel):
direct_tool_servers: bool = False
web_search: bool = True
image_generation: bool = True
code_interpreter: bool = True
@@ -84,16 +101,20 @@ class FeaturesPermissions(BaseModel):
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
sharing: SharingPermissions
chat: ChatPermissions
features: FeaturesPermissions
@router.get("/default/permissions", response_model=UserPermissions)
async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
return {
"workspace": WorkspacePermissions(
**request.app.state.config.USER_PERMISSIONS.get("workspace", {})
),
"sharing": SharingPermissions(
**request.app.state.config.USER_PERMISSIONS.get("sharing", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),
@@ -104,7 +125,7 @@ async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
@router.post("/default/permissions")
async def update_user_permissions(
async def update_default_user_permissions(
request: Request, form_data: UserPermissions, user=Depends(get_admin_user)
):
request.app.state.config.USER_PERMISSIONS = form_data.model_dump()
+80 -35
View File
@@ -3,16 +3,24 @@ import socketio
import logging
import sys
import time
from redis import asyncio as aioredis
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.utils.redis import (
parse_redis_sentinel_url,
get_sentinels_from_env,
AsyncRedisSentinelManager,
)
from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL,
WEBSOCKET_REDIS_LOCK_TIMEOUT,
WEBSOCKET_SENTINEL_PORT,
WEBSOCKET_SENTINEL_HOSTS,
)
from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock
@@ -29,7 +37,19 @@ log.setLevel(SRC_LOG_LEVELS["SOCKET"])
if WEBSOCKET_MANAGER == "redis":
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL)
if WEBSOCKET_SENTINEL_HOSTS:
redis_config = parse_redis_sentinel_url(WEBSOCKET_REDIS_URL)
mgr = AsyncRedisSentinelManager(
WEBSOCKET_SENTINEL_HOSTS.split(","),
sentinel_port=int(WEBSOCKET_SENTINEL_PORT),
redis_port=redis_config["port"],
service=redis_config["service"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
)
else:
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL)
sio = socketio.AsyncServer(
cors_allowed_origins=[],
async_mode="asgi",
@@ -55,14 +75,30 @@ TIMEOUT_DURATION = 3
if WEBSOCKET_MANAGER == "redis":
log.debug("Using Redis to manage websockets.")
SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL)
USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL)
USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL)
redis_sentinels = get_sentinels_from_env(
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
)
SESSION_POOL = RedisDict(
"open-webui:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
USER_POOL = RedisDict(
"open-webui:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
USAGE_POOL = RedisDict(
"open-webui:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL,
lock_name="usage_cleanup_lock",
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
)
aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock
@@ -269,11 +305,19 @@ async def disconnect(sid):
# print(f"Unknown session ID {sid} disconnected")
def get_event_emitter(request_info):
def get_event_emitter(request_info, update_db=True):
async def __event_emitter__(event_data):
user_id = request_info["user_id"]
session_ids = list(
set(USER_POOL.get(user_id, []) + [request_info["session_id"]])
set(
USER_POOL.get(user_id, [])
+ (
[request_info.get("session_id")]
if request_info.get("session_id")
else []
)
)
)
for session_id in session_ids:
@@ -287,40 +331,41 @@ def get_event_emitter(request_info):
to=session_id,
)
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
event_data.get("data", {}),
)
if update_db:
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
return __event_emitter__
+9 -5
View File
@@ -1,15 +1,17 @@
import json
import redis
import uuid
from open_webui.utils.redis import get_redis_connection
class RedisLock:
def __init__(self, redis_url, lock_name, timeout_secs):
def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]):
self.lock_name = lock_name
self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs
self.lock_obtained = False
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
)
def aquire_lock(self):
# nx=True will only set this key if it _hasn't_ already been set
@@ -31,9 +33,11 @@ class RedisLock:
class RedisDict:
def __init__(self, name, redis_url):
def __init__(self, name, redis_url, redis_sentinels=[]):
self.name = name
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
)
def __setitem__(self, key, value):
serialized_value = json.dumps(value)
+2 -2
View File
@@ -3,13 +3,13 @@
"short_name": "WebUI",
"icons": [
{
"src": "/favicon/web-app-manifest-192x192.png",
"src": "/static/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/web-app-manifest-512x512.png",
"src": "/static/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+14 -5
View File
@@ -8,7 +8,9 @@ import requests
import os
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import pytz
from pytz import UTC
from typing import Optional, Union, List, Dict
from open_webui.models.users import Users
@@ -72,7 +74,7 @@ def get_license_data(app, key):
if key:
try:
res = requests.post(
"https://api.openwebui.com/api/v1/license",
"https://api.openwebui.com/api/v1/license/",
json={"key": key, "version": "1"},
timeout=5,
)
@@ -141,12 +143,14 @@ def create_api_key():
return f"sk-{key}"
def get_http_authorization_cred(auth_header: str):
def get_http_authorization_cred(auth_header: Optional[str]):
if not auth_header:
return None
try:
scheme, credentials = auth_header.split(" ")
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
except Exception:
raise ValueError(ERROR_MESSAGES.INVALID_TOKEN)
return None
def get_current_user(
@@ -180,7 +184,12 @@ def get_current_user(
).split(",")
]
if request.url.path not in allowed_paths:
# Check if the request path matches any allowed endpoint.
if not any(
request.url.path == allowed
or request.url.path.startswith(allowed + "/")
for allowed in allowed_paths
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
+5 -4
View File
@@ -12,9 +12,9 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_sorted_filter_ids(model: dict):
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
if function is not None and hasattr(function, "valves"):
# TODO: Fix FunctionModel to include vavles
return (function.valves if function.valves else {}).get("priority", 0)
if function is not None:
valves = Functions.get_function_valves_by_id(function_id)
return valves.get("priority", 0) if valves else 0
return 0
filter_ids = [function.id for function in Functions.get_global_filter_functions()]
@@ -101,11 +101,12 @@ async def process_filter_functions(
form_data = handler(**params)
except Exception as e:
log.exception(f"Error in {filter_type} handler {filter_id}: {e}")
log.debug(f"Error in {filter_type} handler {filter_id}: {e}")
raise e
# Handle file cleanup for inlet
if skip_files and "files" in form_data.get("metadata", {}):
del form_data["files"]
del form_data["metadata"]["files"]
return form_data, {}
+260 -90
View File
@@ -18,9 +18,7 @@ from uuid import uuid4
from concurrent.futures import ThreadPoolExecutor
from fastapi import Request
from fastapi import BackgroundTasks
from fastapi import Request, HTTPException
from starlette.responses import Response, StreamingResponse
@@ -100,7 +98,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def chat_completion_tools_handler(
request: Request, body: dict, user: UserModel, models, tools
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
async def get_content_from_response(response) -> Optional[str]:
content = None
@@ -135,6 +133,9 @@ async def chat_completion_tools_handler(
"metadata": {"task": str(TASKS.FUNCTION_CALLING)},
}
event_caller = extra_params["__event_call__"]
metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id(
body["model"],
request.app.state.config.TASK_MODEL,
@@ -156,7 +157,6 @@ async def chat_completion_tools_handler(
tools_function_calling_prompt = tools_function_calling_generation_template(
template, tools_specs
)
log.info(f"{tools_function_calling_prompt=}")
payload = get_tools_function_calling_payload(
body["messages"], task_model_id, tools_function_calling_prompt
)
@@ -189,34 +189,73 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {})
try:
required_params = (
tools[tool_function_name]
.get("spec", {})
.get("parameters", {})
.get("required", [])
tool = tools[tool_function_name]
spec = tool.get("spec", {})
allowed_params = (
spec.get("parameters", {}).get("properties", {}).keys()
)
tool_function = tools[tool_function_name]["callable"]
tool_function_params = {
k: v
for k, v in tool_function_params.items()
if k in required_params
if k in allowed_params
}
tool_output = await tool_function(**tool_function_params)
if tool.get("direct", False):
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_function_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get("session_id", None),
},
}
)
else:
tool_function = tool["callable"]
tool_result = await tool_function(**tool_function_params)
except Exception as e:
tool_output = str(e)
tool_result = str(e)
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item)
tool_result.remove(item)
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2)
if isinstance(tool_result, str):
tool = tools[tool_function_name]
tool_id = tool.get("tool_id", "")
if tool.get("metadata", {}).get("citation", False) or tool.get(
"direct", False
):
if isinstance(tool_output, str):
if tools[tool_function_name]["citation"]:
sources.append(
{
"source": {
"name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}"
"name": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
),
},
"document": [tool_output],
"document": [tool_result, *tool_result_files],
"metadata": [
{
"source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}"
"source": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
)
}
],
}
@@ -225,16 +264,24 @@ async def chat_completion_tools_handler(
sources.append(
{
"source": {},
"document": [tool_output],
"document": [tool_result, *tool_result_files],
"metadata": [
{
"source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}"
"source": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
)
}
],
}
)
if tools[tool_function_name]["file_handler"]:
if (
tools[tool_function_name]
.get("metadata", {})
.get("file_handler", False)
):
skip_files = True
# check if "tool_calls" in result
@@ -245,10 +292,10 @@ async def chat_completion_tools_handler(
await tool_call_handler(result)
except Exception as e:
log.exception(f"Error: {e}")
log.debug(f"Error: {e}")
content = None
except Exception as e:
log.exception(f"Error: {e}")
log.debug(f"Error: {e}")
content = None
log.debug(f"tool_contexts: {sources}")
@@ -352,24 +399,44 @@ async def chat_web_search_handler(
all_results.append(results)
files = form_data.get("files", [])
if results.get("collection_name"):
files.append(
{
"collection_name": results["collection_name"],
"name": searchQuery,
"type": "web_search",
"urls": results["filenames"],
}
)
if results.get("collection_names"):
for col_idx, collection_name in enumerate(
results.get("collection_names")
):
files.append(
{
"collection_name": collection_name,
"name": searchQuery,
"type": "web_search",
"urls": [results["filenames"][col_idx]],
}
)
elif results.get("docs"):
files.append(
{
"docs": results.get("docs", []),
"name": searchQuery,
"type": "web_search",
"urls": results["filenames"],
}
)
# Invoked when bypass embedding and retrieval is set to True
docs = results["docs"]
if len(docs) == len(results["filenames"]):
# the number of docs and filenames (urls) should be the same
for doc_idx, doc in enumerate(docs):
files.append(
{
"docs": [doc],
"name": searchQuery,
"type": "web_search",
"urls": [results["filenames"][doc_idx]],
}
)
else:
# edge case when the number of docs and filenames (urls) are not the same
# this should not happen, but if it does, we will just append the docs
files.append(
{
"docs": results.get("docs", []),
"name": searchQuery,
"type": "web_search",
"urls": results["filenames"],
}
)
form_data["files"] = files
except Exception as e:
@@ -562,11 +629,12 @@ async def chat_completion_files_handler(
request=request,
files=files,
queries=queries,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
k_reranker=request.app.state.config.TOP_K_RERANKER,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
full_context=request.app.state.config.RAG_FULL_CONTEXT,
@@ -591,27 +659,28 @@ 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:
if "seed" in params and params["seed"] is not None:
form_data["seed"] = params["seed"]
if "stop" in params:
if "stop" in params and params["stop"] is not None:
form_data["stop"] = params["stop"]
if "temperature" in params:
if "temperature" in params and params["temperature"] is not None:
form_data["temperature"] = params["temperature"]
if "max_tokens" in params:
if "max_tokens" in params and params["max_tokens"] is not None:
form_data["max_tokens"] = params["max_tokens"]
if "top_p" in params:
if "top_p" in params and params["top_p"] is not None:
form_data["top_p"] = params["top_p"]
if "frequency_penalty" in params:
if "frequency_penalty" in params and params["frequency_penalty"] is not None:
form_data["frequency_penalty"] = params["frequency_penalty"]
if "reasoning_effort" in params:
if "reasoning_effort" in params and params["reasoning_effort"] is not None:
form_data["reasoning_effort"] = params["reasoning_effort"]
if "logit_bias" in params:
if "logit_bias" in params and params["logit_bias"] is not None:
try:
form_data["logit_bias"] = json.loads(
convert_logit_bias_input_to_json(params["logit_bias"])
@@ -766,12 +835,18 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
form_data["metadata"] = metadata
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
log.debug(f"{tool_servers=}")
tools_dict = {}
if tool_ids:
# If tool_ids field is present, then get the tools
tools = get_tools(
tools_dict = get_tools(
request,
tool_ids,
user,
@@ -782,20 +857,31 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__files__": metadata.get("files", []),
},
)
log.info(f"{tools=}")
if tool_servers:
for tool_server in tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
if tools_dict:
if metadata.get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools
metadata["tools"] = tools_dict
form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})}
for tool in tools.values()
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
form_data, flags = await chat_completion_tools_handler(
request, form_data, user, models, tools
request, form_data, extra_params, user, models, tools_dict
)
sources.extend(flags.get("sources", []))
@@ -814,7 +900,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for source_idx, source in enumerate(sources):
if "document" in source:
for doc_idx, doc_context in enumerate(source["document"]):
context_string += f"<source><source_id>{source_idx}</source_id><source_context>{doc_context}</source_context></source>\n"
context_string += (
f'<source id="{source_idx + 1}">{doc_context}</source>\n'
)
context_string = context_string.strip()
prompt = get_last_user_message(form_data["messages"])
@@ -991,6 +1079,16 @@ async def process_chat_response(
# Non-streaming response
if not isinstance(response, StreamingResponse):
if event_emitter:
if "error" in response:
error = response["error"].get("detail", response["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
)
if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
@@ -1000,7 +1098,8 @@ async def process_chat_response(
},
)
if response.get("choices", [])[0].get("message", {}).get("content"):
choices = response.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"]
if content:
@@ -1081,8 +1180,6 @@ async def process_chat_response(
for filter_id in get_sorted_filter_ids(model)
]
print(f"{filter_functions=}")
# Streaming response
if event_emitter and event_caller:
task_id = str(uuid4()) # Create a unique task ID.
@@ -1121,36 +1218,53 @@ async def process_chat_response(
elif block["type"] == "tool_calls":
attributes = block.get("attributes", {})
block_content = block.get("content", [])
tool_calls = block.get("content", [])
results = block.get("results", [])
if results:
result_display_content = ""
tool_calls_display_content = ""
for tool_call in tool_calls:
for result in results:
tool_call_id = result.get("tool_call_id", "")
tool_name = ""
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_arguments = tool_call.get("function", {}).get(
"arguments", ""
)
for tool_call in block_content:
if tool_call.get("id", "") == tool_call_id:
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_result = None
tool_result_files = None
for result in results:
if tool_call_id == result.get("tool_call_id", ""):
tool_result = result.get("content", None)
tool_result_files = result.get("files", None)
break
result_display_content = f"{result_display_content}\n> {tool_name}: {result.get('content', '')}"
if tool_result:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
if not raw:
content = f'{content}\n<details type="tool_calls" done="true" content="{html.escape(json.dumps(block_content))}" results="{html.escape(json.dumps(results))}">\n<summary>Tool Executed</summary>\n{result_display_content}\n</details>\n'
content = f"{content}\n{tool_calls_display_content}\n\n"
else:
tool_calls_display_content = ""
for tool_call in block_content:
tool_calls_display_content = f"{tool_calls_display_content}\n> Executing {tool_call.get('function', {}).get('name', '')}"
for tool_call in tool_calls:
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_arguments = tool_call.get("function", {}).get(
"arguments", ""
)
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
if not raw:
content = f'{content}\n<details type="tool_calls" done="false" content="{html.escape(json.dumps(block_content))}">\n<summary>Tool Executing...</summary>\n{tool_calls_display_content}\n</details>\n'
content = f"{content}\n{tool_calls_display_content}\n\n"
elif block["type"] == "reasoning":
reasoning_display_content = "\n".join(
@@ -1507,6 +1621,16 @@ async def process_chat_response(
else:
choices = data.get("choices", [])
if not choices:
error = data.get("error", {})
if error:
await event_emitter(
{
"type": "chat:completion",
"data": {
"error": error,
},
}
)
usage = data.get("usage", {})
if usage:
await event_emitter(
@@ -1562,7 +1686,9 @@ async def process_chat_response(
value = delta.get("content")
reasoning_content = delta.get("reasoning_content")
reasoning_content = delta.get(
"reasoning_content"
) or delta.get("reasoning")
if reasoning_content:
if (
not content_blocks
@@ -1718,7 +1844,7 @@ async def process_chat_response(
await stream_body_handler(response)
MAX_TOOL_CALL_RETRIES = 5
MAX_TOOL_CALL_RETRIES = 10
tool_call_retries = 0
while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES:
@@ -1757,6 +1883,15 @@ async def process_chat_response(
)
except Exception as e:
log.debug(e)
# Fallback to JSON parsing
try:
tool_function_params = json.loads(
tool_call.get("function", {}).get("arguments", "{}")
)
except Exception as e:
log.debug(
f"Error parsing tool call arguments: {tool_call.get('function', {}).get('arguments', '{}')}"
)
tool_result = None
@@ -1765,25 +1900,65 @@ async def process_chat_response(
spec = tool.get("spec", {})
try:
required_params = spec.get("parameters", {}).get(
"required", []
allowed_params = (
spec.get("parameters", {})
.get("properties", {})
.keys()
)
tool_function = tool["callable"]
tool_function_params = {
k: v
for k, v in tool_function_params.items()
if k in required_params
if k in allowed_params
}
tool_result = await tool_function(
**tool_function_params
)
if tool.get("direct", False):
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get(
"session_id", None
),
},
}
)
else:
tool_function = tool["callable"]
tool_result = await tool_function(
**tool_function_params
)
except Exception as e:
tool_result = str(e)
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item)
tool_result.remove(item)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
tool_result = json.dumps(tool_result, indent=2)
results.append(
{
"tool_call_id": tool_call_id,
"content": tool_result,
**(
{"files": tool_result_files}
if tool_result_files
else {}
),
}
)
@@ -1982,11 +2157,6 @@ async def process_chat_response(
}
)
log.info(f"content_blocks={content_blocks}")
log.info(
f"serialize_content_blocks={serialize_content_blocks(content_blocks)}"
)
try:
res = await generate_chat_completion(
request,
+1
View File
@@ -49,6 +49,7 @@ async def get_all_base_models(request: Request, user: UserModel = None):
"created": int(time.time()),
"owned_by": "ollama",
"ollama": model,
"tags": model.get("tags", []),
}
for model in ollama_models["models"]
]
+46 -38
View File
@@ -94,7 +94,7 @@ class OAuthManager:
oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
oauth_roles = None
oauth_roles = []
# Default/fallback role if no matching roles are found
role = auth_manager_config.DEFAULT_USER_ROLE
@@ -104,7 +104,7 @@ class OAuthManager:
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
oauth_roles = claim_data if isinstance(claim_data, list) else None
oauth_roles = claim_data if isinstance(claim_data, list) else []
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
@@ -140,6 +140,7 @@ class OAuthManager:
log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
user_oauth_groups = []
# Nested claim search for groups claim
if oauth_claim:
claim_data = user_data
@@ -160,7 +161,7 @@ class OAuthManager:
# Remove groups that user is no longer a part of
for group_model in user_current_groups:
if group_model.name not in user_oauth_groups:
if user_oauth_groups and group_model.name not in user_oauth_groups:
# Remove group from user
log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
@@ -186,8 +187,10 @@ class OAuthManager:
# Add user to new groups
for group_model in all_available_groups:
if group_model.name in user_oauth_groups and not any(
gm.name == group_model.name for gm in user_current_groups
if (
user_oauth_groups
and group_model.name in user_oauth_groups
and not any(gm.name == group_model.name for gm in user_current_groups)
):
# Add user to group
log.debug(
@@ -323,40 +326,45 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
if picture_url:
# Download the profile image into a base64 string
try:
access_token = token.get("access_token")
get_kwargs = {}
if access_token:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session:
async with session.get(picture_url, **get_kwargs) as resp:
if resp.ok:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(
picture
).decode("utf-8")
guessed_mime_type = mimetypes.guess_type(
picture_url
)[0]
if guessed_mime_type is None:
# assume JPG, browsers are tolerant enough of image formats
guessed_mime_type = "image/jpeg"
picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
else:
picture_url = "/user.png"
except Exception as e:
log.error(
f"Error downloading profile image '{picture_url}': {e}"
)
if picture_claim:
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
if picture_url:
# Download the profile image into a base64 string
try:
access_token = token.get("access_token")
get_kwargs = {}
if access_token:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session:
async with session.get(
picture_url, **get_kwargs
) as resp:
if resp.ok:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(
picture
).decode("utf-8")
guessed_mime_type = mimetypes.guess_type(
picture_url
)[0]
if guessed_mime_type is None:
# assume JPG, browsers are tolerant enough of image formats
guessed_mime_type = "image/jpeg"
picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
else:
picture_url = "/user.png"
except Exception as e:
log.error(
f"Error downloading profile image '{picture_url}': {e}"
)
picture_url = "/user.png"
if not picture_url:
picture_url = "/user.png"
if not picture_url:
else:
picture_url = "/user.png"
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
+24
View File
@@ -63,6 +63,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
"seed": lambda x: x,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"logit_bias": lambda x: x,
"response_format": dict,
}
return apply_model_params_to_body(params, form_data, mappings)
@@ -110,6 +111,15 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"num_thread": int,
}
# Extract keep_alive from options if it exists
if "options" in form_data and "keep_alive" in form_data["options"]:
form_data["keep_alive"] = form_data["options"]["keep_alive"]
del form_data["options"]["keep_alive"]
if "options" in form_data and "format" in form_data["options"]:
form_data["format"] = form_data["options"]["format"]
del form_data["options"]["format"]
return apply_model_params_to_body(params, form_data, mappings)
@@ -231,6 +241,11 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
"system"
] # To prevent Ollama warning of invalid option provided
# Extract keep_alive from options if it exists
if "keep_alive" in ollama_options:
ollama_payload["keep_alive"] = ollama_options["keep_alive"]
del ollama_options["keep_alive"]
# If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options
if "stop" in openai_payload:
ollama_options = ollama_payload.get("options", {})
@@ -240,4 +255,13 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
if "metadata" in openai_payload:
ollama_payload["metadata"] = openai_payload["metadata"]
if "response_format" in openai_payload:
response_format = openai_payload["response_format"]
format_type = response_format.get("type", None)
schema = response_format.get(format_type, None)
if schema:
format = schema.get("schema", None)
ollama_payload["format"] = format
return ollama_payload
+16 -12
View File
@@ -7,7 +7,7 @@ import types
import tempfile
import logging
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS
from open_webui.models.functions import Functions
from open_webui.models.tools import Tools
@@ -68,23 +68,23 @@ def replace_imports(content):
return content
def load_tools_module_by_id(toolkit_id, content=None):
def load_tools_module_by_id(tool_id, content=None):
if content is None:
tool = Tools.get_tool_by_id(toolkit_id)
tool = Tools.get_tool_by_id(tool_id)
if not tool:
raise Exception(f"Toolkit not found: {toolkit_id}")
raise Exception(f"Toolkit not found: {tool_id}")
content = tool.content
content = replace_imports(content)
Tools.update_tool_by_id(toolkit_id, {"content": content})
Tools.update_tool_by_id(tool_id, {"content": content})
else:
frontmatter = extract_frontmatter(content)
# Install required packages found within the frontmatter
install_frontmatter_requirements(frontmatter.get("requirements", ""))
module_name = f"tool_{toolkit_id}"
module_name = f"tool_{tool_id}"
module = types.ModuleType(module_name)
sys.modules[module_name] = module
@@ -108,7 +108,7 @@ def load_tools_module_by_id(toolkit_id, content=None):
else:
raise Exception("No Tools class found in the module")
except Exception as e:
log.error(f"Error loading module: {toolkit_id}: {e}")
log.error(f"Error loading module: {tool_id}: {e}")
del sys.modules[module_name] # Clean up
raise e
finally:
@@ -165,15 +165,19 @@ def load_function_module_by_id(function_id, content=None):
os.unlink(temp_file.name)
def install_frontmatter_requirements(requirements):
def install_frontmatter_requirements(requirements: str):
if requirements:
try:
req_list = [req.strip() for req in requirements.split(",")]
for req in req_list:
log.info(f"Installing requirement: {req}")
subprocess.check_call([sys.executable, "-m", "pip", "install", req])
log.info(f"Installing requirements: {' '.join(req_list)}")
subprocess.check_call(
[sys.executable, "-m", "pip", "install"]
+ PIP_OPTIONS
+ req_list
+ PIP_PACKAGE_INDEX_OPTIONS
)
except Exception as e:
log.error(f"Error installing package: {req}")
log.error(f"Error installing packages: {' '.join(req_list)}")
raise e
else:
+109
View File
@@ -0,0 +1,109 @@
import socketio
import redis
from redis import asyncio as aioredis
from urllib.parse import urlparse
def parse_redis_sentinel_url(redis_url):
parsed_url = urlparse(redis_url)
if parsed_url.scheme != "redis":
raise ValueError("Invalid Redis URL scheme. Must be 'redis'.")
return {
"username": parsed_url.username or None,
"password": parsed_url.password or None,
"service": parsed_url.hostname or "mymaster",
"port": parsed_url.port or 6379,
"db": int(parsed_url.path.lstrip("/") or 0),
}
def get_redis_connection(redis_url, redis_sentinels, decode_responses=True):
if redis_sentinels:
redis_config = parse_redis_sentinel_url(redis_url)
sentinel = redis.sentinel.Sentinel(
redis_sentinels,
port=redis_config["port"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
decode_responses=decode_responses,
)
# Get a master connection from Sentinel
return sentinel.master_for(redis_config["service"])
else:
# Standard Redis connection
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
if sentinel_hosts_env:
sentinel_hosts = sentinel_hosts_env.split(",")
sentinel_port = int(sentinel_port_env)
return [(host, sentinel_port) for host in sentinel_hosts]
return []
class AsyncRedisSentinelManager(socketio.AsyncRedisManager):
def __init__(
self,
sentinel_hosts,
sentinel_port=26379,
redis_port=6379,
service="mymaster",
db=0,
username=None,
password=None,
channel="socketio",
write_only=False,
logger=None,
redis_options=None,
):
"""
Initialize the Redis Sentinel Manager.
This implementation mostly replicates the __init__ of AsyncRedisManager and
overrides _redis_connect() with a version that uses Redis Sentinel
:param sentinel_hosts: List of Sentinel hosts
:param sentinel_port: Sentinel Port
:param redis_port: Redis Port (currently unsupported by aioredis!)
:param service: Master service name in Sentinel
:param db: Redis database to use
:param username: Redis username (if any) (currently unsupported by aioredis!)
:param password: Redis password (if any)
:param channel: The channel name on which the server sends and receives
notifications. Must be the same in all the servers.
:param write_only: If set to ``True``, only initialize to emit events. The
default of ``False`` initializes the class for emitting
and receiving.
:param redis_options: additional keyword arguments to be passed to
``aioredis.from_url()``.
"""
self._sentinels = [(host, sentinel_port) for host in sentinel_hosts]
self._redis_port = redis_port
self._service = service
self._db = db
self._username = username
self._password = password
self._channel = channel
self.redis_options = redis_options or {}
# connect and call grandparent constructor
self._redis_connect()
super(socketio.AsyncRedisManager, self).__init__(
channel=channel, write_only=write_only, logger=logger
)
def _redis_connect(self):
"""Establish connections to Redis through Sentinel."""
sentinel = aioredis.sentinel.Sentinel(
self._sentinels,
port=self._redis_port,
db=self._db,
password=self._password,
**self.redis_options,
)
self.redis = sentinel.master_for(self._service)
self.pubsub = self.redis.pubsub(ignore_subscribe_messages=True)
@@ -0,0 +1,26 @@
from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes
# Span Tags
SPAN_DB_TYPE = "mysql"
SPAN_REDIS_TYPE = "redis"
SPAN_DURATION = "duration"
SPAN_SQL_STR = "sql"
SPAN_SQL_EXPLAIN = "explain"
SPAN_ERROR_TYPE = "error"
class SpanAttributes(_SpanAttributes):
"""
Span Attributes
"""
DB_INSTANCE = "db.instance"
DB_TYPE = "db.type"
DB_IP = "db.ip"
DB_PORT = "db.port"
ERROR_KIND = "error.kind"
ERROR_OBJECT = "error.object"
ERROR_MESSAGE = "error.message"
RESULT_CODE = "result.code"
RESULT_MESSAGE = "result.message"
RESULT_ERRORS = "result.errors"
@@ -0,0 +1,31 @@
import threading
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import BatchSpanProcessor
class LazyBatchSpanProcessor(BatchSpanProcessor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.done = True
with self.condition:
self.condition.notify_all()
self.worker_thread.join()
self.done = False
self.worker_thread = None
def on_end(self, span: ReadableSpan) -> None:
if self.worker_thread is None:
self.worker_thread = threading.Thread(
name=self.__class__.__name__, target=self.worker, daemon=True
)
self.worker_thread.start()
super().on_end(span)
def shutdown(self) -> None:
self.done = True
with self.condition:
self.condition.notify_all()
if self.worker_thread:
self.worker_thread.join()
self.span_exporter.shutdown()
@@ -0,0 +1,202 @@
import logging
import traceback
from typing import Collection, Union
from aiohttp import (
TraceRequestStartParams,
TraceRequestEndParams,
TraceRequestExceptionParams,
)
from chromadb.telemetry.opentelemetry.fastapi import instrument_fastapi
from fastapi import FastAPI
from opentelemetry.instrumentation.httpx import (
HTTPXClientInstrumentor,
RequestInfo,
ResponseInfo,
)
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
from opentelemetry.trace import Span, StatusCode
from redis import Redis
from requests import PreparedRequest, Response
from sqlalchemy import Engine
from fastapi import status
from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes
from open_webui.env import SRC_LOG_LEVELS
logger = logging.getLogger(__name__)
logger.setLevel(SRC_LOG_LEVELS["MAIN"])
def requests_hook(span: Span, request: PreparedRequest):
"""
Http Request Hook
"""
span.update_name(f"{request.method} {request.url}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: request.url,
SpanAttributes.HTTP_METHOD: request.method,
}
)
def response_hook(span: Span, request: PreparedRequest, response: Response):
"""
HTTP Response Hook
"""
span.set_attributes(
attributes={
SpanAttributes.HTTP_STATUS_CODE: response.status_code,
}
)
span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK)
def redis_request_hook(span: Span, instance: Redis, args, kwargs):
"""
Redis Request Hook
"""
try:
connection_kwargs: dict = instance.connection_pool.connection_kwargs
host = connection_kwargs.get("host")
port = connection_kwargs.get("port")
db = connection_kwargs.get("db")
span.set_attributes(
{
SpanAttributes.DB_INSTANCE: f"{host}/{db}",
SpanAttributes.DB_NAME: f"{host}/{db}",
SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE,
SpanAttributes.DB_PORT: port,
SpanAttributes.DB_IP: host,
SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]),
SpanAttributes.DB_OPERATION: str(args[0]),
}
)
except Exception: # pylint: disable=W0718
logger.error(traceback.format_exc())
def httpx_request_hook(span: Span, request: RequestInfo):
"""
HTTPX Request Hook
"""
span.update_name(f"{request.method.decode()} {str(request.url)}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
SpanAttributes.HTTP_METHOD: request.method.decode(),
}
)
def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo):
"""
HTTPX Response Hook
"""
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code)
span.set_status(
StatusCode.ERROR
if response.status_code >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
async def httpx_async_request_hook(span: Span, request: RequestInfo):
"""
Async Request Hook
"""
httpx_request_hook(span, request)
async def httpx_async_response_hook(
span: Span, request: RequestInfo, response: ResponseInfo
):
"""
Async Response Hook
"""
httpx_response_hook(span, request, response)
def aiohttp_request_hook(span: Span, request: TraceRequestStartParams):
"""
Aiohttp Request Hook
"""
span.update_name(f"{request.method} {str(request.url)}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
SpanAttributes.HTTP_METHOD: request.method,
}
)
def aiohttp_response_hook(
span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams]
):
"""
Aiohttp Response Hook
"""
if isinstance(response, TraceRequestEndParams):
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status)
span.set_status(
StatusCode.ERROR
if response.response.status >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
elif isinstance(response, TraceRequestExceptionParams):
span.set_status(StatusCode.ERROR)
span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception))
class Instrumentor(BaseInstrumentor):
"""
Instrument OT
"""
def __init__(self, app: FastAPI, db_engine: Engine):
self.app = app
self.db_engine = db_engine
def instrumentation_dependencies(self) -> Collection[str]:
return []
def _instrument(self, **kwargs):
instrument_fastapi(app=self.app)
SQLAlchemyInstrumentor().instrument(engine=self.db_engine)
RedisInstrumentor().instrument(request_hook=redis_request_hook)
RequestsInstrumentor().instrument(
request_hook=requests_hook, response_hook=response_hook
)
LoggingInstrumentor().instrument()
HTTPXClientInstrumentor().instrument(
request_hook=httpx_request_hook,
response_hook=httpx_response_hook,
async_request_hook=httpx_async_request_hook,
async_response_hook=httpx_async_response_hook,
)
AioHttpClientInstrumentor().instrument(
request_hook=aiohttp_request_hook,
response_hook=aiohttp_response_hook,
)
def _uninstrument(self, **kwargs):
if getattr(self, "instrumentors", None) is None:
return
for instrumentor in self.instrumentors:
instrumentor.uninstrument()
@@ -0,0 +1,23 @@
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from sqlalchemy import Engine
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
from open_webui.utils.telemetry.instrumentors import Instrumentor
from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
def setup(app: FastAPI, db_engine: Engine):
# set up trace
trace.set_tracer_provider(
TracerProvider(
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
)
)
# otlp export
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
Instrumentor(app=app, db_engine=db_engine).instrument()
+400 -62
View File
@@ -1,7 +1,11 @@
import inspect
import logging
import re
from typing import Any, Awaitable, Callable, get_type_hints
import inspect
import aiohttp
import asyncio
from typing import Any, Awaitable, Callable, get_type_hints, Dict, List, Union, Optional
from functools import update_wrapper, partial
@@ -14,95 +18,162 @@ from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.utils.plugin import load_tools_module_by_id
import copy
log = logging.getLogger(__name__)
def apply_extra_params_to_tool_function(
def get_async_tool_function_and_apply_extra_params(
function: Callable, extra_params: dict
) -> Callable[..., Awaitable]:
sig = inspect.signature(function)
extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters}
partial_func = partial(function, **extra_params)
if inspect.iscoroutinefunction(function):
update_wrapper(partial_func, function)
return partial_func
else:
# Make it a coroutine function
async def new_function(*args, **kwargs):
return partial_func(*args, **kwargs)
async def new_function(*args, **kwargs):
return partial_func(*args, **kwargs)
update_wrapper(new_function, function)
return new_function
update_wrapper(new_function, function)
return new_function
# Mutation on extra_params
def get_tools(
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
) -> dict[str, dict]:
tools_dict = {}
for tool_id in tool_ids:
tools = Tools.get_tool_by_id(tool_id)
if tools is None:
continue
tool = Tools.get_tool_by_id(tool_id)
if tool is None:
if tool_id.startswith("server:"):
server_idx = int(tool_id.split(":")[1])
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[server_idx]
)
tool_server_data = request.app.state.TOOL_SERVERS[server_idx]
specs = tool_server_data.get("specs", [])
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_tools_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
for spec in specs:
function_name = spec["name"]
extra_params["__id__"] = tool_id
if hasattr(module, "valves") and hasattr(module, "Valves"):
valves = Tools.get_tool_valves_by_id(tool_id) or {}
module.valves = module.Valves(**valves)
auth_type = tool_server_connection.get("auth_type", "bearer")
token = None
if hasattr(module, "UserValves"):
extra_params["__user__"]["valves"] = module.UserValves( # type: ignore
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
if auth_type == "bearer":
token = tool_server_connection.get("key", "")
elif auth_type == "session":
token = request.state.token.credentials
for spec in tools.specs:
# 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":
val["type"] = "string"
def make_tool_function(function_name, token, tool_server_data):
async def tool_function(**kwargs):
print(
f"Executing tool function {function_name} with params: {kwargs}"
)
return await execute_tool_server(
token=token,
url=tool_server_data["url"],
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
# Remove internal parameters
spec["parameters"]["properties"] = {
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
return tool_function
function_name = spec["name"]
tool_function = make_tool_function(
function_name, token, tool_server_data
)
# convert to function that takes only model params and inserts custom params
original_func = getattr(module, function_name)
callable = apply_extra_params_to_tool_function(original_func, extra_params)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
if callable.__doc__ and callable.__doc__.strip() != "":
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
}
# TODO: if collision, prepend toolkit name
if function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
log.warning(f"Discarding {tool_id}.{function_name}")
else:
tools_dict[function_name] = tool_dict
else:
spec["description"] = function_name
continue
else:
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_tools_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
# TODO: This needs to be a pydantic model
tool_dict = {
"toolkit_id": tool_id,
"callable": callable,
"spec": spec,
"pydantic_model": function_to_pydantic_model(callable),
"file_handler": hasattr(module, "file_handler") and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
}
extra_params["__id__"] = tool_id
# TODO: if collision, prepend toolkit name
if function_name in tools_dict:
log.warning(f"Tool {function_name} already exists in another tools!")
log.warning(f"Collision between {tools} and {tool_id}.")
log.warning(f"Discarding {tools}.{function_name}")
else:
tools_dict[function_name] = tool_dict
# Set valves for the tool
if hasattr(module, "valves") and hasattr(module, "Valves"):
valves = Tools.get_tool_valves_by_id(tool_id) or {}
module.valves = module.Valves(**valves)
if hasattr(module, "UserValves"):
extra_params["__user__"]["valves"] = module.UserValves( # type: ignore
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
for spec in tool.specs:
# 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":
val["type"] = "string"
# Remove internal reserved parameters (e.g. __id__, __user__)
spec["parameters"]["properties"] = {
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
# convert to function that takes only model params and inserts custom params
function_name = spec["name"]
tool_function = getattr(module, function_name)
callable = get_async_tool_function_and_apply_extra_params(
tool_function, extra_params
)
# TODO: Support Pydantic models as parameters
if callable.__doc__ and callable.__doc__.strip() != "":
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
else:
spec["description"] = function_name
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"metadata": {
"file_handler": hasattr(module, "file_handler")
and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
},
}
# TODO: if collision, prepend toolkit name
if function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
log.warning(f"Discarding {tool_id}.{function_name}")
else:
tools_dict[function_name] = tool_dict
return tools_dict
@@ -210,6 +281,273 @@ def get_callable_attributes(tool: object) -> list[Callable]:
def get_tools_specs(tool_class: object) -> list[dict]:
function_list = get_callable_attributes(tool_class)
models = map(function_to_pydantic_model, function_list)
return [convert_to_openai_function(tool) for tool in models]
function_model_list = map(
function_to_pydantic_model, get_callable_attributes(tool_class)
)
return [
convert_to_openai_function(function_model)
for function_model in function_model_list
]
def resolve_schema(schema, components):
"""
Recursively resolves a JSON schema using OpenAPI components.
"""
if not schema:
return {}
if "$ref" in schema:
ref_path = schema["$ref"]
ref_parts = ref_path.strip("#/").split("/")
resolved = components
for part in ref_parts[1:]: # Skip the initial 'components'
resolved = resolved.get(part, {})
return resolve_schema(resolved, components)
resolved_schema = copy.deepcopy(schema)
# Recursively resolve inner schemas
if "properties" in resolved_schema:
for prop, prop_schema in resolved_schema["properties"].items():
resolved_schema["properties"][prop] = resolve_schema(
prop_schema, components
)
if "items" in resolved_schema:
resolved_schema["items"] = resolve_schema(resolved_schema["items"], components)
return resolved_schema
def convert_openapi_to_tool_payload(openapi_spec):
"""
Converts an OpenAPI specification into a custom tool payload structure.
Args:
openapi_spec (dict): The OpenAPI specification as a Python dict.
Returns:
list: A list of tool payloads.
"""
tool_payload = []
for path, methods in openapi_spec.get("paths", {}).items():
for method, operation in methods.items():
tool = {
"type": "function",
"name": operation.get("operationId"),
"description": operation.get(
"description", operation.get("summary", "No description available.")
),
"parameters": {"type": "object", "properties": {}, "required": []},
}
# Extract path and query parameters
for param in operation.get("parameters", []):
param_name = param["name"]
param_schema = param.get("schema", {})
tool["parameters"]["properties"][param_name] = {
"type": param_schema.get("type"),
"description": param_schema.get("description", ""),
}
if param.get("required"):
tool["parameters"]["required"].append(param_name)
# Extract and resolve requestBody if available
request_body = operation.get("requestBody")
if request_body:
content = request_body.get("content", {})
json_schema = content.get("application/json", {}).get("schema")
if json_schema:
resolved_schema = resolve_schema(
json_schema, openapi_spec.get("components", {})
)
if resolved_schema.get("properties"):
tool["parameters"]["properties"].update(
resolved_schema["properties"]
)
if "required" in resolved_schema:
tool["parameters"]["required"] = list(
set(
tool["parameters"]["required"]
+ resolved_schema["required"]
)
)
elif resolved_schema.get("type") == "array":
tool["parameters"] = resolved_schema # special case for array
tool_payload.append(tool)
return tool_payload
async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
error = None
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status != 200:
error_body = await response.json()
raise Exception(error_body)
res = await response.json()
except Exception as err:
print("Error:", err)
if isinstance(err, dict) and "detail" in err:
error = err["detail"]
else:
error = str(err)
raise Exception(error)
data = {
"openapi": res,
"info": res.get("info", {}),
"specs": convert_openapi_to_tool_payload(res),
}
print("Fetched data:", data)
return data
async def get_tool_servers_data(
servers: List[Dict[str, Any]], session_token: Optional[str] = None
) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index
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}"
auth_type = server.get("auth_type", "bearer")
token = None
if auth_type == "bearer":
token = server.get("key", "")
elif auth_type == "session":
token = session_token
server_entries.append((idx, server, full_url, token))
# Create async tasks to fetch data
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):
if isinstance(response, Exception):
print(f"Failed to connect to {url} OpenAPI tool server")
continue
results.append(
{
"idx": idx,
"url": server.get("url"),
"openapi": response.get("openapi"),
"info": response.get("info"),
"specs": response.get("specs"),
}
)
return results
async def execute_tool_server(
token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any]
) -> Any:
error = None
try:
openapi = server_data.get("openapi", {})
paths = openapi.get("paths", {})
matching_route = None
for route_path, methods in paths.items():
for http_method, operation in methods.items():
if isinstance(operation, dict) and operation.get("operationId") == name:
matching_route = (route_path, methods)
break
if matching_route:
break
if not matching_route:
raise Exception(f"No matching route found for operationId: {name}")
route_path, methods = matching_route
method_entry = None
for http_method, operation in methods.items():
if operation.get("operationId") == name:
method_entry = (http_method.lower(), operation)
break
if not method_entry:
raise Exception(f"No matching method found for operationId: {name}")
http_method, operation = method_entry
path_params = {}
query_params = {}
body_params = {}
for param in operation.get("parameters", []):
param_name = param["name"]
param_in = param["in"]
if param_name in params:
if param_in == "path":
path_params[param_name] = params[param_name]
elif param_in == "query":
query_params[param_name] = params[param_name]
final_url = f"{url}{route_path}"
for key, value in path_params.items():
final_url = final_url.replace(f"{{{key}}}", str(value))
if query_params:
query_string = "&".join(f"{k}={v}" for k, v in query_params.items())
final_url = f"{final_url}?{query_string}"
if operation.get("requestBody", {}).get("content"):
if params:
body_params = params
else:
raise Exception(
f"Request body expected for operation '{name}' but none found."
)
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
async with aiohttp.ClientSession() as session:
request_method = getattr(session, http_method.lower())
if http_method in ["post", "put", "patch"]:
async with request_method(
final_url, json=body_params, headers=headers
) as response:
if response.status >= 400:
text = await response.text()
raise Exception(f"HTTP error {response.status}: {text}")
return await response.json()
else:
async with request_method(final_url, headers=headers) as response:
if response.status >= 400:
text = await response.text()
raise Exception(f"HTTP error {response.status}: {text}")
return await response.json()
except Exception as err:
error = str(err)
print("API Request Error:", error)
return {"error": error}
+22 -5
View File
@@ -1,7 +1,7 @@
fastapi==0.115.7
uvicorn[standard]==0.34.0
pydantic==2.10.6
python-multipart==0.0.18
python-multipart==0.0.20
python-socketio==5.11.3
python-jose==3.4.0
@@ -37,13 +37,13 @@ asgiref==3.8.1
# AI libraries
openai
anthropic
google-generativeai==0.7.2
google-generativeai==0.8.4
tiktoken
langchain==0.3.19
langchain-community==0.3.18
fake-useragent==1.5.1
fake-useragent==2.1.0
chromadb==0.6.2
pymilvus==2.5.0
qdrant-client~=1.12.0
@@ -54,8 +54,9 @@ elasticsearch==8.17.1
transformers
sentence-transformers==3.3.1
accelerate
colbert-ai==0.2.21
einops==0.8.0
einops==0.8.1
ftfy==6.2.3
@@ -67,7 +68,7 @@ python-pptx==1.0.0
unstructured==0.16.17
nltk==3.9.1
Markdown==3.7
pypandoc==1.13
pypandoc==1.15
pandas==2.2.3
openpyxl==3.1.5
pyxlsb==1.0.10
@@ -78,10 +79,13 @@ sentencepiece
soundfile==0.13.1
azure-ai-documentintelligence==1.0.0
pillow==11.1.0
opencv-python-headless==4.11.0.86
rapidocr-onnxruntime==1.3.24
rank-bm25==0.2.2
onnxruntime==1.20.1
faster-whisper==1.1.1
PyJWT[crypto]==2.10.1
@@ -118,3 +122,16 @@ ldap3==2.9.1
## Firecrawl
firecrawl-py==1.12.0
## Trace
opentelemetry-api==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-exporter-otlp==1.30.0
opentelemetry-instrumentation==0.51b0
opentelemetry-instrumentation-fastapi==0.51b0
opentelemetry-instrumentation-sqlalchemy==0.51b0
opentelemetry-instrumentation-redis==0.51b0
opentelemetry-instrumentation-requests==0.51b0
opentelemetry-instrumentation-logging==0.51b0
opentelemetry-instrumentation-httpx==0.51b0
opentelemetry-instrumentation-aiohttp-client==0.51b0
+2 -1
View File
@@ -41,4 +41,5 @@ IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
:: Execute uvicorn
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*'
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ws auto
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto
+371 -226
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.5.19",
"version": "0.6.2",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -80,6 +80,8 @@
"file-saver": "^2.0.5",
"fuse.js": "^7.0.0",
"highlight.js": "^11.9.0",
"html-entities": "^2.5.3",
"html2canvas-pro": "^1.5.8",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0",
@@ -102,7 +104,7 @@
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.2",
"pyodide": "^0.27.3",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2",
"svelte-sonner": "^0.3.19",
+8 -4
View File
@@ -45,13 +45,13 @@ dependencies = [
"openai",
"anthropic",
"google-generativeai==0.7.2",
"google-generativeai==0.8.4",
"tiktoken",
"langchain==0.3.19",
"langchain-community==0.3.18",
"fake-useragent==1.5.1",
"fake-useragent==2.1.0",
"chromadb==0.6.2",
"pymilvus==2.5.0",
"qdrant-client~=1.12.0",
@@ -61,8 +61,9 @@ dependencies = [
"transformers",
"sentence-transformers==3.3.1",
"accelerate",
"colbert-ai==0.2.21",
"einops==0.8.0",
"einops==0.8.1",
"ftfy==6.2.3",
"pypdf==4.3.1",
@@ -73,7 +74,7 @@ dependencies = [
"unstructured==0.16.17",
"nltk==3.9.1",
"Markdown==3.7",
"pypandoc==1.13",
"pypandoc==1.15",
"pandas==2.2.3",
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
@@ -84,10 +85,13 @@ dependencies = [
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.0",
"pillow==11.1.0",
"opencv-python-headless==4.11.0.86",
"rapidocr-onnxruntime==1.3.24",
"rank-bm25==0.2.2",
"onnxruntime==1.20.1",
"faster-whisper==1.1.1",
"PyJWT[crypto]==2.10.1",
+9 -1
View File
@@ -46,6 +46,14 @@ math {
@apply rounded-lg;
}
input::placeholder {
direction: auto;
}
textarea::placeholder {
direction: auto;
}
.input-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
@@ -106,7 +114,7 @@ li p {
}
::-webkit-scrollbar {
height: 0.4rem;
height: 0.8rem;
width: 0.4rem;
}
+87
View File
@@ -115,6 +115,93 @@ export const setDirectConnectionsConfig = async (token: string, config: object)
return res;
};
export const getToolServerConnections = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setToolServerConnections = async (token: string, connections: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...connections
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const verifyToolServerConnection = async (token: string, connection: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...connection
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getCodeExecutionConfig = async (token: string) => {
let error = null;
+186
View File
@@ -1,6 +1,9 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { convertOpenApiToToolPayload } from '$lib/utils';
import { getOpenAIModelsDirect } from './openai';
import { toast } from 'svelte-sonner';
export const getModels = async (
token: string = '',
connections: object | null = null,
@@ -114,6 +117,13 @@ export const getModels = async (
}
}
const tags = apiConfig.tags;
if (tags) {
for (const model of models) {
model.tags = tags;
}
}
localModels = localModels.concat(models);
}
}
@@ -249,6 +259,182 @@ export const stopTask = async (token: string, id: string) => {
return res;
};
export const getToolServerData = async (token: string, url: string) => {
let error = null;
const res = await fetch(`${url}`, {
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();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
const data = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
console.log(data);
return data;
};
export const getToolServersData = async (i18n, servers: object[]) => {
return (
await Promise.all(
servers
.filter((server) => server?.config?.enable)
.map(async (server) => {
const data = await getToolServerData(
server?.key,
server?.url + '/' + (server?.path ?? 'openapi.json')
).catch((err) => {
toast.error(
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: server?.url + '/' + (server?.path ?? 'openapi.json')
})
);
return null;
});
if (data) {
const { openapi, info, specs } = data;
return {
url: server?.url,
openapi: openapi,
info: info,
specs: specs
};
}
})
)
).filter((server) => server);
};
export const executeToolServer = async (
token: string,
url: string,
name: string,
params: Record<string, any>,
serverData: { openapi: any; info: any; specs: any }
) => {
let error = null;
try {
// Find the matching operationId in the OpenAPI spec
const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) =>
Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name)
);
if (!matchingRoute) {
throw new Error(`No matching route found for operationId: ${name}`);
}
const [routePath, methods] = matchingRoute;
const methodEntry = Object.entries(methods as any).find(
([_, operation]: any) => operation.operationId === name
);
if (!methodEntry) {
throw new Error(`No matching method found for operationId: ${name}`);
}
const [httpMethod, operation]: [string, any] = methodEntry;
// Split parameters by type
const pathParams: Record<string, any> = {};
const queryParams: Record<string, any> = {};
let bodyParams: any = {};
if (operation.parameters) {
operation.parameters.forEach((param: any) => {
const paramName = param.name;
const paramIn = param.in;
if (params.hasOwnProperty(paramName)) {
if (paramIn === 'path') {
pathParams[paramName] = params[paramName];
} else if (paramIn === 'query') {
queryParams[paramName] = params[paramName];
}
}
});
}
let finalUrl = `${url}${routePath}`;
// Replace path parameters (`{param}`)
Object.entries(pathParams).forEach(([key, value]) => {
finalUrl = finalUrl.replace(new RegExp(`{${key}}`, 'g'), encodeURIComponent(value));
});
// Append query parameters to URL if any
if (Object.keys(queryParams).length > 0) {
const queryString = new URLSearchParams(
Object.entries(queryParams).map(([k, v]) => [k, String(v)])
).toString();
finalUrl += `?${queryString}`;
}
// Handle requestBody composite
if (operation.requestBody && operation.requestBody.content) {
const contentType = Object.keys(operation.requestBody.content)[0];
if (params !== undefined) {
bodyParams = params;
} else {
// Optional: Fallback or explicit error if body is expected but not provided
throw new Error(`Request body expected for operation '${name}' but none found.`);
}
}
// Prepare headers and request options
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
};
let requestOptions: RequestInit = {
method: httpMethod.toUpperCase(),
headers
};
if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) {
requestOptions.body = JSON.stringify(bodyParams);
}
const res = await fetch(finalUrl, requestOptions);
if (!res.ok) {
const resText = await res.text();
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
}
return await res.json();
} catch (err: any) {
error = err.message;
console.error('API Request Error:', error);
return { error };
}
};
export const getTaskConfig = async (token: string = '') => {
let error = null;
+39 -8
View File
@@ -14,6 +14,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -31,6 +32,7 @@
let prefixId = '';
let enable = true;
let tags = [];
let modelId = '';
let modelIds = [];
@@ -77,17 +79,21 @@
const submitHandler = async () => {
loading = true;
if (!ollama && (!url || !key)) {
if (!ollama && !url) {
loading = false;
toast.error('URL and Key are required');
toast.error('URL is required');
return;
}
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = {
url,
key,
config: {
enable: enable,
tags: tags,
prefix_id: prefixId,
model_ids: modelIds
}
@@ -101,6 +107,7 @@
url = '';
key = '';
prefixId = '';
tags = [];
modelIds = [];
};
@@ -110,6 +117,7 @@
key = connection.key;
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
modelIds = connection.config?.model_ids ?? [];
}
@@ -179,7 +187,7 @@
</div>
</div>
<Tooltip content="Verify Connection" className="self-end -mb-1">
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
@@ -218,7 +226,7 @@
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={!ollama}
required={false}
/>
</div>
</div>
@@ -244,6 +252,29 @@
</div>
</div>
<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>
<div class="flex-1">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full">
@@ -274,12 +305,12 @@
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{URL}}/api/tags" endpoint', {
URL: url
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
url: url
})}
{:else}
{$i18n.t('Leave empty to include all models from "{{URL}}/models" endpoint', {
URL: url
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
</div>
+347
View File
@@ -0,0 +1,347 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
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';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let direct = false;
export let connection = null;
let url = '';
let path = 'openapi.json';
let auth_type = 'bearer';
let key = '';
let accessControl = null;
let enable = true;
let loading = false;
const verifyHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
if (path === '') {
toast.error($i18n.t('Please enter a valid path'));
return;
}
if (direct) {
const res = await getToolServerData(
auth_type === 'bearer' ? key : localStorage.token,
`${url}/${path}`
).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
if (res) {
toast.success($i18n.t('Connection successful'));
console.debug('Connection successful', res);
}
} else {
const res = await verifyToolServerConnection(localStorage.token, {
url,
path,
auth_type,
key,
config: {
enable: enable,
access_control: accessControl
}
}).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
if (res) {
toast.success($i18n.t('Connection successful'));
console.debug('Connection successful', res);
}
}
};
const submitHandler = async () => {
loading = true;
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = {
url,
path,
auth_type,
key,
config: {
enable: enable,
access_control: accessControl
}
};
await onSubmit(connection);
loading = false;
show = false;
url = '';
path = 'openapi.json';
key = '';
auth_type = 'bearer';
enable = true;
accessControl = null;
};
const init = () => {
if (connection) {
url = connection.url;
path = connection?.path ?? 'openapi.json';
auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? '';
enable = connection.config?.enable ?? true;
accessControl = connection.config?.access_control ?? null;
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</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-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div class=" text-xs text-gray-500">{$i18n.t('URL')}</div>
</div>
<div class="flex flex-1 items-center">
<input
class="w-full flex-1 text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
required
/>
<Tooltip
content={$i18n.t('Verify Connection')}
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
</div>
<div class="flex-1 flex items-center">
<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')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="text-xs text-gray-500 mt-1">
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: `${url}/${path}`
})}
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" text-xs text-gray-500">{$i18n.t('Auth')}</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class="w-full text-sm bg-transparent dark:bg-gray-900 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden pr-5"
bind:value={auth_type}
>
<option value="bearer">Bearer</option>
<option value="session">Session</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'session'}
<div class="text-xs text-gray-500 self-center translate-y-[1px]">
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl />
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<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>
@@ -115,7 +115,7 @@
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{feedbacks.length}</span>
</div>
<div>
{#if feedbacks.length > 0}
<div>
<Tooltip content={$i18n.t('Export')}>
<button
@@ -128,7 +128,7 @@
</button>
</Tooltip>
</div>
</div>
{/if}
</div>
<div
+33 -31
View File
@@ -430,39 +430,41 @@
</div>
</button>
<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"
on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
{#if $functions.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"
on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Functions')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<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 (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Functions')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<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>
+40 -11
View File
@@ -20,6 +20,7 @@
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
import Evaluations from './Settings/Evaluations.svelte';
import CodeExecution from './Settings/CodeExecution.svelte';
import Tools from './Settings/Tools.svelte';
const i18n = getContext('i18n');
@@ -71,7 +72,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'connections'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -95,7 +96,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'models'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -121,7 +122,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'evaluations'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -136,7 +137,33 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'tools'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'tools';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Tools')}</div>
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'documents'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -166,7 +193,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'web'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -190,7 +217,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'code-execution'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -216,7 +243,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'interface'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -242,7 +269,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'audio'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -269,7 +296,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'images'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -295,7 +322,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'pipelines'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -325,7 +352,7 @@
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
'db'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@@ -373,6 +400,8 @@
<Models />
{:else if selectedTab === 'evaluations'}
<Evaluations />
{:else if selectedTab === 'tools'}
<Tools />
{:else if selectedTab === 'documents'}
<Documents
on:save={async () => {
@@ -45,6 +45,16 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Code Execution')}
</div>
<Switch bind:state={config.ENABLE_CODE_EXECUTION} />
</div>
</div>
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
@@ -136,7 +136,7 @@
};
onMount(async () => {
if ($user.role === 'admin') {
if ($user?.role === 'admin') {
let ollamaConfig = {};
let openaiConfig = {};
@@ -5,6 +5,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
@@ -20,6 +21,7 @@
let showManageModal = false;
let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script>
<AddConnectionModal
@@ -31,7 +33,9 @@
key: config?.key ?? '',
config: config
}}
{onDelete}
onDelete={() => {
showDeleteConfirmDialog = true;
}}
onSubmit={(connection) => {
url = connection.url;
config = { ...connection.config, key: connection.key };
@@ -39,6 +43,14 @@
}}
/>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
showConfigModal = false;
}}
/>
<ManageOllamaModal bind:show={showManageModal} urlIdx={idx} />
<div class="flex gap-1.5">
@@ -6,6 +6,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { connect } from 'socket.io-client';
@@ -19,8 +20,16 @@
export let config = {};
let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
}}
/>
<AddConnectionModal
edit
bind:show={showConfigModal}
@@ -29,7 +38,9 @@
key,
config
}}
{onDelete}
onDelete={() => {
showDeleteConfirmDialog = true;
}}
onSubmit={(connection) => {
url = connection.url;
key = connection.key;
+193 -128
View File
@@ -49,9 +49,13 @@
let contentExtractionEngine = 'default';
let tikaServerUrl = '';
let showTikaServerUrl = false;
let doclingServerUrl = '';
let showDoclingServerUrl = false;
let documentIntelligenceEndpoint = '';
let documentIntelligenceKey = '';
let showDocumentIntelligenceConfig = false;
let mistralApiKey = '';
let showMistralOcrConfig = false;
let textSplitter = '';
let chunkSize = 0;
@@ -74,6 +78,7 @@
template: '',
r: 0.0,
k: 4,
k_reranker: 4,
hybrid: false
};
@@ -175,6 +180,10 @@
toast.error($i18n.t('Tika Server URL required.'));
return;
}
if (contentExtractionEngine === 'docling' && doclingServerUrl === '') {
toast.error($i18n.t('Docling Server URL required.'));
return;
}
if (
contentExtractionEngine === 'document_intelligence' &&
(documentIntelligenceEndpoint === '' || documentIntelligenceKey === '')
@@ -182,6 +191,10 @@
toast.error($i18n.t('Document Intelligence endpoint and key required.'));
return;
}
if (contentExtractionEngine === 'mistral_ocr' && mistralApiKey === '') {
toast.error($i18n.t('Mistral OCR API Key required.'));
return;
}
if (!BYPASS_EMBEDDING_AND_RETRIEVAL) {
await embeddingModelUpdateHandler();
@@ -209,9 +222,13 @@
content_extraction: {
engine: contentExtractionEngine,
tika_server_url: tikaServerUrl,
docling_server_url: doclingServerUrl,
document_intelligence_config: {
key: documentIntelligenceKey,
endpoint: documentIntelligenceEndpoint
},
mistral_ocr_config: {
api_key: mistralApiKey
}
}
});
@@ -269,10 +286,15 @@
contentExtractionEngine = res.content_extraction.engine;
tikaServerUrl = res.content_extraction.tika_server_url;
doclingServerUrl = res.content_extraction.docling_server_url;
showTikaServerUrl = contentExtractionEngine === 'tika';
showDoclingServerUrl = contentExtractionEngine === 'docling';
documentIntelligenceEndpoint = res.content_extraction.document_intelligence_config.endpoint;
documentIntelligenceKey = res.content_extraction.document_intelligence_config.key;
showDocumentIntelligenceConfig = contentExtractionEngine === 'document_intelligence';
mistralApiKey = res.content_extraction.mistral_ocr_config.api_key;
showMistralOcrConfig = contentExtractionEngine === 'mistral_ocr';
fileMaxSize = res?.file.max_size ?? '';
fileMaxCount = res?.file.max_count ?? '';
@@ -324,20 +346,21 @@
<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="mb-2.5 flex flex-col w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class="self-center text-xs font-medium">
{$i18n.t('Content Extraction Engine')}
</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={contentExtractionEngine}
>
<option value="">{$i18n.t('Default')} </option>
<option value="">{$i18n.t('Default')}</option>
<option value="tika">{$i18n.t('Tika')}</option>
<option value="docling">{$i18n.t('Docling')}</option>
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
<option value="mistral_ocr">{$i18n.t('Mistral OCR')}</option>
</select>
</div>
</div>
@@ -351,6 +374,14 @@
/>
</div>
</div>
{:else if contentExtractionEngine === 'docling'}
<div class="flex w-full mt-1">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling Server URL')}
bind:value={doclingServerUrl}
/>
</div>
{:else if contentExtractionEngine === 'document_intelligence'}
<div class="my-0.5 flex gap-2 pr-2">
<input
@@ -358,12 +389,18 @@
placeholder={$i18n.t('Enter Document Intelligence Endpoint')}
bind:value={documentIntelligenceEndpoint}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Document Intelligence Key')}
bind:value={documentIntelligenceKey}
/>
</div>
{:else if contentExtractionEngine === 'mistral_ocr'}
<div class="my-0.5 flex gap-2 pr-2">
<SensitiveInput
placeholder={$i18n.t('Enter Mistral API Key')}
bind:value={mistralApiKey}
/>
</div>
{/if}
</div>
@@ -387,8 +424,12 @@
<div class="flex items-center relative">
<Tooltip
content={BYPASS_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
? $i18n.t(
'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
)
: $i18n.t(
'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'
)}
>
<Switch bind:state={BYPASS_EMBEDDING_AND_RETRIEVAL} />
</Tooltip>
@@ -619,104 +660,6 @@
</div>
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Full Context Mode')}</div>
<div class="flex items-center relative">
<Tooltip
content={RAG_FULL_CONTEXT
? 'Inject entire contents as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
>
<Switch bind:state={RAG_FULL_CONTEXT} />
</Tooltip>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Hybrid Search')}</div>
<div class="flex items-center relative">
<Switch
bind:state={querySettings.hybrid}
on:change={() => {
toggleHybridSearch();
}}
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class=" mb-2.5 flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Reranking Model')}</div>
<div class="">
<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"
placeholder={$i18n.t('Set reranking model (e.g. {{model}})', {
model: 'BAAI/bge-reranker-v2-m3'
})}
bind:value={rerankingModel}
/>
</div>
<button
class="px-2.5 bg-transparent text-gray-800 dark:bg-transparent dark:text-gray-100 rounded-lg transition"
on:click={() => {
rerankingModelUpdateHandler();
}}
disabled={updateRerankingModelLoading}
>
{#if updateRerankingModelLoading}
<div class="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>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
</div>
</div>
{/if}
</div>
<div class="mb-3">
@@ -725,42 +668,164 @@
<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('Top K')}</div>
<div class=" self-center text-xs font-medium">{$i18n.t('Full Context Mode')}</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number"
placeholder={$i18n.t('Enter Top K')}
bind:value={querySettings.k}
autocomplete="off"
min="0"
/>
<Tooltip
content={RAG_FULL_CONTEXT
? $i18n.t(
'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
)
: $i18n.t(
'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'
)}
>
<Switch bind:state={RAG_FULL_CONTEXT} />
</Tooltip>
</div>
</div>
{#if querySettings.hybrid === true}
<div class=" mb-2.5 flex flex-col w-full justify-between">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Minimum Score')}</div>
{#if !RAG_FULL_CONTEXT}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Hybrid Search')}</div>
<div class="flex items-center relative">
<Switch
bind:state={querySettings.hybrid}
on:change={() => {
toggleHybridSearch();
}}
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class=" mb-2.5 flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Reranking Model')}</div>
<div class="">
<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"
placeholder={$i18n.t('Set reranking model (e.g. {{model}})', {
model: 'BAAI/bge-reranker-v2-m3'
})}
bind:value={rerankingModel}
/>
</div>
<button
class="px-2.5 bg-transparent text-gray-800 dark:bg-transparent dark:text-gray-100 rounded-lg transition"
on:click={() => {
rerankingModelUpdateHandler();
}}
disabled={updateRerankingModelLoading}
>
{#if updateRerankingModelLoading}
<div class="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>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<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"
type="number"
placeholder={$i18n.t('Enter Top K')}
bind:value={querySettings.k}
autocomplete="off"
min="0"
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class="mb-2.5 flex w-full justify-between">
<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"
type="number"
step="0.01"
placeholder={$i18n.t('Enter Score')}
bind:value={querySettings.r}
placeholder={$i18n.t('Enter Top K Reranker')}
bind:value={querySettings.k_reranker}
autocomplete="off"
min="0.0"
title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
min="0"
/>
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
)}
{/if}
{#if querySettings.hybrid === true}
<div class=" mb-2.5 flex flex-col w-full justify-between">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Minimum Score')}</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number"
step="0.01"
placeholder={$i18n.t('Enter Score')}
bind:value={querySettings.r}
autocomplete="off"
min="0.0"
title={$i18n.t(
'The score should be a value between 0.0 (0%) and 1.0 (100%).'
)}
/>
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
)}
</div>
</div>
</div>
{/if}
{/if}
<div class=" mb-2.5 flex flex-col w-full justify-between">
@@ -77,7 +77,7 @@
};
onMount(async () => {
if ($user.role === 'admin') {
if ($user?.role === 'admin') {
evaluationConfig = await getConfig(localStorage.token).catch((err) => {
toast.error(err);
return null;
@@ -10,6 +10,7 @@
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import { toast } from 'svelte-sonner';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let show = false;
export let edit = false;
@@ -44,6 +45,7 @@
let imageInputElement;
let loading = false;
let showDeleteConfirmDialog = false;
const addModelHandler = () => {
if (selectedModelId) {
@@ -115,6 +117,14 @@
});
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
dispatch('delete', model);
show = false;
}}
/>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
@@ -378,8 +388,7 @@
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
dispatch('delete', model);
show = false;
showDeleteConfirmDialog = true;
}}
>
{$i18n.t('Delete')}
@@ -554,7 +554,6 @@
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path}
/>
@@ -610,6 +609,14 @@
<Switch bind:state={adminConfig.ENABLE_CHANNELS} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('User Webhooks')}
</div>
<Switch bind:state={adminConfig.ENABLE_USER_WEBHOOKS} />
</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>
@@ -176,7 +176,7 @@
};
onMount(async () => {
if ($user.role === 'admin') {
if ($user?.role === 'admin') {
const res = await getConfig(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
@@ -191,11 +191,15 @@
}
if (config.comfyui.COMFYUI_WORKFLOW) {
config.comfyui.COMFYUI_WORKFLOW = JSON.stringify(
JSON.parse(config.comfyui.COMFYUI_WORKFLOW),
null,
2
);
try {
config.comfyui.COMFYUI_WORKFLOW = JSON.stringify(
JSON.parse(config.comfyui.COMFYUI_WORKFLOW),
null,
2
);
} catch (e) {
console.log(e);
}
}
requiredWorkflowNodes = requiredWorkflowNodes.map((node) => {
@@ -380,7 +380,7 @@
</div>
</div>
{#if $user.role === 'admin'}
{#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">
+135 -35
View File
@@ -29,6 +29,12 @@
import Wrench from '$lib/components/icons/Wrench.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
let shiftKey = false;
let importFiles;
let modelsImportInputElement: HTMLInputElement;
@@ -146,8 +152,62 @@
);
};
const hideModelHandler = async (model) => {
model.meta = {
...model.meta,
hidden: !(model?.meta?.hidden ?? false)
};
console.log(model);
toast.success(
model.meta.hidden
? $i18n.t(`Model {{name}} is now hidden`, {
name: model.id
})
: $i18n.t(`Model {{name}} is now visible`, {
name: model.id
})
);
upsertModelHandler(model);
};
const exportModelHandler = async (model) => {
let blob = new Blob([JSON.stringify([model])], {
type: 'application/json'
});
saveAs(blob, `${model.id}-${Date.now()}.json`);
};
onMount(async () => {
init();
await init();
const onKeyDown = (event) => {
if (event.key === 'Shift') {
shiftKey = true;
}
};
const onKeyUp = (event) => {
if (event.key === 'Shift') {
shiftKey = false;
}
};
const onBlur = () => {
shiftKey = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur-sm', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur-sm', onBlur);
};
});
</script>
@@ -211,7 +271,10 @@
{#if models.length > 0}
{#each filteredModels as model, modelIdx (model.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-lg transition"
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-lg transition {model
?.meta?.hidden
? 'opacity-50 dark:opacity-50'
: ''}"
id="model-item-{model.id}"
>
<button
@@ -261,41 +324,78 @@
</div>
</button>
<div class="flex flex-row gap-0.5 items-center self-center">
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedModelId = model.id;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
{#if shiftKey}
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
hideModelHandler(model);
}}
/>
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
</div>
{:else}
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedModelId = model.id;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<ModelMenu
user={$user}
{model}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</ModelMenu>
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
}}
/>
</Tooltip>
</div>
{/if}
</div>
</div>
{/each}
@@ -19,7 +19,7 @@
let ollamaConfig = null;
onMount(async () => {
if ($user.role === 'admin') {
if ($user?.role === 'admin') {
await Promise.all([
(async () => {
ollamaConfig = await getOllamaConfig(localStorage.token);
@@ -33,6 +33,7 @@
if (modelListElement) {
sortable = Sortable.create(modelListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: async (event) => {
positionChangeHandler();
}
@@ -47,7 +48,7 @@
<div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}">
<Tooltip content={modelId} placement="top-start">
<div class="flex items-center gap-1">
<EllipsisVertical className="size-4 cursor-move" />
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class=" text-sm flex-1 py-1 rounded-lg">
{#if $models.find((model) => model.id === modelId)}
@@ -0,0 +1,116 @@
<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 Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
import { config } from '$lib/stores';
const i18n = getContext('i18n');
export let user;
export let model;
export let exportHandler: Function;
export let hideHandler: Function;
export let onClose: Function;
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[160px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
>
{#if model?.meta?.hidden ?? false}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{/if}
<div class="flex items-center">
{#if model?.meta?.hidden ?? false}
{$i18n.t('Show Model')}
{:else}
{$i18n.t('Hide Model')}
{/if}
</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
>
<ArrowDownTray />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>
@@ -0,0 +1,134 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { getModels as _getModels } from '$lib/apis';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { models, settings, user } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
export let saveSettings: Function;
let servers = null;
let showConnectionModal = false;
const addConnectionHandler = async (server) => {
servers = [...servers, server];
await updateHandler();
};
const updateHandler = async () => {
const res = await setToolServerConnections(localStorage.token, {
TOOL_SERVER_CONNECTIONS: servers
}).catch((err) => {
toast.error($i18n.t('Failed to save connections'));
return null;
});
if (res) {
toast.success($i18n.t('Connections saved successfully'));
}
};
onMount(async () => {
const res = await getToolServerConnections(localStorage.token);
servers = res.TOOL_SERVER_CONNECTIONS;
});
</script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
updateHandler();
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full">
{#if servers !== null}
<div class="">
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5 flex flex-col w-full justify-between">
<!-- {$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: 'server?.url'
})} -->
<div class="flex justify-between items-center mb-0.5">
<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
{#each servers as server, idx}
<Connection
bind:connection={server}
onSubmit={() => {
updateHandler();
}}
onDelete={() => {
servers = servers.filter((_, i) => i !== idx);
updateHandler();
}}
/>
{/each}
</div>
<div class="my-1.5">
<div class="text-xs text-gray-500">
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
</div>
</div>
</div>
<!-- <div class="mb-2.5 flex w-full justify-between">
<div class=" text-xs font-medium">{$i18n.t('Arena Models')}</div>
<Tooltip content={$i18n.t(`Message rating should be enabled to use this feature`)}>
<Switch bind:state={evaluationConfig.ENABLE_EVALUATION_ARENA_MODELS} />
</Tooltip>
</div> -->
</div>
</div>
{:else}
<div class="flex h-full justify-center">
<div class="my-auto">
<Spinner className="size-6" />
</div>
</div>
{/if}
</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"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
@@ -462,8 +462,12 @@
<div class="flex items-center relative">
<Tooltip
content={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
? $i18n.t(
'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
)
: $i18n.t(
'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'
)}
>
<Switch bind:state={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL} />
</Tooltip>
+9 -1
View File
@@ -52,14 +52,22 @@
prompts: false,
tools: false
},
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: {
controls: true,
file_upload: true,
delete: true,
edit: true,
temporary: true
temporary: true,
temporary_enforced: false
},
features: {
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true
@@ -9,6 +9,7 @@
import Users from './Users.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -25,6 +26,7 @@
let selectedTab = 'general';
let loading = false;
let showDeleteConfirmDialog = false;
export let name = '';
export let description = '';
@@ -36,6 +38,12 @@
prompts: false,
tools: false
},
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: {
controls: true,
file_upload: true,
@@ -44,6 +52,7 @@
temporary: true
},
features: {
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true
@@ -88,6 +97,14 @@
});
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
show = false;
}}
/>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
@@ -263,18 +280,19 @@
{/if}
</div> -->
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
showDeleteConfirmDialog = true;
}}
>
{$i18n.t('Delete')}
</button>
{:else}
<div></div>
{/if}
<button
@@ -13,14 +13,22 @@
prompts: false,
tools: false
},
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: {
controls: true,
delete: true,
edit: true,
file_upload: true,
temporary: true,
file_upload: true
temporary_enforced: false
},
features: {
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true
@@ -39,6 +47,7 @@
...defaults,
...obj,
workspace: { ...defaults.workspace, ...obj.workspace },
sharing: { ...defaults.sharing, ...obj.sharing },
chat: { ...defaults.chat, ...obj.chat },
features: { ...defaults.features, ...obj.features }
};
@@ -194,6 +203,40 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Sharing Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_models} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_tools} />
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
@@ -236,6 +279,16 @@
<Switch bind:state={permissions.chat.temporary} />
</div>
{#if permissions.chat.temporary}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enforce Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
@@ -243,6 +296,14 @@
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Direct Tool Servers')}
</div>
<Switch bind:state={permissions.features.direct_tool_servers} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')}
@@ -12,6 +12,7 @@
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n');
@@ -19,6 +20,8 @@
export let user;
let chats = null;
let showDeleteConfirmDialog = false;
let chatToDelete = null;
const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
@@ -50,6 +53,16 @@
}
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
if (chatToDelete) {
deleteChatHandler(chatToDelete);
chatToDelete = null;
}
}}
/>
<Modal size="lg" bind:show>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4">
<div class=" text-lg font-medium self-center capitalize">
@@ -142,7 +155,8 @@
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
deleteChatHandler(chat.id);
chatToDelete = chat.id;
showDeleteConfirmDialog = true;
}}
>
<svg
+1 -1
View File
@@ -106,7 +106,7 @@
messages[idx] = data;
}
} else if (type === 'typing' && event.message_id === null) {
if (event.user.id === $user.id) {
if (event.user.id === $user?.id) {
return;
}
@@ -381,7 +381,7 @@
>
<div
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'LTR'}
dir={$settings?.chatDirection ?? 'auto'}
>
{#if files.length > 0}
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
+11 -8
View File
@@ -73,10 +73,13 @@
<div class="text-2xl font-medium capitalize">{channel.name}</div>
<div class=" text-gray-500">
This channel was created on {dayjs(channel.created_at / 1000000).format(
'MMMM D, YYYY'
)}. This is the very beginning of the {channel.name}
channel.
{$i18n.t(
'This channel was created on {{createdAt}}. This is the very beginning of the {{channelName}} channel.',
{
createdAt: dayjs(channel.created_at / 1000000).format('MMMM D, YYYY'),
channelName: channel.name
}
)}
</div>
</div>
{:else}
@@ -129,7 +132,7 @@
if (
(message?.reactions ?? [])
.find((reaction) => reaction.name === name)
?.user_ids?.includes($user.id) ??
?.user_ids?.includes($user?.id) ??
false
) {
messages = messages.map((m) => {
@@ -137,7 +140,7 @@
const reaction = m.reactions.find((reaction) => reaction.name === name);
if (reaction) {
reaction.user_ids = reaction.user_ids.filter((id) => id !== $user.id);
reaction.user_ids = reaction.user_ids.filter((id) => id !== $user?.id);
reaction.count = reaction.user_ids.length;
if (reaction.count === 0) {
@@ -164,12 +167,12 @@
const reaction = m.reactions.find((reaction) => reaction.name === name);
if (reaction) {
reaction.user_ids.push($user.id);
reaction.user_ids.push($user?.id);
reaction.count = reaction.user_ids.length;
} else {
m.reactions.push({
name: name,
user_ids: [$user.id],
user_ids: [$user?.id],
count: 1
});
}
@@ -106,7 +106,7 @@
</Tooltip>
{/if}
{#if message.user_id === $user.id || $user.role === 'admin'}
{#if message.user_id === $user?.id || $user?.role === 'admin'}
<Tooltip content={$i18n.t('Edit')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@@ -265,7 +265,7 @@
<Tooltip content={`:${reaction.name}:`}>
<button
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
$user.id
$user?.id
)
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
+2 -2
View File
@@ -58,7 +58,7 @@
{#if $user !== undefined}
<UserMenu
className="max-w-[200px]"
role={$user.role}
role={$user?.role}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
@@ -71,7 +71,7 @@
>
<div class=" self-center">
<img
src={$user.profile_image_url}
src={$user?.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
+1 -1
View File
@@ -89,7 +89,7 @@
}
}
} else if (type === 'typing' && event.message_id === threadId) {
if (event.user.id === $user.id) {
if (event.user.id === $user?.id) {
return;
}
+59 -32
View File
@@ -35,7 +35,8 @@
showOverview,
chatTitle,
showArtifacts,
tools
tools,
toolServers
} from '$lib/stores';
import {
convertMessagesToHistory,
@@ -47,7 +48,8 @@
splitStream,
sleep,
removeDetails,
getPromptVariables
getPromptVariables,
processDetails
} from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama';
@@ -119,6 +121,7 @@
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
let chat = null;
let tags = [];
@@ -212,7 +215,14 @@
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
let messageChildrenIds = history.messages[_messageId].childrenIds;
let messageChildrenIds = [];
if (_messageId === null) {
messageChildrenIds = Object.keys(history.messages).filter(
(id) => history.messages[id].parentId === null
);
} else {
messageChildrenIds = history.messages[_messageId].childrenIds;
}
while (messageChildrenIds.length !== 0) {
_messageId = messageChildrenIds.at(-1);
@@ -286,18 +296,10 @@
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
} else if (type === 'message') {
} else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content;
} else if (type === 'replace') {
} else if (type === 'chat:message' || type === 'replace') {
message.content = data.content;
} else if (type === 'action') {
if (data.action === 'continue') {
const continueButton = document.getElementById('continue-response-button');
if (continueButton) {
continueButton.click();
}
}
} else if (type === 'confirmation') {
eventCallback = cb;
@@ -384,7 +386,7 @@
if (event.data.type === 'input:prompt:submit') {
console.debug(event.data.text);
if (prompt !== '') {
if (event.data.text !== '') {
await tick();
submitPrompt(event.data.text);
}
@@ -887,6 +889,8 @@
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
}
taskId = null;
};
const chatActionHandler = async (chatId, actionId, modelId, responseMessageId, event = null) => {
@@ -1276,12 +1280,13 @@
prompt = '';
// Reset chat input textarea
const chatInputElement = document.getElementById('chat-input');
if (!($settings?.richTextInput ?? true)) {
const chatInputElement = document.getElementById('chat-input');
if (chatInputElement) {
await tick();
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 320) + 'px';
if (chatInputElement) {
await tick();
chatInputElement.style.height = '';
}
}
const _files = JSON.parse(JSON.stringify(files));
@@ -1332,6 +1337,10 @@
parentId: string,
{ modelId = null, modelIdx = null, newChat = false } = {}
) => {
if (autoScroll) {
scrollToBottom();
}
let _chatId = JSON.parse(JSON.stringify($chatId));
_history = JSON.parse(JSON.stringify(_history));
@@ -1494,7 +1503,7 @@
role: 'system',
content: `${promptTemplate(
params?.system ?? $settings?.system ?? '',
$user.name,
$user?.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
@@ -1510,7 +1519,7 @@
: undefined,
...createMessagesList(_history, responseMessageId).map((message) => ({
...message,
content: removeDetails(message.content, ['reasoning', 'code_interpreter'])
content: processDetails(message.content)
}))
].filter((message) => message);
@@ -1563,27 +1572,28 @@
files: (files?.length ?? 0) > 0 ? files : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
tool_servers: $toolServers,
features: {
image_generation:
$config?.features?.enable_image_generation &&
($user.role === 'admin' || $user?.permissions?.features?.image_generation)
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user.role === 'admin' || $user?.permissions?.features?.code_interpreter)
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search:
$config?.features?.enable_web_search &&
($user.role === 'admin' || $user?.permissions?.features?.web_search)
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled || ($settings?.webSearch ?? false) === 'always'
: false
},
variables: {
...getPromptVariables(
$user.name,
$user?.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
@@ -1621,7 +1631,7 @@
: {})
},
`${WEBUI_BASE_URL}/api`
).catch((error) => {
).catch(async (error) => {
toast.error(`${error}`);
responseMessage.error = {
@@ -1634,10 +1644,12 @@
return null;
});
console.log(res);
if (res) {
taskId = res.task_id;
if (res.error) {
await handleOpenAIError(res.error, responseMessage);
} else {
taskId = res.task_id;
}
}
await tick();
@@ -1654,9 +1666,11 @@
console.error(innerError);
if ('detail' in innerError) {
// FastAPI error
toast.error(innerError.detail);
errorMessage = innerError.detail;
} else if ('error' in innerError) {
// OpenAI error
if ('message' in innerError.error) {
toast.error(innerError.error.message);
errorMessage = innerError.error.message;
@@ -1665,6 +1679,7 @@
errorMessage = innerError.error;
}
} else if ('message' in innerError) {
// OpenAI error
toast.error(innerError.message);
errorMessage = innerError.message;
}
@@ -1683,9 +1698,10 @@
history.messages[responseMessage.id] = responseMessage;
};
const stopResponse = () => {
const stopResponse = async () => {
if (taskId) {
const res = stopTask(localStorage.token, taskId).catch((error) => {
const res = await stopTask(localStorage.token, taskId).catch((error) => {
toast.error(`${error}`);
return null;
});
@@ -1728,6 +1744,11 @@
history.currentId = userMessageId;
await tick();
if (autoScroll) {
scrollToBottom();
}
await sendPrompt(history, userPrompt, userMessageId);
};
@@ -1738,6 +1759,10 @@
let userMessage = history.messages[message.parentId];
let userPrompt = userMessage.content;
if (autoScroll) {
scrollToBottom();
}
if ((userMessage?.models ?? [...selectedModels]).length == 1) {
// If user message has only one model selected, sendPrompt automatically selects it for regeneration
await sendPrompt(history, userPrompt, userMessage.id);
@@ -1937,7 +1962,7 @@
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} class="h-full flex w-full relative">
{#if $banners.length > 0 && !history.currentId && !$chatId && selectedModels.length <= 1}
{#if !history.currentId && !$chatId && selectedModels.length <= 1 && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))}
<div class="absolute top-12 left-0 right-0 w-full z-30">
<div class=" flex flex-col gap-1 w-full">
{#if ($config?.license_metadata?.type ?? null) === 'trial'}
@@ -2031,6 +2056,7 @@
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
toolServers={$toolServers}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
{createMessagePair}
@@ -2084,6 +2110,7 @@
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
on:upload={async (e) => {
@@ -68,12 +68,12 @@
{#if $temporaryChatEnabled}
<Tooltip
content="This chat won't appear in history and your messages will not be saved."
className="w-fit"
placement="top-start"
content={$i18n.t('This chat won’t appear in history and your messages will not be saved.')}
className="w-full flex justify-center mb-0.5"
placement="top"
>
<div class="flex items-center gap-2 text-gray-500 font-medium text-lg my-2 w-fit">
<EyeSlash strokeWidth="2.5" className="size-5" /> Temporary Chat
<EyeSlash strokeWidth="2.5" className="size-5" />{$i18n.t('Temporary Chat')}
</div>
</Tooltip>
{/if}
@@ -86,7 +86,7 @@
{#if models[selectedModelIdx]?.name}
{models[selectedModelIdx]?.name}
{:else}
{$i18n.t('Hello, {{name}}', { name: $user.name })}
{$i18n.t('Hello, {{name}}', { name: $user?.name })}
{/if}
</div>
@@ -263,7 +263,7 @@
</div>
{:else}
<div
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-850 w-72 rounded-full shadow-xl"
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border border-gray-100 dark:border-gray-850 w-72 rounded-full shadow-xl"
>
<input
type="text"

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