Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04799f1f95 | ||
|
|
1b7c125f00 | ||
|
|
13f7a4cf56 | ||
|
|
8f8d8ba27d | ||
|
|
dca68871e6 | ||
|
|
da561c50d0 | ||
|
|
3662ecdeab | ||
|
|
391dd33da3 | ||
|
|
0bc5441d72 | ||
|
|
5f792d2771 | ||
|
|
580965df17 | ||
|
|
50b3f47f81 | ||
|
|
3ba12e7a43 | ||
|
|
def5444ea3 | ||
|
|
cafc5413f5 | ||
|
|
3dc40030a1 | ||
|
|
efb63fb13f | ||
|
|
598222b76f | ||
|
|
fac848ad33 | ||
|
|
098c794de3 | ||
|
|
1571e3dc16 | ||
|
|
a65c426e1d | ||
|
|
9c64310db5 | ||
|
|
3c507e829f | ||
|
|
bc64b2a67f | ||
|
|
6ac153c989 | ||
|
|
5ce6c8ced3 | ||
|
|
9809ffb095 | ||
|
|
a5dbd2e8dd | ||
|
|
021e25ade1 | ||
|
|
05dfb9cecd | ||
|
|
effa87638d | ||
|
|
300b7dfcc0 | ||
|
|
fb875c574c | ||
|
|
6e190bebe8 | ||
|
|
e4cc9f2b4f | ||
|
|
337df80c47 | ||
|
|
d542881ee4 | ||
|
|
4b75966401 | ||
|
|
433b5bddc1 | ||
|
|
12101fd26d | ||
|
|
33f93371dc | ||
|
|
d55735dc1e | ||
|
|
d41844c308 | ||
|
|
50b8dec3ac | ||
|
|
ce0d82b55f | ||
|
|
febc85361f | ||
|
|
d9bbdd8dca | ||
|
|
9c00f3caf3 | ||
|
|
ee9ff96c7d | ||
|
|
50a0751418 | ||
|
|
7f5dc22464 | ||
|
|
5b8317270e | ||
|
|
b5909ffe97 | ||
|
|
03e61e7a4a | ||
|
|
215f991326 | ||
|
|
2cee5a4a03 | ||
|
|
3be626bef3 | ||
|
|
c700126c17 | ||
|
|
feaa01dc77 | ||
|
|
34607736c6 | ||
|
|
5b9b802793 | ||
|
|
da5ea7acb8 | ||
|
|
08ff1d8d88 | ||
|
|
c2ff73c3e1 | ||
|
|
a805e033a5 | ||
|
|
e782e7d3a7 | ||
|
|
66015bb341 | ||
|
|
737f41dd2e | ||
|
|
402d32ccfd | ||
|
|
0413c747a9 | ||
|
|
acd5b07fb4 | ||
|
|
0a49c366f0 | ||
|
|
af2c5f3a18 | ||
|
|
115f34069a | ||
|
|
1ac87c55ff | ||
|
|
b8c1bb0ec5 | ||
|
|
f8835d78d9 | ||
|
|
fd9641dcd1 | ||
|
|
5656f030c4 | ||
|
|
2fc8ace46d | ||
|
|
2070d1096b | ||
|
|
5f9679253e | ||
|
|
359e6f2fd2 | ||
|
|
f647e86346 | ||
|
|
7fce3e2a9c | ||
|
|
c137d3ad17 | ||
|
|
424f007b10 | ||
|
|
5e720cd84b | ||
|
|
7845b2fbef | ||
|
|
04bf9ddab2 | ||
|
|
72bef8798f | ||
|
|
4547453141 | ||
|
|
fc6d432c2f | ||
|
|
038df1131e | ||
|
|
d1bc2cfa2f | ||
|
|
0615c11a53 | ||
|
|
69dee19568 | ||
|
|
4a79320253 | ||
|
|
d9b6d78d5c | ||
|
|
d0b13cf388 | ||
|
|
3514a6c5ed | ||
|
|
7490bc9100 | ||
|
|
eaba9d8a67 | ||
|
|
9d834a8e90 | ||
|
|
79e4f8a7bc | ||
|
|
5119cc47fc | ||
|
|
42636347e2 | ||
|
|
dd2d757270 | ||
|
|
08cd749160 | ||
|
|
f2bc2f704e | ||
|
|
a650d77af4 | ||
|
|
c062353a3f | ||
|
|
c1a02ec7d8 | ||
|
|
1be6ad1250 | ||
|
|
7dcdb1b404 | ||
|
|
5a7efad59c | ||
|
|
82ca88105c | ||
|
|
ec08043f23 | ||
|
|
823d9f0475 | ||
|
|
1a04028507 | ||
|
|
8fe4c51e26 | ||
|
|
9ef5868226 | ||
|
|
7531b7dcaa | ||
|
|
115e46a6a2 | ||
|
|
ee263b2f2b | ||
|
|
ca50e339cd | ||
|
|
94d9d3d590 | ||
|
|
5cf730bba7 | ||
|
|
4906af9319 | ||
|
|
0192b4b468 | ||
|
|
57d1527ce1 | ||
|
|
48b89322fd | ||
|
|
16018b75a3 | ||
|
|
0418416355 | ||
|
|
1db46c31aa | ||
|
|
4f06771559 | ||
|
|
6ed617e9f5 | ||
|
|
f5590b2fc0 | ||
|
|
38d524f6a0 | ||
|
|
ed2ecca475 | ||
|
|
aaf47486da | ||
|
|
c1f1af97c1 | ||
|
|
d81d1333ba | ||
|
|
9de3e6d716 | ||
|
|
e5b7188379 | ||
|
|
a00e5a0c0e | ||
|
|
c94a1ec8f8 | ||
|
|
c714bd8739 | ||
|
|
f2866ed858 | ||
|
|
137f16a160 | ||
|
|
73715538ed | ||
|
|
e4078a6aee | ||
|
|
efd86e2cb4 | ||
|
|
c1f189a602 | ||
|
|
d144592660 | ||
|
|
75b18f92b9 | ||
|
|
07a15494e5 | ||
|
|
bdd236fa3a | ||
|
|
966940cb00 | ||
|
|
f872bda19b | ||
|
|
22f6e0f2f4 | ||
|
|
8aa6dade41 | ||
|
|
d047eb46cc | ||
|
|
87a06a1976 | ||
|
|
a28436237c | ||
|
|
558295f728 | ||
|
|
ca43a4e97a | ||
|
|
cbd11cffa0 | ||
|
|
1813bb509b | ||
|
|
1d305e7b2f | ||
|
|
9b20ef4922 | ||
|
|
f066eea92e | ||
|
|
b96557c46e | ||
|
|
13220991a4 | ||
|
|
132a332ac7 | ||
|
|
42f3e40065 | ||
|
|
c4c6e02b4c | ||
|
|
5b276471b3 | ||
|
|
2bdf77a726 | ||
|
|
07098c6352 | ||
|
|
5f48af5b91 | ||
|
|
70550e41fc | ||
|
|
c69d1c86fe | ||
|
|
f806ab0bd2 | ||
|
|
18a8a375ab | ||
|
|
ec8fc727b8 | ||
|
|
6ace00df24 | ||
|
|
b4c00b2eb2 | ||
|
|
4b10051326 | ||
|
|
01c236ee59 | ||
|
|
f28bec876e | ||
|
|
11f2aaf7b1 | ||
|
|
3e8546135d | ||
|
|
70610f8343 | ||
|
|
6c4352de07 | ||
|
|
d68a6227ad | ||
|
|
bda5e0af74 | ||
|
|
05fa67ae8f | ||
|
|
2b687e2c06 | ||
|
|
3b624f35ac | ||
|
|
5ab789e83e | ||
|
|
ba676b7ed6 | ||
|
|
87a3a893e2 | ||
|
|
62e63cffe7 | ||
|
|
20128e6aa8 | ||
|
|
9a9a8a4b28 | ||
|
|
b609b9d297 | ||
|
|
3186aeac08 | ||
|
|
f13948d805 | ||
|
|
c877b59cbc | ||
|
|
e68cd9b671 | ||
|
|
9167a8bef0 | ||
|
|
9bf663934a | ||
|
|
4370332e32 | ||
|
|
3b357746d8 | ||
|
|
eff1a23c19 | ||
|
|
e2f6cdcf31 | ||
|
|
f5c5a4119f | ||
|
|
47d5e21061 | ||
|
|
9a55071d65 | ||
|
|
1119b06678 | ||
|
|
54d5281256 | ||
|
|
0777ab7011 | ||
|
|
d8eade736f | ||
|
|
b6e56c0e5a | ||
|
|
cfb2319923 | ||
|
|
7de88cc4eb | ||
|
|
10b9fa6ed0 | ||
|
|
4f5aba8be7 | ||
|
|
d93828e923 | ||
|
|
c57db1828f | ||
|
|
b5e74f4eae | ||
|
|
c8d02aafd9 | ||
|
|
f1a84f45c8 | ||
|
|
0551b90d47 | ||
|
|
2de03494a6 | ||
|
|
aa37866798 | ||
|
|
7723705707 | ||
|
|
dbf051ff4e | ||
|
|
2065580a5f | ||
|
|
8bc797fe6a | ||
|
|
04046ed6c8 | ||
|
|
53448b3f0a | ||
|
|
74da9c4e54 | ||
|
|
4b1899bfca | ||
|
|
8588b52fd6 | ||
|
|
4025d61c33 | ||
|
|
53292e7d12 | ||
|
|
b6664f5ae8 | ||
|
|
b02e6c6734 | ||
|
|
d84e7d13d0 | ||
|
|
c309412980 | ||
|
|
b427f506f6 | ||
|
|
ad51b0166c | ||
|
|
e1168139a6 | ||
|
|
9e2fb73440 | ||
|
|
adfa67d9d2 | ||
|
|
396c28817c | ||
|
|
7e0c5a44e3 | ||
|
|
6fc9c69b9f | ||
|
|
9adc1ebcc8 | ||
|
|
6399aaf4b2 | ||
|
|
16c383ab44 | ||
|
|
8905d9e63b | ||
|
|
64fa37c15d | ||
|
|
2c99366543 | ||
|
|
b1224b6839 | ||
|
|
453200ca1e | ||
|
|
1160e21d45 | ||
|
|
0e7164b4f5 | ||
|
|
ac321f4b64 | ||
|
|
7bfda6652f | ||
|
|
3fb477618e | ||
|
|
9e7193b3d2 | ||
|
|
846509bf9f | ||
|
|
c761e4fd08 | ||
|
|
8b5b3f165a | ||
|
|
c2046b9247 | ||
|
|
c5efeb2499 | ||
|
|
116e0559f6 | ||
|
|
428d89784b | ||
|
|
077c805880 | ||
|
|
352ed67667 | ||
|
|
ec29772068 | ||
|
|
3cc44b9de5 | ||
|
|
f8ac44cfbd | ||
|
|
0c5a1e84e3 | ||
|
|
1fe4d49ac6 | ||
|
|
41a83f1dc5 | ||
|
|
9e41b8b024 | ||
|
|
4c98508c0b | ||
|
|
084e2caf6f | ||
|
|
a75f82755d | ||
|
|
c1d2e0d999 | ||
|
|
165e861a1d | ||
|
|
fc056b1397 | ||
|
|
de0f158b04 | ||
|
|
9d6743824e | ||
|
|
ae1f0f0a3c | ||
|
|
cdb5eae43a | ||
|
|
26980fb859 | ||
|
|
53a2acd541 | ||
|
|
c58f0844a3 | ||
|
|
5cc43ae880 | ||
|
|
1c211e32b3 | ||
|
|
0dce81f646 | ||
|
|
b8863545d5 | ||
|
|
36dd630057 | ||
|
|
1de142f435 | ||
|
|
e936d7b53d | ||
|
|
0aa42615f9 | ||
|
|
d669932137 | ||
|
|
6b806ae12f | ||
|
|
b03fc97e28 | ||
|
|
d7bfa395b0 | ||
|
|
67cd4f2ab1 | ||
|
|
dc78d2f758 | ||
|
|
d40e696468 | ||
|
|
324cd94c53 | ||
|
|
4e36b9794f | ||
|
|
58ff221d1f | ||
|
|
1e36282708 | ||
|
|
22b88f9593 | ||
|
|
e4a79a0b0e | ||
|
|
8375c2c32d | ||
|
|
e7013d45c5 | ||
|
|
ac507720c8 | ||
|
|
d59433bf7e | ||
|
|
40257afeed | ||
|
|
851931fdf6 | ||
|
|
8bd527be6a | ||
|
|
e5b02f732b | ||
|
|
7c4a9c2aea | ||
|
|
7917128ed3 | ||
|
|
3c6a834ff4 | ||
|
|
dc3abc2bcb | ||
|
|
3ab917cdd0 | ||
|
|
e3116aac76 | ||
|
|
e85d69fe60 | ||
|
|
027c5c4b72 | ||
|
|
88303093c0 | ||
|
|
7e3be60f0e | ||
|
|
3b39807377 | ||
|
|
17a4360566 | ||
|
|
7a1cf1095a | ||
|
|
ab92737a9a | ||
|
|
27a6d1917d | ||
|
|
25544b38bd | ||
|
|
cf449d1c7e | ||
|
|
20826e15b3 | ||
|
|
d7a0437438 | ||
|
|
b61ea393b4 | ||
|
|
dc04123330 | ||
|
|
72ea6dd9f1 | ||
|
|
f8f54237c6 | ||
|
|
04921be9f1 | ||
|
|
1a066c0357 | ||
|
|
09ccd000d8 | ||
|
|
e3eef58310 | ||
|
|
17dbb3ec57 | ||
|
|
d862295a05 | ||
|
|
987954c817 | ||
|
|
6cb0c0339a | ||
|
|
7d1a094f9b | ||
|
|
533c99e384 | ||
|
|
92fb1109b6 | ||
|
|
98857184ff | ||
|
|
98376fbbce | ||
|
|
561f4d5d69 | ||
|
|
9cc9df3018 | ||
|
|
41a4cf7106 | ||
|
|
3b70cd64d7 | ||
|
|
1173459eee | ||
|
|
c0ecff9d70 | ||
|
|
d4fca9dabf | ||
|
|
aaaebfabbe | ||
|
|
6eccf8668e | ||
|
|
5e78a5c510 | ||
|
|
dce24225e0 | ||
|
|
f9b44c6bf2 | ||
|
|
f573a90a56 | ||
|
|
23bb0d927f | ||
|
|
2982893d0d | ||
|
|
6b0af942db | ||
|
|
0716f96da8 | ||
|
|
f95a482c24 | ||
|
|
edf94d732b | ||
|
|
9aa407dbd2 | ||
|
|
73a77abd20 | ||
|
|
a8f205213c | ||
|
|
a44b35e99e | ||
|
|
4770285c04 | ||
|
|
1639fbb544 | ||
|
|
2dc5f3c30c | ||
|
|
1a51584fe0 | ||
|
|
976c94ba5a | ||
|
|
4df74690e1 | ||
|
|
e2d60bd92e | ||
|
|
c2eada36f1 | ||
|
|
0ce6975ec8 | ||
|
|
1ddc56fcf8 | ||
|
|
7f7365db4f | ||
|
|
c9e2ba56c6 | ||
|
|
b01c9184c2 | ||
|
|
3ee1ae8c95 | ||
|
|
8ce6c8cf78 | ||
|
|
fbf4f5eb30 | ||
|
|
6c77e6fdb3 | ||
|
|
3b74431ea3 | ||
|
|
53fe7c460b | ||
|
|
f39556b933 | ||
|
|
cffd02defe | ||
|
|
bdc6088159 | ||
|
|
2a75a85556 | ||
|
|
94c025c6d7 | ||
|
|
8961bc2453 | ||
|
|
cb82c7062a | ||
|
|
b15814c42f | ||
|
|
023c8bb5b4 | ||
|
|
500440ebec | ||
|
|
b94de4f002 | ||
|
|
039a1e1f14 | ||
|
|
14a6c1f496 | ||
|
|
0a295ff56d | ||
|
|
36ffa9824b | ||
|
|
e3769c1073 | ||
|
|
d78fe4decc | ||
|
|
7b442e4be0 | ||
|
|
d17ac95f95 | ||
|
|
59c3203990 | ||
|
|
a4747c88e0 | ||
|
|
39ea59edc8 | ||
|
|
67ed61d022 | ||
|
|
b8f3abda5a | ||
|
|
8697f72068 | ||
|
|
3e7077bb73 | ||
|
|
5f89c1b137 | ||
|
|
811e1f92b1 | ||
|
|
15cbccc8f5 | ||
|
|
1cfa491179 | ||
|
|
35e7e07821 | ||
|
|
9ff54288ef | ||
|
|
baca6db680 | ||
|
|
6bd2bc5683 | ||
|
|
029625ec4e | ||
|
|
7e65f7fa4d | ||
|
|
80f5573cf3 | ||
|
|
1236157800 | ||
|
|
e628bfe6ff | ||
|
|
bb2bd7d721 | ||
|
|
9c107f9d2d | ||
|
|
fafd847f18 | ||
|
|
3a7e5a5f31 | ||
|
|
613d278afc | ||
|
|
96e0c4d7b8 | ||
|
|
46cea1f904 | ||
|
|
d844fc7edb | ||
|
|
3468dda556 | ||
|
|
e6ff4169e1 | ||
|
|
6f8c1a8f0d | ||
|
|
d529be86fe | ||
|
|
3ad4b10470 | ||
|
|
376fce273e | ||
|
|
c19960002b | ||
|
|
dc2b8fd327 | ||
|
|
737dfd2763 | ||
|
|
1ad80490de | ||
|
|
3fa323d1a1 | ||
|
|
a01ef6cfa0 | ||
|
|
c7d4d2e41f | ||
|
|
744ffbb1fb | ||
|
|
4d6132b9de | ||
|
|
7a31ff6a2f | ||
|
|
516f1cf67b | ||
|
|
3d6e48b05e | ||
|
|
3dd492627e | ||
|
|
66210b95fc | ||
|
|
6e58d99920 | ||
|
|
6471f12668 | ||
|
|
f3c4c2b8e3 | ||
|
|
a73b6448c5 | ||
|
|
3238981202 | ||
|
|
59ddce1ea3 | ||
|
|
2b025b267a | ||
|
|
efe8c4ca69 | ||
|
|
d7088efe73 | ||
|
|
05c5e73304 | ||
|
|
f00f518abe | ||
|
|
639f4a44e0 | ||
|
|
92e1c654ce | ||
|
|
81d2df4df9 | ||
|
|
65949dbcbc | ||
|
|
eb97e30b0a | ||
|
|
84518490c5 | ||
|
|
dd73ecbdc9 | ||
|
|
588a179b7f | ||
|
|
244915cd1e | ||
|
|
094d777e55 | ||
|
|
b430deab15 | ||
|
|
fe44e4d344 | ||
|
|
88c02d5a14 | ||
|
|
b75b891de1 | ||
|
|
f1ce653830 | ||
|
|
c06484297b | ||
|
|
331caad5b8 | ||
|
|
8b3fcfea13 | ||
|
|
05599e1bb5 | ||
|
|
bcdbc4fb8a | ||
|
|
41a2b86e70 | ||
|
|
f9292ab4d2 | ||
|
|
f4bd094128 | ||
|
|
90aa29528c | ||
|
|
34e3cb6881 | ||
|
|
d0ddb0637e | ||
|
|
2405809f92 | ||
|
|
77579f5509 | ||
|
|
5e33955566 | ||
|
|
73c0262345 | ||
|
|
a3ea924f0a | ||
|
|
01435997b9 | ||
|
|
90fe437e29 | ||
|
|
43077b82b7 | ||
|
|
1b56a8f3cb | ||
|
|
1d27402dd0 | ||
|
|
6f7161acf8 | ||
|
|
54e3ca8e6c | ||
|
|
5e873bc643 | ||
|
|
c5cfcc1229 | ||
|
|
203dfdedf1 | ||
|
|
febc9ab885 | ||
|
|
d7b18b662b | ||
|
|
b061775932 | ||
|
|
f865a23a18 | ||
|
|
8aa6415500 | ||
|
|
4a2a12fd21 | ||
|
|
a381ffc585 | ||
|
|
cdc13e9960 | ||
|
|
2419ef06a0 | ||
|
|
40dea3fbe1 | ||
|
|
157c781b0a | ||
|
|
6d2f87e904 | ||
|
|
81102f4be2 | ||
|
|
7b8e5d4e7c | ||
|
|
22c100bb6b | ||
|
|
e6715ce8b8 | ||
|
|
65443a3a66 | ||
|
|
47b8412695 |
@@ -1,80 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
# Bug Report
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Before submitting a bug report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project.
|
||||
|
||||
- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours.
|
||||
|
||||
- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||
|
||||
- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
|
||||
|
||||
Note: Please remove the notes above when submitting your post. Thank you for your understanding and support!
|
||||
|
||||
---
|
||||
|
||||
## Installation Method
|
||||
|
||||
[Describe the method you used to install the project, e.g., git clone, Docker, pip, etc.]
|
||||
|
||||
## Environment
|
||||
|
||||
- **Open WebUI Version:** [e.g., v0.3.11]
|
||||
- **Ollama (if applicable):** [e.g., v0.2.0, v0.1.32-rc1]
|
||||
|
||||
- **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04]
|
||||
- **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0]
|
||||
|
||||
**Confirmation:**
|
||||
|
||||
- [ ] I have read and followed all the instructions provided in the README.md.
|
||||
- [ ] I am on the latest version of both Open WebUI and Ollama.
|
||||
- [ ] I have included the browser console logs.
|
||||
- [ ] I have included the Docker container logs.
|
||||
- [ ] I have provided the exact steps to reproduce the bug in the "Steps to Reproduce" section below.
|
||||
|
||||
## Expected Behavior:
|
||||
|
||||
[Describe what you expected to happen.]
|
||||
|
||||
## Actual Behavior:
|
||||
|
||||
[Describe what actually happened.]
|
||||
|
||||
## Description
|
||||
|
||||
**Bug Summary:**
|
||||
[Provide a brief but clear summary of the bug]
|
||||
|
||||
## Reproduction Details
|
||||
|
||||
**Steps to Reproduce:**
|
||||
[Outline the steps to reproduce the bug. Be as detailed as possible.]
|
||||
|
||||
## Logs and Screenshots
|
||||
|
||||
**Browser Console Logs:**
|
||||
[Include relevant browser console logs, if applicable]
|
||||
|
||||
**Docker Container Logs:**
|
||||
[Include relevant Docker container logs, if applicable]
|
||||
|
||||
**Screenshots/Screen Recordings (if applicable):**
|
||||
[Attach any relevant screenshots to help illustrate the issue]
|
||||
|
||||
## Additional Information
|
||||
|
||||
[Include any additional details that may help in understanding and reproducing the issue. This could include specific configurations, error messages, or anything else relevant to the bug.]
|
||||
|
||||
## Note
|
||||
|
||||
If the bug report is incomplete or does not follow the provided instructions, it may not be addressed. Please ensure that you have followed the steps outlined in the README.md and troubleshooting.md documents, and provide all necessary information for us to reproduce and address the issue. Thank you!
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Bug Report
|
||||
description: Create a detailed bug report to help us improve Open WebUI.
|
||||
title: 'issue: '
|
||||
labels: ['bug', 'triage']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Bug Report
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project.
|
||||
|
||||
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
|
||||
|
||||
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
|
||||
|
||||
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
|
||||
|
||||
- type: checkboxes
|
||||
id: issue-check
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Confirm that you’ve checked for existing reports before submitting a new one.
|
||||
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
|
||||
attributes:
|
||||
label: Installation Method
|
||||
description: How did you install Open WebUI?
|
||||
options:
|
||||
- Git Clone
|
||||
- Pip Install
|
||||
- Docker
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: open-webui-version
|
||||
attributes:
|
||||
label: Open WebUI Version
|
||||
description: Specify the version (e.g., v0.3.11)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: ollama-version
|
||||
attributes:
|
||||
label: Ollama Version (if applicable)
|
||||
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Confirmation
|
||||
description: Ensure the following prerequisites have been met.
|
||||
options:
|
||||
- label: I have read and followed all instructions in `README.md`.
|
||||
required: true
|
||||
- label: I am using the latest version of **both** Open WebUI and Ollama.
|
||||
required: true
|
||||
- label: I have included the browser console logs.
|
||||
required: true
|
||||
- label: I have included the Docker container logs.
|
||||
required: true
|
||||
- label: I have listed steps to reproduce the bug in detail.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what should have happened.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: Describe what actually happened.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Providing clear, step-by-step instructions helps us reproduce and fix the issue faster. If we can't reproduce it, we can't fix it.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See the error message '...'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs-screenshots
|
||||
attributes:
|
||||
label: Logs & Screenshots
|
||||
description: Include relevant logs, errors, or screenshots to help diagnose the issue.
|
||||
placeholder: 'Attach logs from the browser console, Docker logs, or error messages.'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Provide any extra details that may assist in understanding the issue.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Note
|
||||
If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
|
||||
Thank you for contributing to Open WebUI!
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
# Feature Request
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Before submitting a report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project.
|
||||
|
||||
- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours.
|
||||
|
||||
- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||
|
||||
- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
|
||||
|
||||
Note: Please remove the notes above when submitting your post. Thank you for your understanding and support!
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: 'feat: '
|
||||
labels: ['triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Important Notes
|
||||
### Before submitting
|
||||
Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
|
||||
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
|
||||
This will help us efficiently focus on improving the project.
|
||||
|
||||
### Collaborate respectfully
|
||||
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
|
||||
|
||||
Remember:
|
||||
- Open WebUI is a **volunteer-driven project**
|
||||
- It's managed by a **single maintainer**
|
||||
- It's supported by contributors who also have **full-time jobs**
|
||||
|
||||
We appreciate your time and ask that you **respect ours**.
|
||||
|
||||
|
||||
### Contributing
|
||||
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||
|
||||
### Bug reproducibility
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
|
||||
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Please confirm that you've checked for existing similar requests
|
||||
options:
|
||||
- label: I have searched the existing issues and discussions.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
||||
placeholder: "Ex. I'm always frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Desired Solution you'd like
|
||||
description: Clearly describe what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-main-image:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build-cuda-image:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build-ollama-image:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
@@ -5,6 +5,85 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.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
|
||||
|
||||
- **📊 Logit Bias Parameter Support**: Fine-tune conversation dynamics by adjusting the Logit Bias parameter directly in chat settings, giving you more control over model responses.
|
||||
- **⌨️ Customizable Enter Behavior**: You can now configure Enter to send messages only when combined with Ctrl (Ctrl+Enter) via Settings > Interface, preventing accidental message sends.
|
||||
- **📝 Collapsible Code Blocks**: Easily collapse long code blocks to declutter your chat, making it easier to focus on important details.
|
||||
- **🏷️ Tag Selector in Model Selector**: Quickly find and categorize models with the new tag filtering system in the Model Selector, streamlining model discovery.
|
||||
- **📈 Experimental Elasticsearch Vector DB Support**: Now supports Elasticsearch as a vector database, offering more flexibility for data retrieval in Retrieval-Augmented Generation (RAG) workflows.
|
||||
- **⚙️ General Reliability Enhancements**: Various stability improvements across the WebUI, ensuring a smoother, more consistent experience.
|
||||
- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **🔄 "Stream" Hook Activation**: Fixed an issue where the "Stream" hook only worked when globally enabled, ensuring reliable real-time filtering.
|
||||
- **📧 LDAP Email Case Sensitivity**: Resolved an issue where LDAP login failed due to email case sensitivity mismatches, improving authentication reliability.
|
||||
- **💬 WebSocket Chat Event Registration**: Fixed a bug preventing chat event listeners from being registered upon sign-in, ensuring real-time updates work properly.
|
||||
|
||||
## [0.5.18] - 2025-02-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -587,6 +624,17 @@ load_oauth_providers()
|
||||
|
||||
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve()
|
||||
|
||||
for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"):
|
||||
if file_path.is_file():
|
||||
target_path = STATIC_DIR / file_path.relative_to(
|
||||
(FRONTEND_BUILD_DIR / "static")
|
||||
)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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"
|
||||
|
||||
if frontend_favicon.exists():
|
||||
@@ -659,11 +707,7 @@ if CUSTOM_NAME:
|
||||
# LICENSE_KEY
|
||||
####################################
|
||||
|
||||
LICENSE_KEY = PersistentConfig(
|
||||
"LICENSE_KEY",
|
||||
"license.key",
|
||||
os.environ.get("LICENSE_KEY", ""),
|
||||
)
|
||||
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
|
||||
|
||||
####################################
|
||||
# STORAGE PROVIDER
|
||||
@@ -695,16 +739,16 @@ AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", None)
|
||||
# File Upload DIR
|
||||
####################################
|
||||
|
||||
UPLOAD_DIR = f"{DATA_DIR}/uploads"
|
||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
####################################
|
||||
# Cache DIR
|
||||
####################################
|
||||
|
||||
CACHE_DIR = f"{DATA_DIR}/cache"
|
||||
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
CACHE_DIR = DATA_DIR / "cache"
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
####################################
|
||||
@@ -936,6 +980,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"
|
||||
)
|
||||
@@ -956,6 +1029,11 @@ 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_WEB_SEARCH = (
|
||||
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
|
||||
)
|
||||
@@ -978,12 +1056,19 @@ 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": {
|
||||
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
|
||||
@@ -1048,6 +1133,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:
|
||||
@@ -1269,7 +1360,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(
|
||||
@@ -1373,6 +1464,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",
|
||||
@@ -1536,11 +1632,24 @@ 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)
|
||||
|
||||
# ElasticSearch
|
||||
ELASTICSEARCH_URL = os.environ.get("ELASTICSEARCH_URL", "https://localhost:9200")
|
||||
ELASTICSEARCH_CA_CERTS = os.environ.get("ELASTICSEARCH_CA_CERTS", None)
|
||||
ELASTICSEARCH_API_KEY = os.environ.get("ELASTICSEARCH_API_KEY", None)
|
||||
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"):
|
||||
@@ -1600,6 +1709,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",
|
||||
@@ -1623,6 +1738,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",
|
||||
@@ -1704,6 +1824,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",
|
||||
@@ -1927,6 +2055,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",
|
||||
@@ -1977,6 +2111,12 @@ EXA_API_KEY = PersistentConfig(
|
||||
os.getenv("EXA_API_KEY", ""),
|
||||
)
|
||||
|
||||
PERPLEXITY_API_KEY = PersistentConfig(
|
||||
"PERPLEXITY_API_KEY",
|
||||
"rag.web.search.perplexity_api_key",
|
||||
os.getenv("PERPLEXITY_API_KEY", ""),
|
||||
)
|
||||
|
||||
RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig(
|
||||
"RAG_WEB_SEARCH_RESULT_COUNT",
|
||||
"rag.web.search.result_count",
|
||||
@@ -2007,6 +2147,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",
|
||||
|
||||
@@ -65,10 +65,8 @@ except Exception:
|
||||
# LOGGING
|
||||
####################################
|
||||
|
||||
log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
|
||||
|
||||
GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper()
|
||||
if GLOBAL_LOG_LEVEL in log_levels:
|
||||
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
|
||||
else:
|
||||
GLOBAL_LOG_LEVEL = "INFO"
|
||||
@@ -78,6 +76,7 @@ log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}")
|
||||
|
||||
if "cuda_error" in locals():
|
||||
log.exception(cuda_error)
|
||||
del cuda_error
|
||||
|
||||
log_sources = [
|
||||
"AUDIO",
|
||||
@@ -100,13 +99,12 @@ SRC_LOG_LEVELS = {}
|
||||
for source in log_sources:
|
||||
log_env_var = source + "_LOG_LEVEL"
|
||||
SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper()
|
||||
if SRC_LOG_LEVELS[source] not in log_levels:
|
||||
if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping():
|
||||
SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL
|
||||
log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}")
|
||||
|
||||
log.setLevel(SRC_LOG_LEVELS["CONFIG"])
|
||||
|
||||
|
||||
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
|
||||
if WEBUI_NAME != "Open WebUI":
|
||||
WEBUI_NAME += " (Open WebUI)"
|
||||
@@ -131,7 +129,6 @@ else:
|
||||
except Exception:
|
||||
PACKAGE_DATA = {"version": "0.0.0"}
|
||||
|
||||
|
||||
VERSION = PACKAGE_DATA["version"]
|
||||
|
||||
|
||||
@@ -162,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)
|
||||
|
||||
@@ -193,7 +189,6 @@ for version in soup.find_all("h2"):
|
||||
|
||||
changelog_json[version_number] = version_data
|
||||
|
||||
|
||||
CHANGELOG = changelog_json
|
||||
|
||||
####################################
|
||||
@@ -210,7 +205,6 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
|
||||
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# WEBUI_BUILD_HASH
|
||||
####################################
|
||||
@@ -245,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"))
|
||||
@@ -257,7 +250,6 @@ if FROM_INIT_PY:
|
||||
os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend")
|
||||
).resolve()
|
||||
|
||||
|
||||
####################################
|
||||
# Database
|
||||
####################################
|
||||
@@ -322,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"
|
||||
)
|
||||
@@ -331,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)
|
||||
@@ -386,6 +379,11 @@ ENABLE_WEBSOCKET_SUPPORT = (
|
||||
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", "")
|
||||
|
||||
@@ -397,19 +395,18 @@ else:
|
||||
except Exception:
|
||||
AIOHTTP_CLIENT_TIMEOUT = 300
|
||||
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get(
|
||||
"AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", ""
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
|
||||
"AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
|
||||
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
|
||||
)
|
||||
|
||||
if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "":
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = None
|
||||
if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "":
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None
|
||||
else:
|
||||
try:
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = int(
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST
|
||||
)
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
except Exception:
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 5
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10
|
||||
|
||||
####################################
|
||||
# OFFLINE_MODE
|
||||
@@ -423,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:
|
||||
@@ -441,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -105,6 +106,7 @@ from open_webui.config import (
|
||||
# Direct Connections
|
||||
ENABLE_DIRECT_CONNECTIONS,
|
||||
# Code Execution
|
||||
ENABLE_CODE_EXECUTION,
|
||||
CODE_EXECUTION_ENGINE,
|
||||
CODE_EXECUTION_JUPYTER_URL,
|
||||
CODE_EXECUTION_JUPYTER_AUTH,
|
||||
@@ -154,6 +156,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 +188,11 @@ from open_webui.config import (
|
||||
CHUNK_SIZE,
|
||||
CONTENT_EXTRACTION_ENGINE,
|
||||
TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY,
|
||||
RAG_TOP_K,
|
||||
RAG_TOP_K_RERANKER,
|
||||
RAG_TEXT_SPLITTER,
|
||||
TIKTOKEN_ENCODING_NAME,
|
||||
PDF_EXTRACT_IMAGES,
|
||||
@@ -211,10 +216,12 @@ 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,
|
||||
EXA_API_KEY,
|
||||
PERPLEXITY_API_KEY,
|
||||
KAGI_SEARCH_API_KEY,
|
||||
MOJEEK_SEARCH_API_KEY,
|
||||
BOCHA_SEARCH_API_KEY,
|
||||
@@ -246,6 +253,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,
|
||||
@@ -310,6 +318,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,
|
||||
@@ -325,6 +336,7 @@ from open_webui.env import (
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
RESET_CONFIG_ON_START,
|
||||
OFFLINE_MODE,
|
||||
ENABLE_OTEL,
|
||||
)
|
||||
|
||||
|
||||
@@ -352,6 +364,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")
|
||||
@@ -400,8 +414,8 @@ async def lifespan(app: FastAPI):
|
||||
if RESET_CONFIG_ON_START:
|
||||
reset_config()
|
||||
|
||||
if app.state.config.LICENSE_KEY:
|
||||
get_license_data(app, app.state.config.LICENSE_KEY)
|
||||
if LICENSE_KEY:
|
||||
get_license_data(app, LICENSE_KEY)
|
||||
|
||||
asyncio.create_task(periodic_usage_pool_cleanup())
|
||||
yield
|
||||
@@ -416,10 +430,26 @@ 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.config.LICENSE_KEY = LICENSE_KEY
|
||||
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)
|
||||
|
||||
|
||||
########################################
|
||||
#
|
||||
@@ -490,6 +520,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
|
||||
@@ -533,6 +564,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
|
||||
@@ -547,6 +579,7 @@ 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
|
||||
|
||||
@@ -603,14 +636,17 @@ app.state.config.JINA_API_KEY = JINA_API_KEY
|
||||
app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
|
||||
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
|
||||
app.state.config.EXA_API_KEY = EXA_API_KEY
|
||||
app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY
|
||||
|
||||
app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT
|
||||
app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS
|
||||
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
|
||||
@@ -658,6 +694,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
|
||||
@@ -943,14 +980,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:
|
||||
@@ -1016,10 +1063,11 @@ 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),
|
||||
"model": model_info.model_dump() if model_info else model,
|
||||
"model": model,
|
||||
"direct": model_item.get("direct", False),
|
||||
**(
|
||||
{"function_calling": "native"}
|
||||
@@ -1037,11 +1085,19 @@ async def chat_completion(
|
||||
form_data["metadata"] = metadata
|
||||
|
||||
form_data, metadata, events = await process_chat_payload(
|
||||
request, form_data, metadata, user, model
|
||||
request, form_data, user, metadata, model
|
||||
)
|
||||
|
||||
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),
|
||||
@@ -1051,7 +1107,7 @@ async def chat_completion(
|
||||
response = await chat_completion_handler(request, form_data, user)
|
||||
|
||||
return await process_chat_response(
|
||||
request, response, form_data, user, events, metadata, tasks
|
||||
request, response, form_data, user, metadata, model, events, tasks
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -1140,9 +1196,10 @@ async def get_app_config(request: Request):
|
||||
if data is not None and "id" in data:
|
||||
user = Users.get_user_by_id(data["id"])
|
||||
|
||||
user_count = Users.get_num_users()
|
||||
onboarding = False
|
||||
|
||||
if user is None:
|
||||
user_count = Users.get_num_users()
|
||||
onboarding = user_count == 0
|
||||
|
||||
return {
|
||||
@@ -1170,11 +1227,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,
|
||||
@@ -1188,6 +1247,7 @@ async def get_app_config(request: Request):
|
||||
{
|
||||
"default_models": app.state.config.DEFAULT_MODELS,
|
||||
"default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS,
|
||||
"user_count": user_count,
|
||||
"code": {
|
||||
"engine": app.state.config.CODE_EXECUTION_ENGINE,
|
||||
},
|
||||
@@ -1211,6 +1271,14 @@ async def get_app_config(request: Request):
|
||||
"api_key": GOOGLE_DRIVE_API_KEY.value,
|
||||
},
|
||||
"onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value},
|
||||
"license_metadata": app.state.LICENSE_METADATA,
|
||||
**(
|
||||
{
|
||||
"active_entries": app.state.USER_COUNT,
|
||||
}
|
||||
if user.role == "admin"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if user is not None
|
||||
else {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -105,7 +105,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 +117,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
|
||||
@@ -149,6 +195,12 @@ class Loader:
|
||||
file_path=file_path,
|
||||
mime_type=file_content_type,
|
||||
)
|
||||
elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
|
||||
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") != ""
|
||||
@@ -176,7 +228,7 @@ class Loader:
|
||||
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":
|
||||
|
||||
@@ -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
|
||||
@@ -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,66 @@ 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
|
||||
|
||||
tasks = [
|
||||
(collection_name, query)
|
||||
for collection_name in collection_names
|
||||
for query 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 +353,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 None
|
||||
).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 +396,7 @@ def get_sources_from_files(
|
||||
embedding_function,
|
||||
k,
|
||||
reranking_function,
|
||||
k_reranker,
|
||||
r,
|
||||
hybrid_search,
|
||||
full_context=False,
|
||||
@@ -414,6 +466,13 @@ def get_sources_from_files(
|
||||
]
|
||||
],
|
||||
}
|
||||
elif file.get("file").get("data"):
|
||||
context = {
|
||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
||||
"metadatas": [
|
||||
[file.get("file").get("data", {}).get("metadata", {})]
|
||||
],
|
||||
}
|
||||
else:
|
||||
collection_names = []
|
||||
if file.get("type") == "collection":
|
||||
@@ -454,6 +513,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:
|
||||
@@ -546,9 +606,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={
|
||||
@@ -565,7 +630,7 @@ def generate_openai_batch_embeddings(
|
||||
else {}
|
||||
),
|
||||
},
|
||||
json={"input": texts, "model": model},
|
||||
json=json_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
@@ -579,9 +644,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={
|
||||
@@ -598,7 +672,7 @@ def generate_ollama_batch_embeddings(
|
||||
else {}
|
||||
),
|
||||
},
|
||||
json={"input": texts, "model": model},
|
||||
json=json_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
@@ -612,15 +686,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(
|
||||
@@ -629,16 +722,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
|
||||
|
||||
|
||||
@@ -674,9 +771,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]
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ elif VECTOR_DB == "pgvector":
|
||||
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
|
||||
|
||||
VECTOR_DB_CLIENT = PgvectorClient()
|
||||
elif VECTOR_DB == "elasticsearch":
|
||||
from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
|
||||
|
||||
VECTOR_DB_CLIENT = ElasticsearchClient()
|
||||
else:
|
||||
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
from elasticsearch import Elasticsearch, BadRequestError
|
||||
from typing import Optional
|
||||
import ssl
|
||||
from elasticsearch.helpers import bulk, scan
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.config import (
|
||||
ELASTICSEARCH_URL,
|
||||
ELASTICSEARCH_CA_CERTS,
|
||||
ELASTICSEARCH_API_KEY,
|
||||
ELASTICSEARCH_USERNAME,
|
||||
ELASTICSEARCH_PASSWORD,
|
||||
ELASTICSEARCH_CLOUD_ID,
|
||||
ELASTICSEARCH_INDEX_PREFIX,
|
||||
SSL_ASSERT_FINGERPRINT,
|
||||
)
|
||||
|
||||
|
||||
class ElasticsearchClient:
|
||||
"""
|
||||
Important:
|
||||
in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating
|
||||
an index for each file but store it as a text field, while seperating to different index
|
||||
baesd on the embedding length.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.index_prefix = ELASTICSEARCH_INDEX_PREFIX
|
||||
self.client = Elasticsearch(
|
||||
hosts=[ELASTICSEARCH_URL],
|
||||
ca_certs=ELASTICSEARCH_CA_CERTS,
|
||||
api_key=ELASTICSEARCH_API_KEY,
|
||||
cloud_id=ELASTICSEARCH_CLOUD_ID,
|
||||
basic_auth=(
|
||||
(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD)
|
||||
if ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD
|
||||
else None
|
||||
),
|
||||
ssl_assert_fingerprint=SSL_ASSERT_FINGERPRINT,
|
||||
)
|
||||
|
||||
# Status: works
|
||||
def _get_index_name(self, dimension: int) -> str:
|
||||
return f"{self.index_prefix}_d{str(dimension)}"
|
||||
|
||||
# Status: works
|
||||
def _scan_result_to_get_result(self, result) -> GetResult:
|
||||
if not result:
|
||||
return None
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for hit in result:
|
||||
ids.append(hit["_id"])
|
||||
documents.append(hit["_source"].get("text"))
|
||||
metadatas.append(hit["_source"].get("metadata"))
|
||||
|
||||
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||
|
||||
# Status: works
|
||||
def _result_to_get_result(self, result) -> GetResult:
|
||||
if not result["hits"]["hits"]:
|
||||
return None
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for hit in result["hits"]["hits"]:
|
||||
ids.append(hit["_id"])
|
||||
documents.append(hit["_source"].get("text"))
|
||||
metadatas.append(hit["_source"].get("metadata"))
|
||||
|
||||
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||
|
||||
# Status: works
|
||||
def _result_to_search_result(self, result) -> SearchResult:
|
||||
ids = []
|
||||
distances = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for hit in result["hits"]["hits"]:
|
||||
ids.append(hit["_id"])
|
||||
distances.append(hit["_score"])
|
||||
documents.append(hit["_source"].get("text"))
|
||||
metadatas.append(hit["_source"].get("metadata"))
|
||||
|
||||
return SearchResult(
|
||||
ids=[ids],
|
||||
distances=[distances],
|
||||
documents=[documents],
|
||||
metadatas=[metadatas],
|
||||
)
|
||||
|
||||
# Status: works
|
||||
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"},
|
||||
"vector": {
|
||||
"type": "dense_vector",
|
||||
"dims": dimension, # Adjust based on your vector dimensions
|
||||
"index": True,
|
||||
"similarity": "cosine",
|
||||
},
|
||||
"text": {"type": "text"},
|
||||
"metadata": {"type": "object"},
|
||||
},
|
||||
}
|
||||
}
|
||||
self.client.indices.create(index=self._get_index_name(dimension), body=body)
|
||||
|
||||
# Status: works
|
||||
|
||||
def _create_batches(self, items: list[VectorItem], batch_size=100):
|
||||
for i in range(0, len(items), batch_size):
|
||||
yield items[i : min(i + batch_size, len(items))]
|
||||
|
||||
# Status: works
|
||||
def has_collection(self, collection_name) -> bool:
|
||||
query_body = {"query": {"bool": {"filter": []}}}
|
||||
query_body["query"]["bool"]["filter"].append(
|
||||
{"term": {"collection": collection_name}}
|
||||
)
|
||||
|
||||
try:
|
||||
result = self.client.count(index=f"{self.index_prefix}*", body=query_body)
|
||||
|
||||
return result.body["count"] > 0
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
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(
|
||||
self, collection_name: str, vectors: list[list[float]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
query = {
|
||||
"size": limit,
|
||||
"_source": ["text", "metadata"],
|
||||
"query": {
|
||||
"script_score": {
|
||||
"query": {
|
||||
"bool": {"filter": [{"term": {"collection": collection_name}}]}
|
||||
},
|
||||
"script": {
|
||||
"source": "cosineSimilarity(params.vector, 'vector') + 1.0",
|
||||
"params": {
|
||||
"vector": vectors[0]
|
||||
}, # Assuming single query vector
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = self.client.search(
|
||||
index=self._get_index_name(len(vectors[0])), body=query
|
||||
)
|
||||
|
||||
return self._result_to_search_result(result)
|
||||
|
||||
# Status: only tested halfwat
|
||||
def query(
|
||||
self, collection_name: str, filter: dict, limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
if not self.has_collection(collection_name):
|
||||
return None
|
||||
|
||||
query_body = {
|
||||
"query": {"bool": {"filter": []}},
|
||||
"_source": ["text", "metadata"],
|
||||
}
|
||||
|
||||
for field, value in filter.items():
|
||||
query_body["query"]["bool"]["filter"].append({"term": {field: value}})
|
||||
query_body["query"]["bool"]["filter"].append(
|
||||
{"term": {"collection": collection_name}}
|
||||
)
|
||||
size = limit if limit else 10
|
||||
|
||||
try:
|
||||
result = self.client.search(
|
||||
index=f"{self.index_prefix}*",
|
||||
body=query_body,
|
||||
size=size,
|
||||
)
|
||||
|
||||
return self._result_to_get_result(result)
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
# Status: works
|
||||
def _has_index(self, dimension: int):
|
||||
return self.client.indices.exists(
|
||||
index=self._get_index_name(dimension=dimension)
|
||||
)
|
||||
|
||||
def get_or_create_index(self, dimension: int):
|
||||
if not self._has_index(dimension=dimension):
|
||||
self._create_index(dimension=dimension)
|
||||
|
||||
# Status: works
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
# Get all the items in the collection.
|
||||
query = {
|
||||
"query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}},
|
||||
"_source": ["text", "metadata"],
|
||||
}
|
||||
results = list(scan(self.client, index=f"{self.index_prefix}*", query=query))
|
||||
|
||||
return self._scan_result_to_get_result(results)
|
||||
|
||||
# Status: works
|
||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
||||
if not self._has_index(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"])),
|
||||
"_id": item["id"],
|
||||
"_source": {
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
|
||||
# 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(dimension=len(items[0]["vector"]))
|
||||
for batch in self._create_batches(items):
|
||||
actions = [
|
||||
{
|
||||
"_op_type": "update",
|
||||
"_index": self._get_index_name(dimension=len(item["vector"])),
|
||||
"_id": item["id"],
|
||||
"doc": {
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
for item in batch
|
||||
]
|
||||
bulk(self.client, 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}*")
|
||||
for index in indices:
|
||||
self.client.indices.delete(index=index)
|
||||
@@ -20,9 +20,9 @@ class MilvusClient:
|
||||
def __init__(self):
|
||||
self.collection_prefix = "open_webui"
|
||||
if MILVUS_TOKEN is None:
|
||||
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB)
|
||||
self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB)
|
||||
else:
|
||||
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN)
|
||||
self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN)
|
||||
|
||||
def _result_to_get_result(self, result) -> GetResult:
|
||||
ids = []
|
||||
@@ -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,72 +56,88 @@ 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, index_name: str, dimension: int):
|
||||
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}_{index_name}", body=body)
|
||||
self.client.indices.create(
|
||||
index=self._get_index_name(collection_name), body=body
|
||||
)
|
||||
|
||||
def _create_batches(self, items: list[VectorItem], batch_size=100):
|
||||
for i in range(0, len(items), batch_size):
|
||||
yield items[i : i + batch_size]
|
||||
|
||||
def has_collection(self, index_name: str) -> bool:
|
||||
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}_{index_name}")
|
||||
return self.client.indices.exists(index=self._get_index_name(collection_name))
|
||||
|
||||
def delete_colleciton(self, index_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}_{index_name}")
|
||||
self.client.indices.delete(index=self._get_index_name(collection_name))
|
||||
|
||||
def search(
|
||||
self, index_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}_{index_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
|
||||
@@ -125,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,
|
||||
)
|
||||
@@ -141,64 +169,88 @@ class OpenSearchClient:
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def get_or_create_index(self, index_name: str, dimension: int):
|
||||
if not self.has_index(index_name):
|
||||
self._create_index(index_name, dimension)
|
||||
def _create_index_if_not_exists(self, collection_name: str, dimension: int):
|
||||
if not self.has_collection(collection_name):
|
||||
self._create_index(collection_name, dimension)
|
||||
|
||||
def get(self, index_name: str) -> Optional[GetResult]:
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]}
|
||||
|
||||
result = self.client.search(
|
||||
index=f"{self.index_prefix}_{index_name}", body=query
|
||||
index=self._get_index_name(collection_name), body=query
|
||||
)
|
||||
return self._result_to_get_result(result)
|
||||
|
||||
def insert(self, index_name: str, items: list[VectorItem]):
|
||||
if not self.has_index(index_name):
|
||||
self._create_index(index_name, dimension=len(items[0]["vector"]))
|
||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
||||
self._create_index_if_not_exists(
|
||||
collection_name=collection_name, dimension=len(items[0]["vector"])
|
||||
)
|
||||
|
||||
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, index_name: str, items: list[VectorItem]):
|
||||
if not self.has_index(index_name):
|
||||
self._create_index(index_name, dimension=len(items[0]["vector"]))
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
self._create_index_if_not_exists(
|
||||
collection_name=collection_name, dimension=len(items[0]["vector"])
|
||||
)
|
||||
|
||||
for batch in self._create_batches(items):
|
||||
actions = [
|
||||
{
|
||||
"index": {
|
||||
"_id": item["id"],
|
||||
"_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, index_name: str, ids: list[str]):
|
||||
actions = [
|
||||
{"delete": {"_index": f"{self.index_prefix}_{index_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):
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
import requests
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_perplexity(
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Perplexity API key
|
||||
query (str): The query to search for
|
||||
count (int): Maximum number of results to return
|
||||
|
||||
"""
|
||||
|
||||
# Handle PersistentConfig object
|
||||
if hasattr(api_key, "__str__"):
|
||||
api_key = str(api_key)
|
||||
|
||||
try:
|
||||
url = "https://api.perplexity.ai/chat/completions"
|
||||
|
||||
# Create payload for the API call
|
||||
payload = {
|
||||
"model": "sonar",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a search assistant. Provide factual information with citations.",
|
||||
},
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
"temperature": 0.2, # Lower temperature for more factual responses
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Make the API request
|
||||
response = requests.request("POST", url, json=payload, headers=headers)
|
||||
|
||||
# Parse the JSON response
|
||||
json_response = response.json()
|
||||
|
||||
# Extract citations from the response
|
||||
citations = json_response.get("citations", [])
|
||||
|
||||
# Create search results from citations
|
||||
results = []
|
||||
for i, citation in enumerate(citations[:count]):
|
||||
# Extract content from the response to use as snippet
|
||||
content = ""
|
||||
if "choices" in json_response and json_response["choices"]:
|
||||
if i == 0:
|
||||
content = json_response["choices"][0]["message"]["content"]
|
||||
|
||||
result = {"link": citation, "title": f"Source {i+1}", "snippet": content}
|
||||
results.append(result)
|
||||
|
||||
if filter_list:
|
||||
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["link"], title=result["title"], snippet=result["snippet"]
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error searching with Perplexity API: {e}")
|
||||
return []
|
||||
@@ -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)
|
||||
|
||||
@@ -54,7 +54,7 @@ MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["AUDIO"])
|
||||
|
||||
SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
|
||||
SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech"
|
||||
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -230,9 +230,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
|
||||
entry = connection_app.entries[0]
|
||||
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
|
||||
mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
|
||||
if not mail or mail == "" or mail == "[]":
|
||||
raise HTTPException(400, f"User {form_data.user} does not have mail.")
|
||||
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.")
|
||||
else:
|
||||
email = email.lower()
|
||||
|
||||
cn = str(entry["cn"])
|
||||
user_dn = entry.entry_dn
|
||||
|
||||
@@ -247,7 +250,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
if not connection_user.bind():
|
||||
raise HTTPException(400, f"Authentication failed for {form_data.user}")
|
||||
|
||||
user = Users.get_user_by_email(mail)
|
||||
user = Users.get_user_by_email(email)
|
||||
if not user:
|
||||
try:
|
||||
user_count = Users.get_num_users()
|
||||
@@ -259,7 +262,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
)
|
||||
|
||||
user = Auths.insert_new_auth(
|
||||
email=mail, password=str(uuid.uuid4()), name=cn, role=role
|
||||
email=email,
|
||||
password=str(uuid.uuid4()),
|
||||
name=cn,
|
||||
role=role,
|
||||
)
|
||||
|
||||
if not user:
|
||||
@@ -272,7 +278,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
except Exception as err:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
|
||||
|
||||
user = Auths.authenticate_user_by_trusted_header(mail)
|
||||
user = Auths.authenticate_user_by_trusted_header(email)
|
||||
|
||||
if user:
|
||||
token = create_token(
|
||||
@@ -633,11 +639,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -648,11 +655,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")
|
||||
@@ -687,6 +695,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,
|
||||
@@ -699,6 +709,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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
############################
|
||||
|
||||
@@ -70,6 +70,7 @@ async def set_direct_connections_config(
|
||||
# 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 +90,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 +113,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 +157,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,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,33 @@ def upload_file(
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
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)
|
||||
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
|
||||
@@ -160,7 +205,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 +233,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 +267,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 +303,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 +334,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 +375,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 +418,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 +479,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)
|
||||
|
||||
@@ -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,18 @@ 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 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:
|
||||
|
||||
@@ -74,7 +74,7 @@ async def create_new_function(
|
||||
|
||||
function = Functions.insert_new_function(user.id, function_type, form_data)
|
||||
|
||||
function_cache_dir = Path(CACHE_DIR) / "functions" / form_data.id
|
||||
function_cache_dir = CACHE_DIR / "functions" / form_data.id
|
||||
function_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if function:
|
||||
|
||||
@@ -25,7 +25,7 @@ from pydantic import BaseModel
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["IMAGES"])
|
||||
|
||||
IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/")
|
||||
IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations"
|
||||
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -517,7 +517,11 @@ async def image_generations(
|
||||
images = []
|
||||
|
||||
for image in res["data"]:
|
||||
image_data, content_type = load_b64_image_data(image["b64_json"])
|
||||
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"])
|
||||
|
||||
url = upload_image(request, data, image_data, content_type, user)
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,7 +55,7 @@ from open_webui.env import (
|
||||
ENV,
|
||||
SRC_LOG_LEVELS,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
@@ -72,7 +72,7 @@ log.setLevel(SRC_LOG_LEVELS["OLLAMA"])
|
||||
|
||||
|
||||
async def send_get_request(url, key=None, user: UserModel = None):
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
@@ -216,7 +216,7 @@ async def verify_connection(
|
||||
key = form_data.key
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
@@ -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),
|
||||
|
||||
@@ -22,7 +22,7 @@ from open_webui.config import (
|
||||
)
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
)
|
||||
@@ -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
|
||||
@@ -53,7 +56,7 @@ log.setLevel(SRC_LOG_LEVELS["OPENAI"])
|
||||
|
||||
|
||||
async def send_get_request(url, key=None, user: UserModel = None):
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
@@ -192,7 +195,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
body = await request.body()
|
||||
name = hashlib.sha256(body).hexdigest()
|
||||
|
||||
SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
|
||||
SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech"
|
||||
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
|
||||
file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -448,9 +462,7 @@ async def get_models(
|
||||
|
||||
r = None
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(
|
||||
total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST
|
||||
)
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
@@ -530,7 +542,7 @@ async def verify_connection(
|
||||
key = form_data.key
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
@@ -668,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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -59,7 +59,7 @@ from open_webui.retrieval.web.serpstack import search_serpstack
|
||||
from open_webui.retrieval.web.tavily import search_tavily
|
||||
from open_webui.retrieval.web.bing import search_bing
|
||||
from open_webui.retrieval.web.exa import search_exa
|
||||
|
||||
from open_webui.retrieval.web.perplexity import search_perplexity
|
||||
|
||||
from open_webui.retrieval.utils import (
|
||||
get_embedding_function,
|
||||
@@ -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,
|
||||
@@ -358,6 +359,7 @@ 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,
|
||||
@@ -405,6 +407,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
|
||||
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
|
||||
"exa_api_key": request.app.state.config.EXA_API_KEY,
|
||||
"perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY,
|
||||
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
|
||||
"trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
|
||||
"concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
@@ -427,6 +430,7 @@ class DocumentIntelligenceConfigForm(BaseModel):
|
||||
class ContentExtractionConfig(BaseModel):
|
||||
engine: str = ""
|
||||
tika_server_url: Optional[str] = None
|
||||
docling_server_url: Optional[str] = None
|
||||
document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None
|
||||
|
||||
|
||||
@@ -465,6 +469,7 @@ class WebSearchConfig(BaseModel):
|
||||
bing_search_v7_endpoint: Optional[str] = None
|
||||
bing_search_v7_subscription_key: Optional[str] = None
|
||||
exa_api_key: Optional[str] = None
|
||||
perplexity_api_key: Optional[str] = None
|
||||
result_count: Optional[int] = None
|
||||
concurrent_requests: Optional[int] = None
|
||||
trust_env: Optional[bool] = None
|
||||
@@ -538,6 +543,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
|
||||
@@ -617,6 +625,10 @@ async def update_rag_config(
|
||||
|
||||
request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key
|
||||
|
||||
request.app.state.config.PERPLEXITY_API_KEY = (
|
||||
form_data.web.search.perplexity_api_key
|
||||
)
|
||||
|
||||
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = (
|
||||
form_data.web.search.result_count
|
||||
)
|
||||
@@ -642,6 +654,7 @@ 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,
|
||||
@@ -683,6 +696,7 @@ async def update_rag_config(
|
||||
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
|
||||
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
|
||||
"exa_api_key": request.app.state.config.EXA_API_KEY,
|
||||
"perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY,
|
||||
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
|
||||
"concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
"trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
|
||||
@@ -706,6 +720,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,
|
||||
}
|
||||
@@ -713,6 +728,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
|
||||
@@ -724,6 +740,7 @@ 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 = (
|
||||
@@ -734,6 +751,7 @@ async def update_query_settings(
|
||||
"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,
|
||||
}
|
||||
@@ -874,7 +892,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 = [
|
||||
@@ -983,6 +1003,7 @@ 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,
|
||||
@@ -1182,9 +1203,13 @@ def process_web(
|
||||
content = " ".join([doc.page_content for doc in docs])
|
||||
|
||||
log.debug(f"text_content: {content}")
|
||||
save_docs_to_vector_db(
|
||||
request, docs, collection_name, overwrite=True, user=user
|
||||
)
|
||||
|
||||
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
|
||||
save_docs_to_vector_db(
|
||||
request, docs, collection_name, overwrite=True, user=user
|
||||
)
|
||||
else:
|
||||
collection_name = None
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
@@ -1196,6 +1221,7 @@ def process_web(
|
||||
},
|
||||
"meta": {
|
||||
"name": form_data.url,
|
||||
"source": form_data.url,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1221,6 +1247,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
- SERPLY_API_KEY
|
||||
- TAVILY_API_KEY
|
||||
- EXA_API_KEY
|
||||
- PERPLEXITY_API_KEY
|
||||
- SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`)
|
||||
- SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`)
|
||||
Args:
|
||||
@@ -1385,6 +1412,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
elif engine == "perplexity":
|
||||
return search_perplexity(
|
||||
request.app.state.config.PERPLEXITY_API_KEY,
|
||||
query,
|
||||
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception("No search engine API key found in environment variables")
|
||||
|
||||
@@ -1468,6 +1502,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
|
||||
|
||||
@@ -1483,11 +1518,13 @@ def query_doc_handler(
|
||||
return query_doc_with_hybrid_search(
|
||||
collection_name=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
|
||||
@@ -1499,7 +1536,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,
|
||||
@@ -1516,6 +1553,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
|
||||
|
||||
@@ -1531,11 +1569,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
|
||||
@@ -1546,8 +1586,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,
|
||||
)
|
||||
@@ -1624,7 +1664,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):
|
||||
|
||||
@@ -105,7 +105,7 @@ async def create_new_tools(
|
||||
specs = get_tools_specs(TOOLS[form_data.id])
|
||||
tools = Tools.insert_new_tool(user.id, form_data, specs)
|
||||
|
||||
tool_cache_dir = Path(CACHE_DIR) / "tools" / form_data.id
|
||||
tool_cache_dir = CACHE_DIR / "tools" / form_data.id
|
||||
tool_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if tools:
|
||||
|
||||
@@ -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,12 +76,20 @@ 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):
|
||||
@@ -84,16 +100,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 +124,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()
|
||||
|
||||
@@ -3,15 +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
|
||||
@@ -28,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",
|
||||
@@ -54,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=TIMEOUT_DURATION * 2,
|
||||
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
|
||||
redis_sentinels=redis_sentinels,
|
||||
)
|
||||
aquire_func = clean_up_lock.aquire_lock
|
||||
renew_func = clean_up_lock.renew_lock
|
||||
@@ -268,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:
|
||||
@@ -286,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__
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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"
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -101,19 +101,33 @@ class LocalStorageProvider(StorageProvider):
|
||||
|
||||
class S3StorageProvider(StorageProvider):
|
||||
def __init__(self):
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
region_name=S3_REGION_NAME,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
aws_access_key_id=S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
|
||||
config=Config(
|
||||
s3={
|
||||
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
|
||||
"addressing_style": S3_ADDRESSING_STYLE,
|
||||
},
|
||||
),
|
||||
config = Config(
|
||||
s3={
|
||||
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
|
||||
"addressing_style": S3_ADDRESSING_STYLE,
|
||||
},
|
||||
)
|
||||
|
||||
# If access key and secret are provided, use them for authentication
|
||||
if S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY:
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
region_name=S3_REGION_NAME,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
aws_access_key_id=S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
# If no explicit credentials are provided, fall back to default AWS credentials
|
||||
# This supports workload identity (IAM roles for EC2, EKS, etc.)
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
region_name=S3_REGION_NAME,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
config=config,
|
||||
)
|
||||
|
||||
self.bucket_name = S3_BUCKET_NAME
|
||||
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
|
||||
|
||||
|
||||
@@ -187,6 +187,17 @@ class TestS3StorageProvider:
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
assert not (upload_dir / self.filename_extra).exists()
|
||||
|
||||
def test_init_without_credentials(self, monkeypatch):
|
||||
"""Test that S3StorageProvider can initialize without explicit credentials."""
|
||||
# Temporarily unset the environment variables
|
||||
monkeypatch.setattr(provider, "S3_ACCESS_KEY_ID", None)
|
||||
monkeypatch.setattr(provider, "S3_SECRET_ACCESS_KEY", None)
|
||||
|
||||
# Should not raise an exception
|
||||
storage = provider.S3StorageProvider()
|
||||
assert storage.s3_client is not None
|
||||
assert storage.bucket_name == provider.S3_BUCKET_NAME
|
||||
|
||||
|
||||
class TestGCSStorageProvider:
|
||||
Storage = provider.GCSStorageProvider()
|
||||
|
||||
@@ -72,7 +72,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,
|
||||
)
|
||||
@@ -83,11 +83,12 @@ def get_license_data(app, key):
|
||||
if k == "resources":
|
||||
for p, c in v.items():
|
||||
globals().get("override_static", lambda a, b: None)(p, c)
|
||||
elif k == "user_count":
|
||||
elif k == "count":
|
||||
setattr(app.state, "USER_COUNT", v)
|
||||
elif k == "webui_name":
|
||||
elif k == "name":
|
||||
setattr(app.state, "WEBUI_NAME", v)
|
||||
|
||||
elif k == "metadata":
|
||||
setattr(app.state, "LICENSE_METADATA", v)
|
||||
return True
|
||||
else:
|
||||
log.error(
|
||||
|
||||
@@ -149,7 +149,7 @@ async def generate_direct_chat_completion(
|
||||
}
|
||||
)
|
||||
|
||||
if "error" in res:
|
||||
if "error" in res and res["error"]:
|
||||
raise Exception(res["error"])
|
||||
|
||||
return res
|
||||
@@ -328,9 +328,14 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
}
|
||||
|
||||
try:
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(model)
|
||||
]
|
||||
|
||||
result, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_ids=get_sorted_filter_ids(model),
|
||||
filter_functions=filter_functions,
|
||||
filter_type="outlet",
|
||||
form_data=data,
|
||||
extra_params=extra_params,
|
||||
|
||||
@@ -1,148 +1,210 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
import requests
|
||||
from urllib.parse import urljoin
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
async def execute_code_jupyter(
|
||||
jupyter_url, code, token=None, password=None, timeout=10
|
||||
):
|
||||
class ResultModel(BaseModel):
|
||||
"""
|
||||
Executes Python code in a Jupyter kernel.
|
||||
Supports authentication with a token or password.
|
||||
:param jupyter_url: Jupyter server URL (e.g., "http://localhost:8888")
|
||||
:param code: Code to execute
|
||||
:param token: Jupyter authentication token (optional)
|
||||
:param password: Jupyter password (optional)
|
||||
:param timeout: WebSocket timeout in seconds (default: 10s)
|
||||
:return: Dictionary with stdout, stderr, and result
|
||||
- Images are prefixed with "base64:image/png," and separated by newlines if multiple.
|
||||
Execute Code Result Model
|
||||
"""
|
||||
session = requests.Session() # Maintain cookies
|
||||
headers = {} # Headers for requests
|
||||
|
||||
# Authenticate using password
|
||||
if password and not token:
|
||||
stdout: Optional[str] = ""
|
||||
stderr: Optional[str] = ""
|
||||
result: Optional[str] = ""
|
||||
|
||||
|
||||
class JupyterCodeExecuter:
|
||||
"""
|
||||
Execute code in jupyter notebook
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
code: str,
|
||||
token: str = "",
|
||||
password: str = "",
|
||||
timeout: int = 60,
|
||||
):
|
||||
"""
|
||||
:param base_url: Jupyter server URL (e.g., "http://localhost:8888")
|
||||
:param code: Code to execute
|
||||
:param token: Jupyter authentication token (optional)
|
||||
:param password: Jupyter password (optional)
|
||||
:param timeout: WebSocket timeout in seconds (default: 60s)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.code = code
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.kernel_id = ""
|
||||
self.session = aiohttp.ClientSession(base_url=self.base_url)
|
||||
self.params = {}
|
||||
self.result = ResultModel()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.kernel_id:
|
||||
try:
|
||||
async with self.session.delete(
|
||||
f"/api/kernels/{self.kernel_id}", params=self.params
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
except Exception as err:
|
||||
logger.exception("close kernel failed, %s", err)
|
||||
await self.session.close()
|
||||
|
||||
async def run(self) -> ResultModel:
|
||||
try:
|
||||
login_url = urljoin(jupyter_url, "/login")
|
||||
response = session.get(login_url)
|
||||
await self.sign_in()
|
||||
await self.init_kernel()
|
||||
await self.execute_code()
|
||||
except Exception as err:
|
||||
logger.exception("execute code failed, %s", err)
|
||||
self.result.stderr = f"Error: {err}"
|
||||
return self.result
|
||||
|
||||
async def sign_in(self) -> None:
|
||||
# password authentication
|
||||
if self.password and not self.token:
|
||||
async with self.session.get("/login") as response:
|
||||
response.raise_for_status()
|
||||
xsrf_token = response.cookies["_xsrf"].value
|
||||
if not xsrf_token:
|
||||
raise ValueError("_xsrf token not found")
|
||||
self.session.cookie_jar.update_cookies(response.cookies)
|
||||
self.session.headers.update({"X-XSRFToken": xsrf_token})
|
||||
async with self.session.post(
|
||||
"/login",
|
||||
data={"_xsrf": xsrf_token, "password": self.password},
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
self.session.cookie_jar.update_cookies(response.cookies)
|
||||
|
||||
# token authentication
|
||||
if self.token:
|
||||
self.params.update({"token": self.token})
|
||||
|
||||
async def init_kernel(self) -> None:
|
||||
async with self.session.post(
|
||||
url="/api/kernels", params=self.params
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
xsrf_token = session.cookies.get("_xsrf")
|
||||
if not xsrf_token:
|
||||
raise ValueError("Failed to fetch _xsrf token")
|
||||
|
||||
login_data = {"_xsrf": xsrf_token, "password": password}
|
||||
login_response = session.post(
|
||||
login_url, data=login_data, cookies=session.cookies
|
||||
)
|
||||
login_response.raise_for_status()
|
||||
headers["X-XSRFToken"] = xsrf_token
|
||||
except Exception as e:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"Authentication Error: {str(e)}",
|
||||
"result": "",
|
||||
}
|
||||
|
||||
# Construct API URLs with authentication token if provided
|
||||
params = f"?token={token}" if token else ""
|
||||
kernel_url = urljoin(jupyter_url, f"/api/kernels{params}")
|
||||
|
||||
try:
|
||||
response = session.post(kernel_url, headers=headers, cookies=session.cookies)
|
||||
response.raise_for_status()
|
||||
kernel_id = response.json()["id"]
|
||||
|
||||
websocket_url = urljoin(
|
||||
jupyter_url.replace("http", "ws"),
|
||||
f"/api/kernels/{kernel_id}/channels{params}",
|
||||
)
|
||||
kernel_data = await response.json()
|
||||
self.kernel_id = kernel_data["id"]
|
||||
|
||||
def init_ws(self) -> (str, dict):
|
||||
ws_base = self.base_url.replace("http", "ws")
|
||||
ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()])
|
||||
websocket_url = f"{ws_base}/api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}"
|
||||
ws_headers = {}
|
||||
if password and not token:
|
||||
ws_headers["X-XSRFToken"] = session.cookies.get("_xsrf")
|
||||
cookies = {name: value for name, value in session.cookies.items()}
|
||||
ws_headers["Cookie"] = "; ".join(
|
||||
[f"{name}={value}" for name, value in cookies.items()]
|
||||
)
|
||||
if self.password and not self.token:
|
||||
ws_headers = {
|
||||
"Cookie": "; ".join(
|
||||
[
|
||||
f"{cookie.key}={cookie.value}"
|
||||
for cookie in self.session.cookie_jar
|
||||
]
|
||||
),
|
||||
**self.session.headers,
|
||||
}
|
||||
return websocket_url, ws_headers
|
||||
|
||||
async def execute_code(self) -> None:
|
||||
# initialize ws
|
||||
websocket_url, ws_headers = self.init_ws()
|
||||
# execute
|
||||
async with websockets.connect(
|
||||
websocket_url, additional_headers=ws_headers
|
||||
) as ws:
|
||||
msg_id = str(uuid.uuid4())
|
||||
execute_request = {
|
||||
"header": {
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
"username": "user",
|
||||
"session": str(uuid.uuid4()),
|
||||
"date": "",
|
||||
"version": "5.3",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"content": {
|
||||
"code": code,
|
||||
"silent": False,
|
||||
"store_history": True,
|
||||
"user_expressions": {},
|
||||
"allow_stdin": False,
|
||||
"stop_on_error": True,
|
||||
},
|
||||
"channel": "shell",
|
||||
}
|
||||
await ws.send(json.dumps(execute_request))
|
||||
await self.execute_in_jupyter(ws)
|
||||
|
||||
stdout, stderr, result = "", "", []
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(ws.recv(), timeout)
|
||||
message_data = json.loads(message)
|
||||
if message_data.get("parent_header", {}).get("msg_id") == msg_id:
|
||||
msg_type = message_data.get("msg_type")
|
||||
|
||||
if msg_type == "stream":
|
||||
if message_data["content"]["name"] == "stdout":
|
||||
stdout += message_data["content"]["text"]
|
||||
elif message_data["content"]["name"] == "stderr":
|
||||
stderr += message_data["content"]["text"]
|
||||
|
||||
elif msg_type in ("execute_result", "display_data"):
|
||||
data = message_data["content"]["data"]
|
||||
if "image/png" in data:
|
||||
result.append(
|
||||
f"data:image/png;base64,{data['image/png']}"
|
||||
)
|
||||
elif "text/plain" in data:
|
||||
result.append(data["text/plain"])
|
||||
|
||||
elif msg_type == "error":
|
||||
stderr += "\n".join(message_data["content"]["traceback"])
|
||||
|
||||
elif (
|
||||
msg_type == "status"
|
||||
and message_data["content"]["execution_state"] == "idle"
|
||||
):
|
||||
async def execute_in_jupyter(self, ws) -> None:
|
||||
# send message
|
||||
msg_id = uuid.uuid4().hex
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"header": {
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
"username": "user",
|
||||
"session": uuid.uuid4().hex,
|
||||
"date": "",
|
||||
"version": "5.3",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"content": {
|
||||
"code": self.code,
|
||||
"silent": False,
|
||||
"store_history": True,
|
||||
"user_expressions": {},
|
||||
"allow_stdin": False,
|
||||
"stop_on_error": True,
|
||||
},
|
||||
"channel": "shell",
|
||||
}
|
||||
)
|
||||
)
|
||||
# parse message
|
||||
stdout, stderr, result = "", "", []
|
||||
while True:
|
||||
try:
|
||||
# wait for message
|
||||
message = await asyncio.wait_for(ws.recv(), self.timeout)
|
||||
message_data = json.loads(message)
|
||||
# msg id not match, skip
|
||||
if message_data.get("parent_header", {}).get("msg_id") != msg_id:
|
||||
continue
|
||||
# check message type
|
||||
msg_type = message_data.get("msg_type")
|
||||
match msg_type:
|
||||
case "stream":
|
||||
if message_data["content"]["name"] == "stdout":
|
||||
stdout += message_data["content"]["text"]
|
||||
elif message_data["content"]["name"] == "stderr":
|
||||
stderr += message_data["content"]["text"]
|
||||
case "execute_result" | "display_data":
|
||||
data = message_data["content"]["data"]
|
||||
if "image/png" in data:
|
||||
result.append(f"data:image/png;base64,{data['image/png']}")
|
||||
elif "text/plain" in data:
|
||||
result.append(data["text/plain"])
|
||||
case "error":
|
||||
stderr += "\n".join(message_data["content"]["traceback"])
|
||||
case "status":
|
||||
if message_data["content"]["execution_state"] == "idle":
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
stderr += "\nExecution timed out."
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
stderr += "\nExecution timed out."
|
||||
break
|
||||
self.result.stdout = stdout.strip()
|
||||
self.result.stderr = stderr.strip()
|
||||
self.result.result = "\n".join(result).strip() if result else ""
|
||||
|
||||
except Exception as e:
|
||||
return {"stdout": "", "stderr": f"Error: {str(e)}", "result": ""}
|
||||
|
||||
finally:
|
||||
if kernel_id:
|
||||
requests.delete(
|
||||
f"{kernel_url}/{kernel_id}", headers=headers, cookies=session.cookies
|
||||
)
|
||||
|
||||
return {
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"result": "\n".join(result).strip() if result else "",
|
||||
}
|
||||
async def execute_code_jupyter(
|
||||
base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60
|
||||
) -> dict:
|
||||
async with JupyterCodeExecuter(
|
||||
base_url, code, token, password, timeout
|
||||
) as executor:
|
||||
result = await executor.run()
|
||||
return result.model_dump()
|
||||
|
||||
@@ -9,7 +9,7 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
def get_sorted_filter_ids(model):
|
||||
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"):
|
||||
@@ -33,12 +33,13 @@ def get_sorted_filter_ids(model):
|
||||
|
||||
|
||||
async def process_filter_functions(
|
||||
request, filter_ids, filter_type, form_data, extra_params
|
||||
request, filter_functions, filter_type, form_data, extra_params
|
||||
):
|
||||
skip_files = None
|
||||
|
||||
for filter_id in filter_ids:
|
||||
filter = Functions.get_function_by_id(filter_id)
|
||||
for function in filter_functions:
|
||||
filter = function
|
||||
filter_id = function.id
|
||||
if not filter:
|
||||
continue
|
||||
|
||||
@@ -48,6 +49,11 @@ async def process_filter_functions(
|
||||
function_module, _, _ = load_function_module_by_id(filter_id)
|
||||
request.app.state.FUNCTIONS[filter_id] = function_module
|
||||
|
||||
# Prepare handler function
|
||||
handler = getattr(function_module, filter_type, None)
|
||||
if not handler:
|
||||
continue
|
||||
|
||||
# Check if the function has a file_handler variable
|
||||
if filter_type == "inlet" and hasattr(function_module, "file_handler"):
|
||||
skip_files = function_module.file_handler
|
||||
@@ -59,11 +65,6 @@ async def process_filter_functions(
|
||||
**(valves if valves else {})
|
||||
)
|
||||
|
||||
# Prepare handler function
|
||||
handler = getattr(function_module, filter_type, None)
|
||||
if not handler:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Prepare parameters
|
||||
sig = inspect.signature(handler)
|
||||
@@ -100,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, {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -68,6 +66,7 @@ from open_webui.utils.misc import (
|
||||
get_last_user_message,
|
||||
get_last_assistant_message,
|
||||
prepend_to_first_user_message_content,
|
||||
convert_logit_bias_input_to_json,
|
||||
)
|
||||
from open_webui.utils.tools import get_tools
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
@@ -99,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
|
||||
@@ -134,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,
|
||||
@@ -155,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
|
||||
)
|
||||
@@ -188,34 +189,63 @@ 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)
|
||||
|
||||
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("toolkit_id", "")
|
||||
if tool.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],
|
||||
"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}"
|
||||
)
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -224,16 +254,20 @@ async def chat_completion_tools_handler(
|
||||
sources.append(
|
||||
{
|
||||
"source": {},
|
||||
"document": [tool_output],
|
||||
"document": [tool_result],
|
||||
"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("file_handler", False):
|
||||
skip_files = True
|
||||
|
||||
# check if "tool_calls" in result
|
||||
@@ -244,10 +278,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}")
|
||||
@@ -561,11 +595,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,
|
||||
@@ -610,11 +645,18 @@ def apply_params_to_form_data(form_data, model):
|
||||
|
||||
if "reasoning_effort" in params:
|
||||
form_data["reasoning_effort"] = params["reasoning_effort"]
|
||||
if "logit_bias" in params:
|
||||
try:
|
||||
form_data["logit_bias"] = json.loads(
|
||||
convert_logit_bias_input_to_json(params["logit_bias"])
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error parsing logit_bias: {e}")
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
async def process_chat_payload(request, form_data, metadata, user, model):
|
||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
form_data = apply_params_to_form_data(form_data, model)
|
||||
log.debug(f"form_data: {form_data}")
|
||||
@@ -707,9 +749,14 @@ async def process_chat_payload(request, form_data, metadata, user, model):
|
||||
raise e
|
||||
|
||||
try:
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(model)
|
||||
]
|
||||
|
||||
form_data, flags = await process_filter_functions(
|
||||
request=request,
|
||||
filter_ids=get_sorted_filter_ids(model),
|
||||
filter_functions=filter_functions,
|
||||
filter_type="inlet",
|
||||
form_data=form_data,
|
||||
extra_params=extra_params,
|
||||
@@ -753,12 +800,18 @@ async def process_chat_payload(request, form_data, metadata, user, 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,
|
||||
@@ -769,20 +822,31 @@ async def process_chat_payload(request, form_data, metadata, user, 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", []))
|
||||
|
||||
@@ -801,7 +865,7 @@ async def process_chat_payload(request, form_data, metadata, user, 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><source_id>{source_idx + 1}</source_id><source_context>{doc_context}</source_context></source>\n"
|
||||
|
||||
context_string = context_string.strip()
|
||||
prompt = get_last_user_message(form_data["messages"])
|
||||
@@ -856,7 +920,7 @@ async def process_chat_payload(request, form_data, metadata, user, model):
|
||||
|
||||
|
||||
async def process_chat_response(
|
||||
request, response, form_data, user, events, metadata, tasks
|
||||
request, response, form_data, user, metadata, model, events, tasks
|
||||
):
|
||||
async def background_tasks_handler():
|
||||
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
|
||||
@@ -978,6 +1042,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"],
|
||||
@@ -987,7 +1061,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:
|
||||
@@ -1061,9 +1136,12 @@ async def process_chat_response(
|
||||
},
|
||||
"__metadata__": metadata,
|
||||
"__request__": request,
|
||||
"__model__": metadata.get("model"),
|
||||
"__model__": model,
|
||||
}
|
||||
filter_ids = get_sorted_filter_ids(form_data.get("model"))
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(model)
|
||||
]
|
||||
|
||||
# Streaming response
|
||||
if event_emitter and event_caller:
|
||||
@@ -1103,36 +1181,51 @@ 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
|
||||
for result in results:
|
||||
if tool_call_id == result.get("tool_call_id", ""):
|
||||
tool_result = result.get("content", 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))}">\n<summary>Tool Executed</summary>\n</details>'
|
||||
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(
|
||||
@@ -1470,7 +1563,7 @@ async def process_chat_response(
|
||||
|
||||
data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_ids=filter_ids,
|
||||
filter_functions=filter_functions,
|
||||
filter_type="stream",
|
||||
form_data=data,
|
||||
extra_params=extra_params,
|
||||
@@ -1489,6 +1582,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(
|
||||
@@ -1544,9 +1647,61 @@ async def process_chat_response(
|
||||
|
||||
value = delta.get("content")
|
||||
|
||||
if value:
|
||||
content = f"{content}{value}"
|
||||
reasoning_content = delta.get(
|
||||
"reasoning_content"
|
||||
) or delta.get("reasoning")
|
||||
if reasoning_content:
|
||||
if (
|
||||
not content_blocks
|
||||
or content_blocks[-1]["type"] != "reasoning"
|
||||
):
|
||||
reasoning_block = {
|
||||
"type": "reasoning",
|
||||
"start_tag": "think",
|
||||
"end_tag": "/think",
|
||||
"attributes": {
|
||||
"type": "reasoning_content"
|
||||
},
|
||||
"content": "",
|
||||
"started_at": time.time(),
|
||||
}
|
||||
content_blocks.append(reasoning_block)
|
||||
else:
|
||||
reasoning_block = content_blocks[-1]
|
||||
|
||||
reasoning_block["content"] += reasoning_content
|
||||
|
||||
data = {
|
||||
"content": serialize_content_blocks(
|
||||
content_blocks
|
||||
)
|
||||
}
|
||||
|
||||
if value:
|
||||
if (
|
||||
content_blocks
|
||||
and content_blocks[-1]["type"]
|
||||
== "reasoning"
|
||||
and content_blocks[-1]
|
||||
.get("attributes", {})
|
||||
.get("type")
|
||||
== "reasoning_content"
|
||||
):
|
||||
reasoning_block = content_blocks[-1]
|
||||
reasoning_block["ended_at"] = time.time()
|
||||
reasoning_block["duration"] = int(
|
||||
reasoning_block["ended_at"]
|
||||
- reasoning_block["started_at"]
|
||||
)
|
||||
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "text",
|
||||
"content": "",
|
||||
}
|
||||
)
|
||||
|
||||
content = f"{content}{value}"
|
||||
if not content_blocks:
|
||||
content_blocks.append(
|
||||
{
|
||||
@@ -1689,6 +1844,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
|
||||
|
||||
@@ -1697,21 +1861,48 @@ 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)
|
||||
|
||||
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,
|
||||
@@ -1914,11 +2105,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,
|
||||
@@ -2017,7 +2203,7 @@ async def process_chat_response(
|
||||
for event in events:
|
||||
event, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_ids=filter_ids,
|
||||
filter_functions=filter_functions,
|
||||
filter_type="stream",
|
||||
form_data=event,
|
||||
extra_params=extra_params,
|
||||
@@ -2029,7 +2215,7 @@ async def process_chat_response(
|
||||
async for data in original_generator:
|
||||
data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_ids=filter_ids,
|
||||
filter_functions=filter_functions,
|
||||
filter_type="stream",
|
||||
form_data=data,
|
||||
extra_params=extra_params,
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
import json
|
||||
|
||||
|
||||
import collections.abc
|
||||
@@ -450,3 +451,15 @@ def parse_ollama_modelfile(model_text):
|
||||
data["params"]["messages"] = messages
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def convert_logit_bias_input_to_json(user_input):
|
||||
logit_bias_pairs = user_input.split(",")
|
||||
logit_bias_json = {}
|
||||
for pair in logit_bias_pairs:
|
||||
token, bias = pair.split(":")
|
||||
token = str(token.strip())
|
||||
bias = int(bias.strip())
|
||||
bias = 100 if bias > 100 else -100 if bias < -100 else bias
|
||||
logit_bias_json[token] = bias
|
||||
return json.dumps(logit_bias_json)
|
||||
|
||||
@@ -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"]
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
@@ -234,7 +237,7 @@ class OAuthManager:
|
||||
log.warning(f"OAuth callback error: {e}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
user_data: UserInfo = token.get("userinfo")
|
||||
if not user_data or "email" not in user_data:
|
||||
if not user_data or auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data:
|
||||
user_data: UserInfo = await client.userinfo(token=token)
|
||||
if not user_data:
|
||||
log.warning(f"OAuth callback failed, user data is missing: {token}")
|
||||
|
||||
@@ -62,6 +62,8 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
|
||||
"reasoning_effort": str,
|
||||
"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)
|
||||
|
||||
@@ -109,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)
|
||||
|
||||
|
||||
@@ -230,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", {})
|
||||
@@ -239,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
|
||||
|
||||
@@ -110,7 +110,7 @@ class PDFGenerator:
|
||||
# When running using `pip install -e .` the static directory is in the site packages.
|
||||
# This path only works if `open-webui serve` is run from the root of this project.
|
||||
if not FONTS_DIR.exists():
|
||||
FONTS_DIR = Path("./backend/static/fonts")
|
||||
FONTS_DIR = Path(".") / "backend" / "static" / "fonts"
|
||||
|
||||
pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf")
|
||||
pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -104,7 +104,7 @@ def replace_prompt_variable(template: str, prompt: str) -> str:
|
||||
|
||||
|
||||
def replace_messages_variable(
|
||||
template: str, messages: Optional[list[str]] = None
|
||||
template: str, messages: Optional[list[dict]] = None
|
||||
) -> str:
|
||||
def replacement_function(match):
|
||||
full_match = match.group(0)
|
||||
|
||||
@@ -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()
|
||||
@@ -1,6 +1,9 @@
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import inspect
|
||||
import uuid
|
||||
|
||||
from typing import Any, Awaitable, Callable, get_type_hints
|
||||
from functools import update_wrapper, partial
|
||||
|
||||
@@ -88,10 +91,11 @@ def get_tools(
|
||||
|
||||
# TODO: This needs to be a pydantic model
|
||||
tool_dict = {
|
||||
"toolkit_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
"callable": callable,
|
||||
"toolkit_id": tool_id,
|
||||
"pydantic_model": function_to_pydantic_model(callable),
|
||||
# Misc info
|
||||
"file_handler": hasattr(module, "file_handler") and module.file_handler,
|
||||
"citation": hasattr(module, "citation") and module.citation,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fastapi==0.115.7
|
||||
uvicorn[standard]==0.30.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic==2.10.6
|
||||
python-multipart==0.0.18
|
||||
|
||||
@@ -13,14 +13,14 @@ async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
|
||||
sqlalchemy==2.0.32
|
||||
sqlalchemy==2.0.38
|
||||
alembic==1.14.0
|
||||
peewee==3.17.8
|
||||
peewee==3.17.9
|
||||
peewee-migrate==1.12.2
|
||||
psycopg2-binary==2.9.9
|
||||
pgvector==0.3.5
|
||||
PyMySQL==1.1.1
|
||||
bcrypt==4.2.0
|
||||
bcrypt==4.3.0
|
||||
|
||||
pymongo
|
||||
redis
|
||||
@@ -37,18 +37,20 @@ asgiref==3.8.1
|
||||
# AI libraries
|
||||
openai
|
||||
anthropic
|
||||
google-generativeai==0.7.2
|
||||
google-generativeai==0.8.4
|
||||
tiktoken
|
||||
|
||||
langchain==0.3.7
|
||||
langchain-community==0.3.7
|
||||
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
|
||||
opensearch-py==2.8.0
|
||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||
elasticsearch==8.17.1
|
||||
|
||||
|
||||
transformers
|
||||
sentence-transformers==3.3.1
|
||||
@@ -76,6 +78,7 @@ 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
|
||||
@@ -85,7 +88,7 @@ faster-whisper==1.1.1
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.4.1
|
||||
|
||||
black==24.8.0
|
||||
black==25.1.0
|
||||
langfuse==2.44.0
|
||||
youtube-transcript-api==0.6.3
|
||||
pytube==15.0.0
|
||||
@@ -116,3 +119,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
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.5.18",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/language-data": "^6.5.1",
|
||||
@@ -79,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",
|
||||
@@ -101,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",
|
||||
|
||||
@@ -7,7 +7,7 @@ authors = [
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastapi==0.115.7",
|
||||
"uvicorn[standard]==0.30.6",
|
||||
"uvicorn[standard]==0.34.0",
|
||||
"pydantic==2.10.6",
|
||||
"python-multipart==0.0.18",
|
||||
|
||||
@@ -21,14 +21,14 @@ dependencies = [
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
|
||||
"sqlalchemy==2.0.32",
|
||||
"sqlalchemy==2.0.38",
|
||||
"alembic==1.14.0",
|
||||
"peewee==3.17.8",
|
||||
"peewee==3.17.9",
|
||||
"peewee-migrate==1.12.2",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pgvector==0.3.5",
|
||||
"PyMySQL==1.1.1",
|
||||
"bcrypt==4.2.0",
|
||||
"bcrypt==4.3.0",
|
||||
|
||||
"pymongo",
|
||||
"redis",
|
||||
@@ -48,15 +48,16 @@ dependencies = [
|
||||
"google-generativeai==0.7.2",
|
||||
"tiktoken",
|
||||
|
||||
"langchain==0.3.7",
|
||||
"langchain-community==0.3.7",
|
||||
"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",
|
||||
"opensearch-py==2.8.0",
|
||||
"playwright==1.49.1",
|
||||
"elasticsearch==8.17.1",
|
||||
|
||||
"transformers",
|
||||
"sentence-transformers==3.3.1",
|
||||
@@ -83,6 +84,7 @@ 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",
|
||||
@@ -92,7 +94,7 @@ dependencies = [
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.4.1",
|
||||
|
||||
"black==24.8.0",
|
||||
"black==25.1.0",
|
||||
"langfuse==2.44.0",
|
||||
"youtube-transcript-api==0.6.3",
|
||||
"pytube==15.0.0",
|
||||
|
||||
@@ -106,7 +106,7 @@ li p {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.4rem;
|
||||
height: 0.8rem;
|
||||
width: 0.4rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/static/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
@@ -74,6 +76,28 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setSplashImage() {
|
||||
const logo = document.getElementById('logo');
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
|
||||
if (isDarkMode) {
|
||||
const darkImage = new Image();
|
||||
darkImage.src = '/static/splash-dark.png';
|
||||
|
||||
darkImage.onload = () => {
|
||||
logo.src = '/static/splash-dark.png';
|
||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
||||
};
|
||||
|
||||
darkImage.onerror = () => {
|
||||
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Runs after classes are assigned
|
||||
window.onload = setSplashImage;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -176,10 +200,6 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
html.dark #splash-screen img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
html.her #splash-screen {
|
||||
background: #983724;
|
||||
}
|
||||
|
||||
@@ -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,179 @@ 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}/openapi.json`, {
|
||||
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).catch((err) => {
|
||||
toast.error(
|
||||
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
|
||||
URL: server?.url
|
||||
})
|
||||
);
|
||||
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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<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';
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
export let onDelete: Function = () => {};
|
||||
|
||||
export let show = false;
|
||||
export let edit = false;
|
||||
|
||||
export let connection = null;
|
||||
|
||||
let url = '';
|
||||
let key = '';
|
||||
let enable = true;
|
||||
|
||||
let loading = false;
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
const connection = {
|
||||
url,
|
||||
key,
|
||||
config: {
|
||||
enable: enable
|
||||
}
|
||||
};
|
||||
|
||||
await onSubmit(connection);
|
||||
|
||||
loading = false;
|
||||
show = false;
|
||||
|
||||
url = '';
|
||||
key = '';
|
||||
enable = true;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
if (connection) {
|
||||
url = connection.url;
|
||||
key = connection.key;
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
}
|
||||
};
|
||||
|
||||
$: 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=" mb-0.5 text-xs text-gray-500">{$i18n.t('URL')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('API Base URL')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col shrink-0 self-end">
|
||||
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch bind:state={enable} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}/openapi.json"`, {
|
||||
url: url
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Key')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
@@ -10,6 +10,32 @@
|
||||
|
||||
export let show = true;
|
||||
export let getStartedHandler = () => {};
|
||||
|
||||
function setLogoImage() {
|
||||
const logo = document.getElementById('logo');
|
||||
|
||||
if (logo) {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
|
||||
if (isDarkMode) {
|
||||
const darkImage = new Image();
|
||||
darkImage.src = '/static/favicon-dark.png';
|
||||
|
||||
darkImage.onload = () => {
|
||||
logo.src = '/static/favicon-dark.png';
|
||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
||||
};
|
||||
|
||||
darkImage.onerror = () => {
|
||||
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if (show) {
|
||||
setLogoImage();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
@@ -18,6 +44,7 @@
|
||||
<div class="flex space-x-2">
|
||||
<div class=" self-center">
|
||||
<img
|
||||
id="logo"
|
||||
crossorigin="anonymous"
|
||||
src="{WEBUI_BASE_URL}/static/favicon.png"
|
||||
class=" w-6 rounded-full"
|
||||
|
||||
@@ -71,7 +71,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 +95,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 +121,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 +136,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 ===
|
||||
'documents'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
@@ -166,7 +166,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 +190,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 +216,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 +242,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 +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 ===
|
||||
'images'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
@@ -295,7 +295,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 +325,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'}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
let contentExtractionEngine = 'default';
|
||||
let tikaServerUrl = '';
|
||||
let showTikaServerUrl = false;
|
||||
let doclingServerUrl = '';
|
||||
let showDoclingServerUrl = false;
|
||||
let documentIntelligenceEndpoint = '';
|
||||
let documentIntelligenceKey = '';
|
||||
let showDocumentIntelligenceConfig = false;
|
||||
@@ -74,6 +76,7 @@
|
||||
template: '',
|
||||
r: 0.0,
|
||||
k: 4,
|
||||
k_reranker: 4,
|
||||
hybrid: false
|
||||
};
|
||||
|
||||
@@ -175,6 +178,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 === '')
|
||||
@@ -209,6 +216,7 @@
|
||||
content_extraction: {
|
||||
engine: contentExtractionEngine,
|
||||
tika_server_url: tikaServerUrl,
|
||||
docling_server_url: doclingServerUrl,
|
||||
document_intelligence_config: {
|
||||
key: documentIntelligenceKey,
|
||||
endpoint: documentIntelligenceEndpoint
|
||||
@@ -269,7 +277,10 @@
|
||||
|
||||
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';
|
||||
@@ -337,6 +348,7 @@
|
||||
>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
@@ -351,6 +363,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
|
||||
@@ -387,8 +407,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 +643,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 +651,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">
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
|
||||
import {
|
||||
getAdminConfig,
|
||||
@@ -220,15 +222,44 @@
|
||||
<div class="">
|
||||
{$i18n.t('License')}
|
||||
</div>
|
||||
<a
|
||||
class=" text-xs text-gray-500 hover:underline"
|
||||
href="https://docs.openwebui.com/enterprise"
|
||||
target="_blank"
|
||||
>
|
||||
{$i18n.t(
|
||||
'Upgrade to a licensed plan for enhanced capabilities, including custom theming and branding, and dedicated support.'
|
||||
)}
|
||||
</a>
|
||||
|
||||
{#if $config?.license_metadata}
|
||||
<a
|
||||
href="https://docs.openwebui.com/enterprise"
|
||||
target="_blank"
|
||||
class="text-gray-500 mt-0.5"
|
||||
>
|
||||
<span class=" capitalize text-black dark:text-white"
|
||||
>{$config?.license_metadata?.type}
|
||||
license</span
|
||||
>
|
||||
registered to
|
||||
<span class=" capitalize text-black dark:text-white"
|
||||
>{$config?.license_metadata?.organization_name}</span
|
||||
>
|
||||
for
|
||||
<span class=" font-medium text-black dark:text-white"
|
||||
>{$config?.license_metadata?.seats ?? 'Unlimited'} users.</span
|
||||
>
|
||||
</a>
|
||||
{#if $config?.license_metadata?.html}
|
||||
<div class="mt-0.5">
|
||||
{@html DOMPurify.sanitize($config?.license_metadata?.html)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
class=" text-xs hover:underline"
|
||||
href="https://docs.openwebui.com/enterprise"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="text-gray-500">
|
||||
{$i18n.t(
|
||||
'Upgrade to a licensed plan for enhanced capabilities, including custom theming and branding, and dedicated support.'
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <button
|
||||
@@ -523,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}
|
||||
/>
|
||||
@@ -579,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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -29,7 +29,8 @@
|
||||
'tavily',
|
||||
'jina',
|
||||
'bing',
|
||||
'exa'
|
||||
'exa',
|
||||
'perplexity'
|
||||
];
|
||||
|
||||
let youtubeLanguage = 'en';
|
||||
@@ -361,6 +362,17 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.search.engine === 'perplexity'}
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Perplexity API Key')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Perplexity API Key')}
|
||||
bind:value={webConfig.search.perplexity_api_key}
|
||||
/>
|
||||
</div>
|
||||
{:else if webConfig.search.engine === 'bing'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
@@ -450,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>
|
||||
|
||||
@@ -52,12 +52,19 @@
|
||||
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: true
|
||||
},
|
||||
features: {
|
||||
web_search: 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 = '';
|
||||
@@ -88,6 +90,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 +273,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
|
||||
|
||||