Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8920bf2377 | ||
|
|
b55a38ee97 | ||
|
|
6c39dc764d | ||
|
|
cde5879987 | ||
|
|
dddd1e44f3 | ||
|
|
60f62c2f59 | ||
|
|
a7267b5914 | ||
|
|
c01255570b | ||
|
|
caf0a1fbb6 | ||
|
|
72cd3a54f7 | ||
|
|
c96252f7fe | ||
|
|
1d83bc15de | ||
|
|
b8159fc43e | ||
|
|
09502c6664 | ||
|
|
db1730f47b | ||
|
|
70ab38194f | ||
|
|
867fcc4d95 | ||
|
|
d88c6f81ee | ||
|
|
0cfc8f5256 | ||
|
|
59f9a4cdf5 | ||
|
|
382299d549 | ||
|
|
df433f852f | ||
|
|
980019bb50 | ||
|
|
6549fc839f | ||
|
|
365756c9a2 | ||
|
|
03d5d00d28 | ||
|
|
051765e77a | ||
|
|
001775d6c3 | ||
|
|
2ceb769057 | ||
|
|
9f9f1a1517 | ||
|
|
71c672c90f | ||
|
|
1d0881e283 | ||
|
|
fd2b9cfd61 | ||
|
|
c1f37d9aed | ||
|
|
4fe97d8794 | ||
|
|
9738ddfd99 | ||
|
|
9b3d71f0d2 | ||
|
|
779db74d7e | ||
|
|
1077b2ac8b | ||
|
|
c3ea4feca5 | ||
|
|
9321a6ff60 | ||
|
|
26764a0b92 | ||
|
|
aef1e06f0b | ||
|
|
b27243df81 | ||
|
|
bbd1d2b58c | ||
|
|
99bba12de2 | ||
|
|
f0856bcb46 | ||
|
|
b34b200682 | ||
|
|
a7fd76df41 | ||
|
|
c870b2ea29 | ||
|
|
71f99228ef | ||
|
|
f0bd74b75b | ||
|
|
69646eb1de | ||
|
|
8edfd29102 | ||
|
|
d8ad384b06 | ||
|
|
10e39956ef | ||
|
|
4a971eeaee | ||
|
|
767acd1789 | ||
|
|
a3dd678bbd | ||
|
|
8bf0b40fdd | ||
|
|
3e58413268 | ||
|
|
b1e5bc8e49 | ||
|
|
2b1ee8b0dc | ||
|
|
2a234829f5 | ||
|
|
d46b7b8f1b | ||
|
|
32015c392d | ||
|
|
c8780a7f93 | ||
|
|
0f04227c34 | ||
|
|
b14617a653 | ||
|
|
e66e0526ed | ||
|
|
fed5615c19 | ||
|
|
f68d11ec52 | ||
|
|
034163e9f9 | ||
|
|
77358031f5 | ||
|
|
e1e3009a30 | ||
|
|
76d358bd6c | ||
|
|
c2f98a4cd2 | ||
|
|
83699cf3ef | ||
|
|
5ba0b52cf3 | ||
|
|
40b6e6802f | ||
|
|
a341b9a4ea | ||
|
|
db05e34736 | ||
|
|
072de1fb7e | ||
|
|
d041d58bb6 | ||
|
|
3ec1efb6e0 | ||
|
|
3f27d9ada1 | ||
|
|
19e3214997 | ||
|
|
49c9b3199f | ||
|
|
c17f3bc82b | ||
|
|
8b8908fa93 | ||
|
|
45da209f85 | ||
|
|
ad58e6b297 | ||
|
|
4f72d5453f | ||
|
|
5eb26fe7ab | ||
|
|
e1386fe80b | ||
|
|
ac375fe3d2 | ||
|
|
218920f0c3 | ||
|
|
3d5699ef22 | ||
|
|
621e7679c4 | ||
|
|
c04e1787c5 | ||
|
|
e5bb366719 | ||
|
|
c0f3352b71 | ||
|
|
9701d127bf | ||
|
|
0e5320c39e | ||
|
|
c744ae0d63 | ||
|
|
596be451ec | ||
|
|
5434172e88 | ||
|
|
cf77a4276e | ||
|
|
adaa467f75 | ||
|
|
879778361d | ||
|
|
fb02ec52a5 | ||
|
|
2ca34217e6 | ||
|
|
9e50026107 | ||
|
|
67549dcadd | ||
|
|
308c277fd3 | ||
|
|
24971ffce0 | ||
|
|
b6a2ca877a | ||
|
|
e4c864de7e | ||
|
|
a35469c669 | ||
|
|
a51f0c30ec | ||
|
|
d5824b1b49 | ||
|
|
5afa42b0d9 | ||
|
|
48ed800d91 | ||
|
|
dd6f83e9c5 | ||
|
|
29f9d1d0c2 | ||
|
|
530460d711 | ||
|
|
3e65109900 | ||
|
|
db0379030e | ||
|
|
098f34f400 | ||
|
|
22e11760a1 | ||
|
|
cae7ad8c70 | ||
|
|
4e21e4ba0e | ||
|
|
e701c65db4 | ||
|
|
e61e7434a0 | ||
|
|
1ef8204359 | ||
|
|
025eef754f | ||
|
|
b775826a27 | ||
|
|
58d7ca35e3 | ||
|
|
aa8ab349ed | ||
|
|
c923461882 | ||
|
|
8753c2ff51 | ||
|
|
cc788eb4f9 | ||
|
|
b53ddfbd19 | ||
|
|
c03ca7270e | ||
|
|
eadec9e86e | ||
|
|
3ed0a6d11f | ||
|
|
caaaa07232 | ||
|
|
8802c6e8db | ||
|
|
a4ca9a981e | ||
|
|
f9fd22d98e | ||
|
|
031cf38655 | ||
|
|
f68d1ba394 | ||
|
|
02f7c3258b | ||
|
|
5c2db102d0 | ||
|
|
58a8cae3d7 | ||
|
|
ef56b14636 | ||
|
|
cd740f436d | ||
|
|
d4c628de09 | ||
|
|
9208a84185 | ||
|
|
49bb1255c5 | ||
|
|
7344bd3e66 | ||
|
|
7f41bb6118 | ||
|
|
74263c872c | ||
|
|
f3cd2ffb18 | ||
|
|
b623a9ea69 | ||
|
|
e097bbdf11 | ||
|
|
b1cba685f4 | ||
|
|
721ea9cbd8 | ||
|
|
6a67a2217c | ||
|
|
d7f43bfc1a | ||
|
|
384a53b339 | ||
|
|
9a55547827 | ||
|
|
3288b19795 | ||
|
|
56a8973fa0 | ||
|
|
680adea4e7 | ||
|
|
d6094c2881 | ||
|
|
f6b1b075e1 | ||
|
|
210197fd43 | ||
|
|
2227f24bd6 | ||
|
|
3f137f9bbc | ||
|
|
9f6872a8e1 | ||
|
|
c55f557828 | ||
|
|
3e1b14f570 | ||
|
|
f51a0b181f | ||
|
|
a4ce1ebf4f | ||
|
|
766e8bc022 | ||
|
|
5df9cad671 | ||
|
|
81cefd8541 | ||
|
|
06c1426e14 | ||
|
|
b2623c9799 | ||
|
|
153afd832c | ||
|
|
19e18bc461 | ||
|
|
06a6855f84 | ||
|
|
6b69c4da0f | ||
|
|
d973db829f | ||
|
|
4977e6d50f | ||
|
|
136972ccf0 | ||
|
|
96b8aaf83f | ||
|
|
097b5e4c0b | ||
|
|
ca853ca465 | ||
|
|
a68342d5a8 | ||
|
|
fe7b8d9617 | ||
|
|
042191372a | ||
|
|
fbf9b3f1bb | ||
|
|
cba2e44de4 | ||
|
|
ff8100dc6f | ||
|
|
a156033d3c | ||
|
|
b7f268f163 | ||
|
|
d5c236aa8e | ||
|
|
c9e69fb2f4 | ||
|
|
ee82439e67 | ||
|
|
fcd3c7a10f | ||
|
|
051b6daa82 | ||
|
|
6c0a5fa91c | ||
|
|
edc056351d | ||
|
|
7347f95b24 | ||
|
|
a6f25b99f9 | ||
|
|
2185fc61c0 | ||
|
|
bf3a140254 | ||
|
|
bbe6564bfd | ||
|
|
95674162f9 | ||
|
|
24f78fed21 | ||
|
|
cf72f5503f | ||
|
|
932f759410 | ||
|
|
d6668515f6 | ||
|
|
a05966af96 | ||
|
|
3802ab9fd1 | ||
|
|
171021cfa4 | ||
|
|
d3d7b209b2 | ||
|
|
6ddbb41aac | ||
|
|
2a9925fe18 | ||
|
|
81e401a520 | ||
|
|
ba432090c5 | ||
|
|
de634a7599 | ||
|
|
803daebfe4 | ||
|
|
958a76d448 | ||
|
|
2dd0eaad73 | ||
|
|
8339f59cdf | ||
|
|
c51a65173e | ||
|
|
d5cb65527e | ||
|
|
956cb7beaa | ||
|
|
4eb928693b | ||
|
|
d5b903234d | ||
|
|
86f191d015 | ||
|
|
b35b56394a | ||
|
|
23990276cb | ||
|
|
f2a09c7149 | ||
|
|
774c0056bd | ||
|
|
c299d3fd54 | ||
|
|
7d62ce5b19 | ||
|
|
5ad93b6fc9 | ||
|
|
e6a5618d13 | ||
|
|
d335851a1c | ||
|
|
8bc6a30c7b | ||
|
|
b7243e6efc | ||
|
|
40fab879ff | ||
|
|
5af4714bf0 | ||
|
|
edd65feff5 | ||
|
|
202eada74f | ||
|
|
d863fd3626 | ||
|
|
3ff8deeea8 | ||
|
|
041a89ef5c | ||
|
|
918f507d8c | ||
|
|
f7e85cd0bf | ||
|
|
4b4583df62 | ||
|
|
bcf76dfa74 | ||
|
|
485392fe63 | ||
|
|
cbb4684b16 | ||
|
|
0531ca6530 | ||
|
|
32cb9df3c4 | ||
|
|
71fd483fba | ||
|
|
63f38c584f | ||
|
|
77779b30d4 | ||
|
|
d0f338bb99 | ||
|
|
63ca0b8cba | ||
|
|
d0b20df46c | ||
|
|
0214c1e66c | ||
|
|
015342e669 | ||
|
|
0cd32b015b | ||
|
|
5f0d262c59 | ||
|
|
7fc3ac38ac | ||
|
|
a04e8ee84e | ||
|
|
3cce416cab | ||
|
|
f17d8b5d19 | ||
|
|
c881e033b2 | ||
|
|
3ae547f796 | ||
|
|
79e92378de | ||
|
|
53ecfc2b5d | ||
|
|
d783708745 | ||
|
|
218701e617 | ||
|
|
c6a46195c0 | ||
|
|
4a76bea80c | ||
|
|
fcebc82ec2 | ||
|
|
daa2a036f8 | ||
|
|
41f9a8caff | ||
|
|
6f6412dd16 | ||
|
|
2b2d123531 | ||
|
|
474df5e534 | ||
|
|
30d1dc2c60 | ||
|
|
4cea3a57be | ||
|
|
8a9f862701 | ||
|
|
001dab0439 | ||
|
|
b786d1e3f3 | ||
|
|
b5bb6ae177 | ||
|
|
f71834720e | ||
|
|
f11bdc6ab5 | ||
|
|
35c1c48fd2 | ||
|
|
fc11e4384f | ||
|
|
7693d0e2b0 | ||
|
|
217f4daef0 | ||
|
|
6d38ac41b6 | ||
|
|
91755309ce | ||
|
|
a28ca30519 | ||
|
|
e023a98f11 | ||
|
|
6dc0df2473 | ||
|
|
cd5e2be27b | ||
|
|
3d37e4a42d | ||
|
|
7f523de408 | ||
|
|
f2525ebc44 | ||
|
|
1cdb7aed1e | ||
|
|
16090bc272 | ||
|
|
0a85dd4bca | ||
|
|
33f04a7714 | ||
|
|
c9282135c4 | ||
|
|
3d6d050ad8 | ||
|
|
9368d0ac75 | ||
|
|
40e40d1ddd | ||
|
|
d9f97c832c | ||
|
|
4f2b821088 | ||
|
|
eb10ff2ae6 | ||
|
|
b70e910e4b | ||
|
|
955fc5c736 | ||
|
|
9aac148908 | ||
|
|
183ba83920 | ||
|
|
85cc067599 | ||
|
|
fe0665605a | ||
|
|
e593ea8935 | ||
|
|
e114a40bb8 | ||
|
|
eb64f7bdb3 | ||
|
|
1bc9711afd | ||
|
|
de48a32ea2 | ||
|
|
5b3eae3855 | ||
|
|
9764bf3b78 | ||
|
|
a74ec200b3 | ||
|
|
49e045ea3d | ||
|
|
c0a47169fa | ||
|
|
a140cfc521 | ||
|
|
ea2f97004b | ||
|
|
93132abb40 | ||
|
|
df0b1e5552 | ||
|
|
e5ea595425 | ||
|
|
a5b781f3d8 | ||
|
|
5128402282 | ||
|
|
11dea8795b | ||
|
|
b70c0f36c0 | ||
|
|
e42ee34672 | ||
|
|
71b6a942fe | ||
|
|
5bdd334b74 | ||
|
|
6d6794aa85 | ||
|
|
1588f42fe7 | ||
|
|
029db5c635 | ||
|
|
b85700f6ca | ||
|
|
fbbe1117ae | ||
|
|
df66e21472 | ||
|
|
51fc792501 | ||
|
|
7a166152d9 | ||
|
|
926954f93b | ||
|
|
5a596ae40c | ||
|
|
048f30aa97 | ||
|
|
66bf28cd85 | ||
|
|
bbe1167958 | ||
|
|
357a53eb6f | ||
|
|
37bf0087e5 | ||
|
|
e5829572ff | ||
|
|
4ca936f0bf | ||
|
|
0a351580f8 | ||
|
|
472b71f331 | ||
|
|
308f4d6b26 | ||
|
|
2d62796616 | ||
|
|
330bec67b7 | ||
|
|
2890c6d62d | ||
|
|
9119db001d | ||
|
|
c7faccd5fa | ||
|
|
eff06538a6 | ||
|
|
0503fbd2e3 | ||
|
|
03d1d2a88b | ||
|
|
c0b3db38a5 | ||
|
|
f588655f7f | ||
|
|
c45201a8a2 | ||
|
|
3ccbb46938 | ||
|
|
a980af3ca4 | ||
|
|
22c4ef4fb0 | ||
|
|
df0d29c81c | ||
|
|
bc6afc9057 | ||
|
|
182408a52e | ||
|
|
513cab94b1 | ||
|
|
d01439d144 | ||
|
|
0a535a6fed | ||
|
|
3a38173c66 | ||
|
|
adbaaa65f9 | ||
|
|
379592dae4 | ||
|
|
22ff239e3a | ||
|
|
e830b4959e | ||
|
|
1c1b32b390 | ||
|
|
e6daad2ab9 | ||
|
|
4f2e426fc7 | ||
|
|
609a6a3721 | ||
|
|
85153afda8 | ||
|
|
f56889c5c7 | ||
|
|
cef4028c1c | ||
|
|
4b97884fce | ||
|
|
9ac7f2635c | ||
|
|
2d6f68eb2d | ||
|
|
61e7aa2229 | ||
|
|
9f5df72d7c | ||
|
|
4dd9484b48 | ||
|
|
dbe36e841a | ||
|
|
201eaa9655 | ||
|
|
4cd550d1d0 | ||
|
|
94dae76d0f | ||
|
|
3e69f10af7 | ||
|
|
bcc0cbf895 | ||
|
|
0e0c1c4ca4 | ||
|
|
ed5d95f434 | ||
|
|
3d2fcef28f | ||
|
|
77b65ccbfb | ||
|
|
487979859a | ||
|
|
ac0243e8b7 | ||
|
|
5037197541 | ||
|
|
b45219c8b1 | ||
|
|
ebfc887869 | ||
|
|
719d115d49 | ||
|
|
4e7b0ea4b4 | ||
|
|
c2b4976c82 | ||
|
|
e0ab5adb97 | ||
|
|
b0f6f24ca8 | ||
|
|
61f530ff4b | ||
|
|
7df1a059d6 | ||
|
|
3e18187805 | ||
|
|
aa13ed885d | ||
|
|
2a27db2d3b | ||
|
|
89f005ece6 | ||
|
|
48afd424a3 | ||
|
|
39ae9167ee | ||
|
|
20b6902b9f | ||
|
|
08b958cfc9 | ||
|
|
c62f30e22c | ||
|
|
562710fe33 | ||
|
|
647e38f701 | ||
|
|
292cb62d4a | ||
|
|
32a303f392 | ||
|
|
1d1a83b754 | ||
|
|
1ca5ad47b1 | ||
|
|
3111d1bf61 | ||
|
|
d735b036fe | ||
|
|
9d80cc3b2d | ||
|
|
0ebe4f8f84 | ||
|
|
be373e9fd4 | ||
|
|
898826dc22 | ||
|
|
0bca4e230e | ||
|
|
ef5374a34e | ||
|
|
2407d9b905 | ||
|
|
25d9a728f4 | ||
|
|
103688936e | ||
|
|
ac73f97604 | ||
|
|
0d09571ca2 | ||
|
|
5b4409814b | ||
|
|
8cca648efa | ||
|
|
a8222e391b | ||
|
|
dca7ce9420 | ||
|
|
bcd5783752 | ||
|
|
12bd04d24d | ||
|
|
2bb6063dcb | ||
|
|
4d7c8b4a6a | ||
|
|
40617b9e0e | ||
|
|
52030a241c | ||
|
|
3d6605bbfd | ||
|
|
a60b0a108a | ||
|
|
48635ced35 | ||
|
|
f4dde86b36 | ||
|
|
fcc1e2729c | ||
|
|
e7c7c65227 | ||
|
|
b2d1aa3c6e | ||
|
|
24767bb0fa | ||
|
|
c000188728 | ||
|
|
db4adc0e89 | ||
|
|
cf08d34879 | ||
|
|
d2fdf6999b | ||
|
|
d9c8a2508f | ||
|
|
df2428b356 | ||
|
|
e39ce16a86 | ||
|
|
d67cf5d9f2 | ||
|
|
688bfac4ba | ||
|
|
7c2cb5899c | ||
|
|
b16ad094be | ||
|
|
d5715cec6f | ||
|
|
c7caa268ce | ||
|
|
de8e8af989 | ||
|
|
ddc88d615c | ||
|
|
7644d66902 | ||
|
|
177788540b | ||
|
|
65a9a7eae0 | ||
|
|
804340ee26 | ||
|
|
937f97d7d6 | ||
|
|
f6637cc78e | ||
|
|
29cd1bc739 | ||
|
|
31485835a7 | ||
|
|
e4b6855984 | ||
|
|
bbd3e38e25 | ||
|
|
b29d5b80cd | ||
|
|
edf7a3d142 | ||
|
|
8b425a6295 | ||
|
|
d42e3fce4a | ||
|
|
58cc57e8a4 | ||
|
|
d3a952877a | ||
|
|
07357afcf6 | ||
|
|
ceb4948a28 | ||
|
|
86a8eb1023 | ||
|
|
dced9e4094 | ||
|
|
803b2e35be | ||
|
|
23a9731899 | ||
|
|
4267e22d4a | ||
|
|
e3207f35d7 | ||
|
|
85264e76de | ||
|
|
1dc8056d84 | ||
|
|
853c14b4c2 | ||
|
|
01dba25466 | ||
|
|
9c3f54cf1c | ||
|
|
f4047eea77 | ||
|
|
0583847943 | ||
|
|
4b22aa819c | ||
|
|
630cea105e | ||
|
|
c61698efcf | ||
|
|
a37d411dcf | ||
|
|
eacec793e9 | ||
|
|
0e46f8091f | ||
|
|
7b9dc69362 | ||
|
|
0a16af4e96 | ||
|
|
24e696e341 | ||
|
|
0ea421ea20 | ||
|
|
3da6049a4a | ||
|
|
3839f18bb1 | ||
|
|
2b0e90c032 | ||
|
|
3a360e441e | ||
|
|
a463e58de6 | ||
|
|
7a59cc9186 | ||
|
|
ff6946c230 | ||
|
|
3ff773c199 | ||
|
|
8048d4ac39 | ||
|
|
8d7319441e | ||
|
|
d98a60fbbb | ||
|
|
093af754e7 | ||
|
|
fa1590df57 | ||
|
|
9e7ec0eb1e | ||
|
|
5051bfe7ab | ||
|
|
1db8dec4f5 | ||
|
|
dafedf5675 | ||
|
|
37a3de0703 | ||
|
|
72b25ab78b | ||
|
|
fbff4e19de | ||
|
|
2777bab148 | ||
|
|
4ec70e3d46 | ||
|
|
43b2eca418 | ||
|
|
99f7d1b62b | ||
|
|
62a5db7719 | ||
|
|
278a4edd0b | ||
|
|
279e3e970f | ||
|
|
88a5b5ff4c | ||
|
|
c159a1a120 | ||
|
|
1decefbe59 | ||
|
|
28157b4f8c | ||
|
|
e013df19f9 | ||
|
|
486f953ba2 | ||
|
|
407dc9a401 | ||
|
|
9fb2c9c86d | ||
|
|
60b8cfb9fa | ||
|
|
5a66f69460 | ||
|
|
578f116ebb | ||
|
|
d3b5ba8472 | ||
|
|
3e28c46026 | ||
|
|
71d7ba6c07 | ||
|
|
581c0f137e | ||
|
|
f2e78d7940 | ||
|
|
c821c3ecb0 | ||
|
|
b3de3295d6 | ||
|
|
0952772705 | ||
|
|
c0189e43ad | ||
|
|
8fad4fb264 | ||
|
|
e9cab818f8 | ||
|
|
3523cea8cc | ||
|
|
01002042a8 | ||
|
|
f81f3b3a02 | ||
|
|
99ee1a2702 | ||
|
|
7ede54ee0a | ||
|
|
e96d139180 | ||
|
|
e6da38464b | ||
|
|
1e9abe6d8c | ||
|
|
61af00d0a6 | ||
|
|
bb6864dd12 | ||
|
|
1404fd9fbd | ||
|
|
02479425a5 | ||
|
|
cd762db9bf | ||
|
|
6663fc3a6c | ||
|
|
67db71f140 | ||
|
|
aa94359f74 | ||
|
|
9726c0b7ee | ||
|
|
449f0c155b | ||
|
|
48f0992300 | ||
|
|
492bcf9d26 | ||
|
|
bbfe456f90 | ||
|
|
774140ae0f | ||
|
|
5590ad438f | ||
|
|
9eea1aff10 | ||
|
|
1a15a62b73 | ||
|
|
efcf6db687 | ||
|
|
66f00ce4bf | ||
|
|
d720e027ad | ||
|
|
f46837fd10 | ||
|
|
1477f14827 | ||
|
|
094a16ab49 | ||
|
|
86011e40be | ||
|
|
4451f86eb0 | ||
|
|
8f51c11bc2 | ||
|
|
01f438c3fe | ||
|
|
1f91e5d3c6 | ||
|
|
faf01bdeac | ||
|
|
bb21bf5239 | ||
|
|
919d65f36f | ||
|
|
19e82ace23 | ||
|
|
98159dbcae | ||
|
|
ba972ecd52 | ||
|
|
d2d23f4abd | ||
|
|
45180e829a | ||
|
|
cd02ff2e07 | ||
|
|
39fe385017 | ||
|
|
7452b87877 | ||
|
|
45d9a720b9 | ||
|
|
7fcc545672 | ||
|
|
5e1f4fa0ff | ||
|
|
efebf4d3a0 | ||
|
|
e304ca5bd5 | ||
|
|
8eb5807c5f | ||
|
|
7670543a39 | ||
|
|
ed926c8f26 | ||
|
|
b3a95f40fc | ||
|
|
96643f5b6d | ||
|
|
575db66295 | ||
|
|
47ec443728 | ||
|
|
d6f709574e | ||
|
|
4bc77b544e | ||
|
|
be93081bd7 | ||
|
|
70d0477418 | ||
|
|
f592748011 | ||
|
|
8a157578f4 | ||
|
|
8a1f321fd4 | ||
|
|
f97f21bf3a | ||
|
|
a651598d19 | ||
|
|
68d42ef850 | ||
|
|
979ec71522 | ||
|
|
6d0f757848 | ||
|
|
dc9445c4ec | ||
|
|
901c997054 | ||
|
|
99b88c3024 | ||
|
|
094a82b264 | ||
|
|
99604fd644 | ||
|
|
33ecc405cb | ||
|
|
ad49435a94 | ||
|
|
282a884fad | ||
|
|
d7363fd65f | ||
|
|
f23eb2a31c | ||
|
|
ccd2a0be5b | ||
|
|
47560d4d72 | ||
|
|
7f0c50f445 | ||
|
|
bed6aa63e1 | ||
|
|
6a109e972e | ||
|
|
2387877dae | ||
|
|
40f60c163d | ||
|
|
28c1de237b | ||
|
|
04bd09d027 | ||
|
|
35e8c77025 | ||
|
|
ffd9e9cbe7 | ||
|
|
8f5843c8bc | ||
|
|
397421ce0b | ||
|
|
0dc6220ae6 | ||
|
|
8ae99ad7bb | ||
|
|
fe4b7621b3 | ||
|
|
3da22af859 | ||
|
|
cb1445086e | ||
|
|
9ba424e18a | ||
|
|
dfc9412117 | ||
|
|
43aa23ea77 | ||
|
|
286896b941 | ||
|
|
b810868239 | ||
|
|
0b59aa940e | ||
|
|
f1c28455ad | ||
|
|
daa4b3284f | ||
|
|
72023f4d03 | ||
|
|
313c54a4d1 | ||
|
|
c82183f985 | ||
|
|
84fe62d557 | ||
|
|
43234c3c4d | ||
|
|
9e2d8470d4 | ||
|
|
057533f9cc | ||
|
|
e277dba8fe | ||
|
|
4525ac687b | ||
|
|
53f1caf91f | ||
|
|
7dd5c957cf | ||
|
|
a1e62ab422 | ||
|
|
1a42e96a3b | ||
|
|
13fe78428b | ||
|
|
50705f926e | ||
|
|
709258bbb5 | ||
|
|
d303f0351d | ||
|
|
82ed9b0a97 | ||
|
|
e13f1738d4 | ||
|
|
b43acc2b3d | ||
|
|
3e8d3b08fa | ||
|
|
c134de8799 | ||
|
|
ad98d4300b | ||
|
|
115231c0e5 | ||
|
|
72168b1586 | ||
|
|
93205d4320 | ||
|
|
64b6ac4c1f | ||
|
|
28faaa23c4 | ||
|
|
2ed9896dea | ||
|
|
84fd0e4152 | ||
|
|
7fb0228ac8 | ||
|
|
0845b7ffe5 | ||
|
|
55b6a82cd5 | ||
|
|
ff55ca4d75 | ||
|
|
2011e5711d | ||
|
|
e7d9755d97 | ||
|
|
20101579e0 | ||
|
|
8a745b9bbf | ||
|
|
2cb87a71f7 | ||
|
|
86a3e0a0b8 | ||
|
|
37b8ea3ab2 | ||
|
|
4cca1829c2 | ||
|
|
230e21b10c | ||
|
|
dd3cd4ac4f | ||
|
|
f0c2d47b24 | ||
|
|
5fa502e419 | ||
|
|
5a2fafceae | ||
|
|
b5ab9634e8 | ||
|
|
e289e02193 | ||
|
|
e966f54001 | ||
|
|
c11f02662f | ||
|
|
23575825a1 | ||
|
|
1e67035bd3 | ||
|
|
85077d198b | ||
|
|
7f17b31c12 | ||
|
|
01e54dae94 | ||
|
|
47cde38369 | ||
|
|
4616997e6a | ||
|
|
1aba7accbf | ||
|
|
32753326fa | ||
|
|
966e244e33 | ||
|
|
0c5950fb60 | ||
|
|
1d3c656286 | ||
|
|
74b1c80132 | ||
|
|
7aa41d5d0c | ||
|
|
4c30f22444 | ||
|
|
6af94ae8f3 | ||
|
|
2625cd8719 | ||
|
|
f91da291d9 | ||
|
|
5543f30c49 | ||
|
|
06666cb148 | ||
|
|
df314fda1d | ||
|
|
f758bf74c2 | ||
|
|
357b57e1d6 | ||
|
|
a10e038fff | ||
|
|
d8c4dd6f79 | ||
|
|
7ace997c53 | ||
|
|
62506b1955 | ||
|
|
53c9c48387 | ||
|
|
2df4e7207b | ||
|
|
f890fe6901 | ||
|
|
7bd7559bfe | ||
|
|
635cb8e3ff | ||
|
|
b23abcbfe5 | ||
|
|
bb09245792 | ||
|
|
6dd969129d | ||
|
|
4f982e244f |
@@ -11,7 +11,7 @@ body:
|
||||
|
||||
## 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.
|
||||
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [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. Duplicates may be closed without notice. **Please search for existing issues and discussions.**
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -25,7 +25,9 @@ body:
|
||||
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.
|
||||
- label: I have searched for any existing and/or related issues.
|
||||
required: true
|
||||
- label: I have searched for any existing and/or related discussions.
|
||||
required: true
|
||||
- label: I am using the latest version of Open WebUI.
|
||||
required: true
|
||||
@@ -47,7 +49,7 @@ body:
|
||||
id: open-webui-version
|
||||
attributes:
|
||||
label: Open WebUI Version
|
||||
description: Specify the version (e.g., v0.3.11)
|
||||
description: Specify the version (e.g., v0.6.26)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -63,7 +65,7 @@ body:
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
|
||||
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -126,6 +128,7 @@ body:
|
||||
description: |
|
||||
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
|
||||
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
|
||||
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
|
||||
|
||||
placeholder: |
|
||||
Example (include every detail):
|
||||
@@ -163,5 +166,5 @@ body:
|
||||
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.
|
||||
**If the bug report is incomplete, does not follow instructions or is lacking details 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!
|
||||
|
||||
@@ -12,12 +12,6 @@ updates:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
|
||||
@@ -73,4 +73,4 @@
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check for changes in package.json
|
||||
run: |
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
echo "::set-output name=content::$CHANGELOG_ESCAPED"
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build workflow
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -419,6 +419,108 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-slim-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (slim tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-slim,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-slim-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (slim)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_SLIM=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-slim-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-main-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-main-image]
|
||||
@@ -433,7 +535,7 @@ jobs:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-main-*
|
||||
path: /tmp/digests
|
||||
@@ -487,7 +589,7 @@ jobs:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-cuda-*
|
||||
path: /tmp/digests
|
||||
@@ -543,7 +645,7 @@ jobs:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-cuda126-*
|
||||
path: /tmp/digests
|
||||
@@ -599,7 +701,7 @@ jobs:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-ollama-*
|
||||
path: /tmp/digests
|
||||
@@ -640,3 +742,59 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-slim-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-slim-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-slim-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default slim tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-slim,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
- 3.12.x
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '${{ matrix.python-version }}'
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Git
|
||||
run: sudo apt-get update && sudo apt-get install -y git
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Build
|
||||
|
||||
+277
@@ -5,6 +5,283 @@ 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.30] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access.
|
||||
|
||||
## [0.6.29] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🎨 The chat input menu has been completely overhauled with a revolutionary new design, consolidating attachments under a unified '+' button, organizing integrations into a streamlined options menu, and introducing powerful, interactive selectors for attaching chats, notes, and knowledge base items. [Commit](https://github.com/open-webui/open-webui/commit/a68342d5a887e36695e21f8c2aec593b159654ff), [Commit](https://github.com/open-webui/open-webui/commit/96b8aaf83ff341fef432649366bc5155bac6cf20), [Commit](https://github.com/open-webui/open-webui/commit/4977e6d50f7b931372c96dd5979ca635d58aeb78), [Commit](https://github.com/open-webui/open-webui/commit/d973db829f7ec98b8f8fe7d3b2822d588e79f94e), [Commit](https://github.com/open-webui/open-webui/commit/d4c628de09654df76653ad9bce9cb3263e2f27c8), [Commit](https://github.com/open-webui/open-webui/commit/cd740f436db4ea308dbede14ef7ff56e8126f51b), [Commit](https://github.com/open-webui/open-webui/commit/5c2db102d06b5c18beb248d795682ff422e9b6d1), [Commit](https://github.com/open-webui/open-webui/commit/031cf38655a1a2973194d2eaa0fbbd17aca8ee92), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3ed0a6d11fea1a054e0bc8aa8dfbe417c7c53e51), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/eadec9e86e01bc8f9fb90dfe7a7ae4fc3bfa6420), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c03ca7270e64e3a002d321237160c0ddaf2bb129), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b53ddfbd19aa94e9cbf7210acb31c3cfafafa5fe), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c923461882fcde30ae297a95e91176c95b9b72e1)
|
||||
- 🤖 AI models can now be mentioned in channels to automatically generate responses, enabling multi-model conversations where mentioned models participate directly in threaded discussions with full context awareness. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/4fe97d8794ee18e087790caab9e5d82886006145)
|
||||
- 💬 The Channels feature now utilizes the modern rich text editor, including support for '/', '@', and '#' command suggestions. [Commit](https://github.com/open-webui/open-webui/commit/06c1426e14ac0dfaf723485dbbc9723a4d89aba9), [Commit](https://github.com/open-webui/open-webui/commit/02f7c3258b62970ce79716f75d15467a96565054)
|
||||
- 📎 Channel message input now supports direct paste functionality for images and files from the clipboard, streamlining content sharing workflows. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/6549fc839f86c40c26c2ef4dedcaf763a9304418)
|
||||
- ⚙️ Models can now be configured with default features (Web Search, Image Generation) and filters that automatically activate when a user selects the model. [Commit](https://github.com/open-webui/open-webui/commit/9a555478273355a5177bfc7f7211c64778e4c8de), [Commit](https://github.com/open-webui/open-webui/commit/384a53b339820068e92f7eaea0d9f3e0536c19c2), [Commit](https://github.com/open-webui/open-webui/commit/d7f43bfc1a30c065def8c50d77c2579c1a3c5c67), [Commit](https://github.com/open-webui/open-webui/commit/6a67a2217cc5946ad771e479e3a37ac213210748)
|
||||
- 💬 The ability to reference other chats as context within a conversation was added via the attachment menu. [Commit](https://github.com/open-webui/open-webui/commit/e097bbdf11ae4975c622e086df00d054291cdeb3), [Commit](https://github.com/open-webui/open-webui/commit/f3cd2ffb18e7dedbe88430f9ae7caa6b3cfd79d0), [Commit](https://github.com/open-webui/open-webui/commit/74263c872c5d574a9bb0944d7984f748dc772dba), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aa8ab349ed2fcb46d1cf994b9c0de2ec2ea35d0d), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/025eef754f0d46789981defd473d001e3b1d0ca2)
|
||||
- 🎨 The command suggestion UI for prompts ('/'), models ('@'), and knowledge ('#') was completely overhauled with a more responsive and keyboard-navigable interface. [Commit](https://github.com/open-webui/open-webui/commit/6b69c4da0fb9329ccf7024483960e070cf52ccab), [Commit](https://github.com/open-webui/open-webui/commit/06a6855f844456eceaa4d410c93379460e208202), [Commit](https://github.com/open-webui/open-webui/commit/c55f5578280b936cf581a743df3703e3db1afd54), [Commit](https://github.com/open-webui/open-webui/commit/f68d1ba394d4423d369f827894cde99d760b2402)
|
||||
- 👥 User and channel suggestions were added to the mention system, enabling '@' mentions for users and models, and '#' mentions for channels with searchable user lookup and clickable navigation. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/bbd1d2b58c89b35daea234f1fc9208f2af840899), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aef1e06f0bb72065a25579c982dd49157e320268), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/779db74d7e9b7b00d099b7d65cfbc8a831e74690)
|
||||
- 📁 Folder functionality was enhanced with custom background image support, improved drag-and-drop capabilities for moving folders to root level, and better menu interactions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2a234829f5dfdfde27fdfd30591caa908340efb4), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2b1ee8b0dc5f7c0caaafdd218f20705059fa72e2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b1e5bc8e490745f701909c19b6a444b67c04660e), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3e584132686372dfeef187596a7c557aa5f48308)
|
||||
- ☁️ OneDrive integration configuration now supports selecting between personal and work/school account types via ENABLE_ONEDRIVE_PERSONAL and ENABLE_ONEDRIVE_BUSINESS environment variables. [#17354](https://github.com/open-webui/open-webui/pull/17354), [Commit](https://github.com/open-webui/open-webui/commit/e1e3009a30f9808ce06582d81a60e391f5ca09ec), [Docs:#697](https://github.com/open-webui/docs/pull/697)
|
||||
- ⚡ Mermaid.js is now dynamically loaded on demand, significantly reducing first-screen loading time and improving initial page performance. [#17476](https://github.com/open-webui/open-webui/issues/17476), [#17477](https://github.com/open-webui/open-webui/pull/17477)
|
||||
- ⚡ Azure MSAL browser library is now dynamically loaded on demand, reducing initial bundle size by 730KB and improving first-screen loading speed. [#17479](https://github.com/open-webui/open-webui/pull/17479)
|
||||
- ⚡ CodeEditor component is now dynamically loaded on demand, reducing initial bundle size by 1MB and improving first-screen loading speed. [#17498](https://github.com/open-webui/open-webui/pull/17498)
|
||||
- ⚡ Hugging Face Transformers library is now dynamically loaded on demand, reducing initial bundle size by 1.9MB and improving first-screen loading speed. [#17499](https://github.com/open-webui/open-webui/pull/17499)
|
||||
- ⚡ jsPDF and html2canvas-pro libraries are now dynamically loaded on demand, reducing initial bundle size by 980KB and improving first-screen loading speed. [#17502](https://github.com/open-webui/open-webui/pull/17502)
|
||||
- ⚡ Leaflet mapping library is now dynamically loaded on demand, reducing initial bundle size by 454KB and improving first-screen loading speed. [#17503](https://github.com/open-webui/open-webui/pull/17503)
|
||||
- 📊 OpenTelemetry metrics collection was enhanced to properly handle HTTP 500 errors and ensure metrics are recorded even during exceptions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b14617a653c6bdcfd3102c12f971924fd1faf572)
|
||||
- 🔒 OAuth token retrieval logic was refactored, improving the reliability and consistency of authentication handling across the backend. [Commit](https://github.com/open-webui/open-webui/commit/6c0a5fa91cdbf6ffb74667ee61ca96bebfdfbc50)
|
||||
- 💻 Code block output processing was improved to handle Python execution results more reliably, along with refined visual styling and button layouts. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0e5320c39e308ff97f2ca9e289618af12479eb6e)
|
||||
- ⚡ Message input processing was optimized to skip unnecessary text variable handling when input is empty, improving performance. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e1386fe80b77126a12dabc4ad058abe9b024b275)
|
||||
- 📄 Individual chat PDF export was added to the sidebar chat menu, allowing users to export single conversations as PDF documents with both stylized and plain text options. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d041d58bb619689cd04a391b4f8191b23941ca62)
|
||||
- 🛠️ Function validation was enhanced with improved valve validation and better error handling during function loading and synchronization. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e66e0526ed6a116323285f79f44237538b6c75e6), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8edfd29102e0a61777b23d3575eaa30be37b59a5)
|
||||
- 🔔 Notification toast interaction was enhanced with drag detection to prevent accidental clicks and added keyboard support for accessibility. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/621e7679c427b6f0efa85f95235319238bf171ad)
|
||||
- 🗓️ Improved date and time formatting dynamically adapts to the selected language, ensuring consistent localization across the UI. [#17409](https://github.com/open-webui/open-webui/pull/17409), [Commit](https://github.com/open-webui/open-webui/commit/2227f24bd6d861b1fad8d2cabacf7d62ce137d0c)
|
||||
- 🔒 Feishu SSO integration was added, allowing users to authenticate via Feishu. [#17284](https://github.com/open-webui/open-webui/pull/17284), [Docs:#685](https://github.com/open-webui/docs/pull/685)
|
||||
- 🔠 Toggle filters in the chat input options menu are now sorted alphabetically for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/ca853ca4656180487afcd84230d214f91db52533)
|
||||
- 🎨 Long chat titles in the sidebar are now truncated to prevent text overflow and maintain a clean layout. [#17356](https://github.com/open-webui/open-webui/pull/17356)
|
||||
- 🎨 Temporary chat interface design was refined with improved layout and visual consistency. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/67549dcadd670285d491bd41daf3d081a70fd094), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2ca34217e68f3b439899c75881dfb050f49c9eb2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/fb02ec52a5df3f58b53db4ab3a995c15f83503cd)
|
||||
- 🎨 Download icon consistency was improved across the entire interface by standardizing the icon component used in menus, functions, tools, and export features. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/596be451ece7e11b5cd25465d49670c27a1cb33f)
|
||||
- 🎨 Settings interface was enhanced with improved iconography and reorganized the 'Chats' section into 'Data Controls' for better clarity. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8bf0b40fdd978b5af6548a6e1fb3aabd90bcd5cd)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Finnish, German, Kabyle, Portuguese (Brazil), Simplified Chinese, Spanish (Spain), and Traditional Chinese (Taiwan) were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📚 Knowledge base permission logic was corrected to ensure private collection owners can access their own content when embedding bypass is enabled. [#17432](https://github.com/open-webui/open-webui/issues/17432), [Commit](https://github.com/open-webui/open-webui/commit/a51f0c30ec1472d71487eab3e15d0351a2716b12)
|
||||
- ⚙️ Connection URL editing in Admin Settings now properly saves changes instead of reverting to original values, fixing issues with both Ollama and OpenAI-compatible endpoints. [#17435](https://github.com/open-webui/open-webui/issues/17435), [Commit](https://github.com/open-webui/open-webui/commit/e4c864de7eb0d577843a80688677ce3659d1f81f)
|
||||
- 📊 Usage information collection from Google models was corrected to handle providers that send usage data alongside content chunks instead of separately. [#17421](https://github.com/open-webui/open-webui/pull/17421), [Commit](https://github.com/open-webui/open-webui/commit/c2f98a4cd29ed738f395fef09c42ab8e73cd46a0)
|
||||
- ⚙️ Settings modal scrolling issue was resolved by moving image compression controls to a dedicated modal, preventing the main settings from becoming scrollable out of view. [#17474](https://github.com/open-webui/open-webui/issues/17474), [Commit](https://github.com/open-webui/open-webui/commit/fed5615c19b0045a55b0be426b468a57bfda4b66)
|
||||
- 📁 Folder click behavior was improved to prevent accidental actions by implementing proper double-click detection and timing delays for folder expansion and selection. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/19e3214997170eea6ee92452e8c778e04a28e396)
|
||||
- 🔐 Access control component reliability was improved with better null checking and error handling for group permissions and private access scenarios. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c8780a7f934c5e49a21b438f2f30232f83cf75d2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/32015c392dbc6b7367a6a91d9e173e675ea3402c)
|
||||
- 🔗 The citation modal now correctly displays and links to external web page sources in addition to internal documents. [Commit](https://github.com/open-webui/open-webui/commit/9208a84185a7e59524f00a7576667d493c3ac7d4)
|
||||
- 🔗 Web and YouTube attachment handling was fixed, ensuring their content is now reliably processed and included in the chat context for retrieval. [Commit](https://github.com/open-webui/open-webui/commit/210197fd438b52080cda5d6ce3d47b92cdc264c8)
|
||||
- 📂 Large file upload failures are resolved by correcting the processing logic for scenarios where document embedding is bypassed. [Commit](https://github.com/open-webui/open-webui/commit/051b6daa8299fd332503bd584563556e2ae6adab)
|
||||
- 🌐 Rich text input placeholder text now correctly updates when the interface language is switched, ensuring proper localization. [#17473](https://github.com/open-webui/open-webui/pull/17473), [Commit](https://github.com/open-webui/open-webui/commit/77358031f5077e6efe5cc08d8d4e5831c7cd1cd9)
|
||||
- 📊 Llama.cpp server timing metrics are now correctly parsed and displayed by fixing a typo in the response handling. [#17350](https://github.com/open-webui/open-webui/issues/17350), [Commit](https://github.com/open-webui/open-webui/commit/cf72f5503f39834b9da44ebbb426a3674dad0caa)
|
||||
- 🛠️ Filter functions with file_handler configuration now properly handle messages without file attachments, preventing runtime errors. [#17423](https://github.com/open-webui/open-webui/pull/17423)
|
||||
- 🔔 Channel notification delivery was fixed to properly handle background task execution and user access checking. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/1077b2ac8b96e49c2ad2620e76eb65bbb2a3a1f3)
|
||||
|
||||
### Changed
|
||||
|
||||
- 📝 Prompt template variables are now optional by default instead of being forced as required, allowing flexible workflows with optional metadata fields. [#17447](https://github.com/open-webui/open-webui/issues/17447), [Commit](https://github.com/open-webui/open-webui/commit/d5824b1b495fcf86e57171769bcec2a0f698b070), [Docs:#696](https://github.com/open-webui/docs/pull/696)
|
||||
- 🛠️ Direct external tool servers now require explicit user selection from the input interface instead of being automatically included in conversations, providing better control over tool usage. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0f04227c34ca32746c43a9323e2df32299fcb6af), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/99bba12de279dd55c55ded35b2e4f819af1c9ab5)
|
||||
- 📺 Widescreen mode option was removed from Channels interface, with all channel layouts now using full-width display. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d46b7b8f1b99a8054b55031fe935c8a16d5ec956)
|
||||
- 🎛️ The plain textarea input option was deprecated, and the custom text editor is now the standard for all chat inputs. [Commit](https://github.com/open-webui/open-webui/commit/153afd832ccd12a1e5fd99b085008d080872c161)
|
||||
|
||||
## [0.6.28] - 2025-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- 🔍 The "@" command for model selection now supports real-time search and filtering, improving usability and aligning its behavior with other input commands. [#17307](https://github.com/open-webui/open-webui/issues/17307), [Commit](https://github.com/open-webui/open-webui/commit/f2a09c71499489ee71599af4a179e7518aaf658b)
|
||||
- 🛠️ External tool server data handling is now more robust, automatically attempting to parse specifications as JSON before falling back to YAML, regardless of the URL extension. [Commit](https://github.com/open-webui/open-webui/commit/774c0056bde88ed4831422efa81506488e3d6641)
|
||||
- 🎯 The "Title" field is now automatically focused when creating a new chat folder, streamlining the folder creation process. [#17315](https://github.com/open-webui/open-webui/issues/17315), [Commit](https://github.com/open-webui/open-webui/commit/c51a651a2d5e2a27546416666812e9b92205562d)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Brazilian Portuguese and Simplified Chinese translations were expanded and refined.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔊 A regression affecting Text-to-Speech for local providers using the OpenAI engine was fixed by reverting a URL joining change. [#17316](https://github.com/open-webui/open-webui/issues/17316), [Commit](https://github.com/open-webui/open-webui/commit/8339f59cdfc63f2d58c8e26933d1bf1438479d75)
|
||||
- 🪧 A regression was fixed where the input modal for prompts with placeholders would not open, causing the raw prompt text to be pasted into the chat input field instead. [#17325](https://github.com/open-webui/open-webui/issues/17325), [Commit](https://github.com/open-webui/open-webui/commit/d5cb65527eaa4831459a4c7dbf187daa9c0525ae)
|
||||
- 🔑 An issue was resolved where modified connection keys in the OpenAIConnection component did not take effect. [#17324](https://github.com/open-webui/open-webui/pull/17324)
|
||||
|
||||
## [0.6.27] - 2025-09-09
|
||||
|
||||
### Added
|
||||
|
||||
- 📁 Emoji folder icons were added, allowing users to personalize workspace organization with visual cues, including improved chevron display. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1588f42fe777ad5d807e3f2fc8dbbc47a8db87c0), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b70c0f36c0f5bbfc2a767429984d6fba1a7bb26c), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/11dea8795bfce42aa5d8d58ef316ded05173bd87), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c0a47169fa059154d5f5a9ea6b94f9a66d82f255)
|
||||
- 📁 The 'Search Collection' input field now dynamically displays the total number of files within the knowledge base. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/fbbe1117ae4c9c8fec6499d790eee275818eccc5)
|
||||
- ☁️ A provider toggle in connection settings now allows users to manually specify Azure OpenAI deployments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/5bdd334b74fbd154085f2d590f4afdba32469c8a)
|
||||
- ⚡ Model list caching performance was optimized by fixing cache key generation to reduce redundant API calls. [#17158](https://github.com/open-webui/open-webui/pull/17158)
|
||||
- 🎨 Azure OpenAI image generation is now supported, with configurations for IMAGES_OPENAI_API_VERSION via environment variable and admin UI. [#17147](https://github.com/open-webui/open-webui/pull/17147), [#16274](https://github.com/open-webui/open-webui/discussions/16274), [Docs:#679](https://github.com/open-webui/docs/pull/679)
|
||||
- ⚡ Comprehensive N+1 query performance is optimized by reducing database queries from 1+N to 1+1 patterns across major listing endpoints. [#17165](https://github.com/open-webui/open-webui/pull/17165), [#17160](https://github.com/open-webui/open-webui/pull/17160), [#17161](https://github.com/open-webui/open-webui/pull/17161), [#17162](https://github.com/open-webui/open-webui/pull/17162), [#17159](https://github.com/open-webui/open-webui/pull/17159), [#17166](https://github.com/open-webui/open-webui/pull/17166)
|
||||
- ⚡ The PDF.js library is now dynamically loaded, significantly reducing initial page load size and improving responsiveness. [#17222](https://github.com/open-webui/open-webui/pull/17222)
|
||||
- ⚡ The heic2any library is now dynamically loaded across various message input components, including channels, for faster page loads. [#17225](https://github.com/open-webui/open-webui/pull/17225), [#17229](https://github.com/open-webui/open-webui/pull/17229)
|
||||
- 📚 The knowledge API now supports a "delete_file" query parameter, allowing configurable file deletion behavior. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/22c4ef4fb096498066b73befe993ae3a82f7a8e7)
|
||||
- 📊 Llama.cpp timing statistics are now integrated into the usage field for comprehensive model performance metrics. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/e830b4959ecd4b2795e29e53026984a58a7696a9)
|
||||
- 🗄️ The PGVECTOR_CREATE_EXTENSION environment variable now allows control over automatic pgvector extension creation. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c2b4976c82d335ed524bd80dc914b5e2f5bfbd9e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b45219c8b15b48d5ee3d42983e1107bbcefbab01), [Docs:#672](https://github.com/open-webui/docs/pull/672)
|
||||
- 🔒 Comprehensive server-side OAuth token management was implemented, securely storing encrypted tokens in a new database table and introducing an automatic refresh mechanism, enabling seamless and secure forwarding of valid user-specific OAuth tokens to downstream services, including OpenAI-compatible endpoints and external tool servers via the new "system_oauth" authentication type, resolving long-standing issues such as large token size limitations, stale/expired tokens, and reliable token propagation, and enhancing overall security by minimizing client-side token exposure, configurable via "ENABLE_OAUTH_ID_TOKEN_COOKIE" and "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY" environment variables. [Docs:#683](https://github.com/open-webui/docs/pull/683), [#17210](https://github.com/open-webui/open-webui/pull/17210), [#8957](https://github.com/open-webui/open-webui/discussions/8957), [#11029](https://github.com/open-webui/open-webui/discussions/11029), [#17178](https://github.com/open-webui/open-webui/issues/17178), [#17183](https://github.com/open-webui/open-webui/issues/17183), [Commit](https://github.com/open-webui/open-webui/commit/217f4daef09b36d3d4cc4681e11d3ebd9984a1a5), [Commit](https://github.com/open-webui/open-webui/commit/fc11e4384fe98fac659e10596f67c23483578867), [Commit](https://github.com/open-webui/open-webui/commit/f11bdc6ab5dd5682bb3e27166e77581f5b8af3e0), [Commit](https://github.com/open-webui/open-webui/commit/f71834720e623761d972d4d740e9bbd90a3a86c6), [Commit](https://github.com/open-webui/open-webui/commit/b5bb6ae177dcdc4e8274d7e5ffa50bc8099fd466), [Commit](https://github.com/open-webui/open-webui/commit/b786d1e3f3308ef4f0f95d7130ddbcaaca4fc927), [Commit](https://github.com/open-webui/open-webui/commit/8a9f8627017bd0a74cbd647891552b26e56aabb7), [Commit](https://github.com/open-webui/open-webui/commit/30d1dc2c60e303756120fe1c5538968c4e6139f4), [Commit](https://github.com/open-webui/open-webui/commit/2b2d123531eb3f42c0e940593832a64e2806240d), [Commit](https://github.com/open-webui/open-webui/commit/6f6412dd16c63c2bb4df79a96b814bf69cb3f880)
|
||||
- 🔒 Conditional Permission Hardening for OpenShift Deployments: Added a build argument to enable optional permission hardening for OpenShift and container environments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
|
||||
- 👥 Regex pattern support is added for OAuth blocked groups, allowing more flexible group filtering rules. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/df66e21472646648d008ebb22b0e8d5424d491df)
|
||||
- 💬 Web search result display was enhanced to include titles and favicons, providing a clearer overview of search sources. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/33f04a771455e3fabf8f0e8ebb994ae7f41b8ed4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0a85dd4bca23022729eafdbc82c8c139fa365af2), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/16090bc2721fde492afa2c4af5927e2b668527e1), [#17197](https://github.com/open-webui/open-webui/pull/17197), [#14179](https://github.com/open-webui/open-webui/issues/14179), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1cdb7aed1ee9bf81f2fd0404be52dcfa64f8ed4f), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f2525ebc447c008cf7269ef20ce04fa456f302c4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/7f523de408ede4075349d8de71ae0214b7e1a62e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/3d37e4a42d344051ae715ab59bd7b5718e46c343), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/cd5e2be27b613314aadda6107089331783987985), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/6dc0df247347aede2762fe2065cf30275fd137ae)
|
||||
- 💬 A new setting was added to control whether clicking a suggested prompt automatically sends the message or only inserts the text. [#17192](https://github.com/open-webui/open-webui/issues/17192), [Commit](https://github.com/open-webui/open-webui/commit/e023a98f11fc52feb21e4065ec707cc98e50c7d3)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, Catalan, and Spanish were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔍 Hybrid search functionality now correctly handles lexical-semantic weight labels and avoids errors when BM25 weight is zero. [#17049](https://github.com/open-webui/open-webui/pull/17049), [#17046](https://github.com/open-webui/open-webui/issues/17046)
|
||||
- 🛑 Task stopping errors are prevented by gracefully handling multiple stop requests for the same task. [#17195](https://github.com/open-webui/open-webui/pull/17195)
|
||||
- 🐍 Code execution package detection precision is improved in Pyodide to prevent unnecessary package inclusions. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/bbe116795860a81a647d9567e0d9cb1950650095)
|
||||
- 🛠️ Tool message format API compliance is fixed by ensuring content fields in tool call responses contain valid string values instead of null. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/37bf0087e5b8a324009c9d06b304027df351ea6b)
|
||||
- 📱 Mobile app config API authentication now supports Authorization header token verification with cookie fallback for iOS and Android requests. [#17175](https://github.com/open-webui/open-webui/pull/17175)
|
||||
- 💾 Knowledge file save race conditions are prevented by serializing API calls and adding an "isSaving" guard. [#17137](https://github.com/open-webui/open-webui/pull/17137), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4ca936f0bf9813bee11ec8aea41d7e34fb6b16a9)
|
||||
- 🔐 The SSO login button visibility is restored for OIDC PKCE authentication without a client secret. [#17012](https://github.com/open-webui/open-webui/pull/17012)
|
||||
- 🔊 Text-to-Speech (TTS) API requests now use proper URL joining methods, ensuring reliable functionality regardless of trailing slashes in the base URL. [#17061](https://github.com/open-webui/open-webui/pull/17061)
|
||||
- 🛡️ Admin account creation on Hugging Face Spaces now correctly detects the configured port, resolving issues with custom port deployments. [#17064](https://github.com/open-webui/open-webui/pull/17064)
|
||||
- 📁 Unicode filename support is improved for external document loaders by properly URL-encoding filenames in HTTP headers. [#17013](https://github.com/open-webui/open-webui/pull/17013), [#17000](https://github.com/open-webui/open-webui/issues/17000)
|
||||
- 🔗 Web page and YouTube attachments are now correctly processed by setting their type as "text" and using collection names for accurate content retrieval. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/487979859a6ffcfd60468f523822cdf838fbef5b)
|
||||
- ✍️ Message input composition event handling is fixed to properly manage text input for multilingual users using Input Method Editors (IME). [#17085](https://github.com/open-webui/open-webui/pull/17085)
|
||||
- 💬 Follow-up tooltip duplication is removed, streamlining the user interface and preventing visual clutter. [#17186](https://github.com/open-webui/open-webui/pull/17186)
|
||||
- 🎨 Chat button text display is corrected by preventing clipping of descending characters and removing unnecessary capitalization. [#17191](https://github.com/open-webui/open-webui/pull/17191)
|
||||
- 🧠 RAG Loop/Error with Gemma 3.1 2B Instruct is fixed by correctly unwrapping unexpected single-item list responses from models. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1bc9711afd2b72cd07c4e539a83783868733767c), [#17213](https://github.com/open-webui/open-webui/issues/17213)
|
||||
- 🖼️ HEIC conversion failures are resolved, improving robustness of image handling. [#17225](https://github.com/open-webui/open-webui/pull/17225)
|
||||
- 📦 The slim Docker image size regression has been fixed by refining the build process to correctly exclude components when USE_SLIM=true. [#16997](https://github.com/open-webui/open-webui/issues/16997), [Commit](https://github.com/open-webui/open-webui/commit/be373e9fd42ac73b0302bdb487e16dbeae178b4e), [Commit](https://github.com/open-webui/open-webui/commit/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
|
||||
- 📁 Knowledge base update validation errors are resolved, ensuring seamless management via UI or API. [#17244](https://github.com/open-webui/open-webui/issues/17244), [Commit](https://github.com/open-webui/open-webui/commit/9aac1489080a5c9441e89b1a56de0d3a672bc5fb)
|
||||
- 🔐 Resolved a security issue where a global web search setting overrode model-specific restrictions, ensuring model-level settings are now correctly prioritized. [#17151](https://github.com/open-webui/open-webui/issues/17151), [Commit](https://github.com/open-webui/open-webui/commit/9368d0ac751ec3072d5a96712b80a9b20a642ce6)
|
||||
- 🔐 OAuth redirect reliability is improved by robustly preserving the intended redirect path using session storage. [#17235](https://github.com/open-webui/open-webui/issues/17235), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4f2b821088367da18374027919594365c7a3f459), [#15575](https://github.com/open-webui/open-webui/pull/15575), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/d9f97c832c556fae4b116759da0177bf4fe619de)
|
||||
- 🔐 Fixed a security vulnerability where knowledge base access within chat folders persisted after permissions were revoked. [#17182](https://github.com/open-webui/open-webui/issues/17182), [Commit](https://github.com/open-webui/open-webui/commit/40e40d1dddf9ca937e99af41c8ca038dbc93a7e6)
|
||||
- 🔒 OIDC access denied errors are now displayed as user-friendly toast notifications instead of raw JSON. [#17208](https://github.com/open-webui/open-webui/issues/17208), [Commit](https://github.com/open-webui/open-webui/commit/3d6d050ad82d360adc42d6e9f42e8faf8d13c9f4)
|
||||
- 💬 Chat exception handling is enhanced to prevent system instability during message generation and ensure graceful error recovery. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f56889c5c7f0cf1a501c05d35dfa614e4f8b6958)
|
||||
- 🔒 Static asset authentication is improved by adding crossorigin="use-credentials" attributes to all link elements, enabling proper cookie forwarding for proxy environments and authenticated requests to favicon, manifest, and stylesheet resources. [#17280](https://github.com/open-webui/open-webui/pull/17280), [Commit](https://github.com/open-webui/open-webui/commit/f17d8b5d19e1a05df7d63f53e939c99772a59c1e)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛠️ Renamed "Tools" to "External Tools" across the UI for clearer distinction between built-in and external functionalities. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0bca4e230ef276bec468889e3be036242ad11086f)
|
||||
- 🛡️ Default permission validation for message regeneration and deletion actions is enhanced to provide more restrictive access controls, improving chat security and user data protection. [#17285](https://github.com/open-webui/open-webui/pull/17285)
|
||||
|
||||
## [0.6.26] - 2025-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- 🛂 **Granular Chat Interaction Permissions**: Added fine-grained permission controls for individual chat actions including "Continue Response", "Regenerate Response", "Rate Response", and "Delete Messages". Administrators can now configure these permissions per user group or set system defaults via environment variables, providing enhanced security and governance by preventing potential system prompt leakage through response continuation and enabling precise control over user interactions with AI responses.
|
||||
- 🧠 **Custom Reasoning Tags Configuration**: Added configurable reasoning tag detection for AI model responses, allowing administrators and users to customize how the system identifies and processes reasoning content. Users can now define custom reasoning tag pairs, use default tags like "think" and "reasoning", or disable reasoning detection entirely through the Advanced Parameters interface, providing enhanced control over AI thought process visibility.
|
||||
- 📱 **Pull-to-Refresh Support**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA.
|
||||
- 📁 **Configurable File Upload Processing Mode**: Added "process_in_background" query parameter to the file upload API endpoint, allowing clients to choose between asynchronous (default) and synchronous file processing. Setting "process_in_background=false" forces the upload request to wait until extraction and embedding complete, returning immediately usable files and simplifying integration for backend API consumers that prefer blocking calls over polling workflows.
|
||||
- 🔐 **Azure Document Intelligence DefaultAzureCredential Support**: Added support for authenticating with Azure Document Intelligence using DefaultAzureCredential in addition to API key authentication, enabling seamless integration with Azure Entra ID and managed identity authentication for enterprise Azure environments.
|
||||
- 🔐 **Authentication Bootstrapping Enhancements**: Added "ENABLE_INITIAL_ADMIN_SIGNUP" environment variable and "?form=true" URL parameter to enable initial admin user creation and forced login form display in SSO-only deployments. This resolves bootstrap issues where administrators couldn't create the first user when login forms were disabled, allowing proper initialization of SSO-configured deployments without requiring temporary configuration changes.
|
||||
- ⚡ **Query Generation Caching**: Added "ENABLE_QUERIES_CACHE" environment variable to enable request-scoped caching of generated search queries. When both web search and file retrieval are active, queries generated for web search are automatically reused for file retrieval, eliminating duplicate LLM API calls and reducing token usage and costs while maintaining search quality.
|
||||
- 🔧 **Configurable Tool Call Retry Limit**: Added "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" environment variable to control the maximum number of sequential tool calls allowed before safety stopping a chat session. This replaces the previous hardcoded limit of 10, enabling administrators to configure higher limits for complex workflows requiring extensive tool interactions.
|
||||
- 📦 **Slim Docker Image Variant**: Added new slim Docker image option via "USE_SLIM" build argument that excludes embedded AI models and Ollama installation, reducing image size by approximately 1GB. This variant enables faster image pulls and deployments for environments where AI models are managed externally, particularly beneficial for auto-scaling clusters and distributed deployments.
|
||||
- 🗂️ **Shift-to-Delete Functionality for Workspace Prompts**: Added keyboard shortcut support for quick prompt deletion on the Workspace Prompts page. Hold Shift and hover over any prompt to reveal a trash icon for instant deletion, bringing consistent interaction patterns across all workspace sections (Models, Tools, Functions, and now Prompts) and streamlining prompt management workflows.
|
||||
- ♿ **Accessibility Enhancements**: Enhanced user interface accessibility with improved keyboard navigation, ARIA labels, and screen reader compatibility across key platform components.
|
||||
- 📄 **Optimized PDF Export for Smaller File Size**: PDF exports are now significantly optimized, producing much smaller files for faster downloads and easier sharing or archiving of your chats and documents.
|
||||
- 📦 **Slimmed Default Install with Optional Full Dependencies**: Installing Open WebUI via pip now defaults to a slimmer package; PostgreSQL support is no longer included by default—simply use 'pip install open-webui[all]' to include all optional dependencies for full feature compatibility.
|
||||
- 🔄 **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Enhanced and expanded translations for Finnish, Spanish, Japanese, Polish, Portuguese (Brazil), and Chinese, including missing translations and typo corrections, providing a more natural and professional user experience for speakers of these languages across the entire interface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚠️ **Chat Error Feedback Restored**: Fixed an issue where various backend errors (tool server failures, API connection issues, malformed responses) would cause chats to load indefinitely without providing user feedback. The system now properly displays error messages when failures occur during chat generation, allowing users to understand issues and retry as needed instead of waiting indefinitely.
|
||||
- 🖼️ **Image Generation Steps Setting Visibility Fixed**: Fixed a UI issue where the "Set Steps" configuration option was incorrectly displayed for OpenAI and Gemini image generation engines that don't support this parameter. The setting is now only visible for compatible engines like ComfyUI and Automatic1111, eliminating user confusion about non-functional configuration options.
|
||||
- 📄 **Datalab Marker API Document Loader Fixed**: Fixed broken Datalab Marker API document loader functionality by correcting URL path handling for both hosted Datalab services and self-hosted Marker servers. Removed hardcoded "/marker" paths from the loader code and restored proper default URL structure, ensuring PDF and document processing works correctly with both deployment types.
|
||||
- 📋 **Citation Error Handling Improved**: Fixed an issue where malformed citation or source objects from external tools, pipes, or filters would cause JavaScript errors and make the chat interface completely unresponsive. The system now gracefully handles missing or undefined citation properties, allowing conversations to load properly even when tools generate defective citation events.
|
||||
- 👥 **Group User Add API Endpoint Fixed**: Fixed an issue where the "/api/v1/groups/id/{group_id}/users/add" API endpoint would accept requests without errors but fail to actually add users to groups. The system now properly initializes and deduplicates user ID lists, ensuring users are correctly added to and removed from groups via API calls.
|
||||
- 🛠️ **External Tool Server Error Handling Improved**: Fixed an issue where unreachable or misconfigured external tool servers would cause JavaScript errors and prevent the interface from loading properly. The system now gracefully handles connection failures, displays appropriate error messages, and filters out inaccessible servers while maintaining full functionality for working connections.
|
||||
- 📋 **Code Block Copy Button Content Fixed**: Fixed an issue where the copy button in code blocks would copy the original AI-generated code instead of any user-edited content, ensuring the copy function always captures the currently displayed code as modified by users.
|
||||
- 📄 **PDF Export Content Mismatch Fixed**: Resolved an issue where exporting a PDF of one chat while viewing another chat would incorrectly generate the PDF using the currently viewed chat's content instead of the intended chat's content. Additionally optimized the PDF generation algorithm with improved canvas slicing, better memory management, and enhanced image quality, while removing the problematic PDF export option from individual chat menus to prevent further confusion.
|
||||
- 🖱️ **Windows Sidebar Cursor Icon Corrected**: Fixed confusing cursor icons on Windows systems where sidebar toggle buttons displayed resize cursors (ew-resize) instead of appropriate pointer cursors. The sidebar buttons now show standard pointer cursors on Windows, eliminating user confusion about whether the buttons expand/collapse the sidebar or resize it.
|
||||
- 📝 **Safari IME Composition Bug Fixed**: Resolved an issue where pressing Enter while composing Chinese text using Input Method Editors (IMEs) on Safari would prematurely send messages instead of completing text composition. The system now properly detects composition states and ignores keydown events that occur immediately after composition ends, ensuring smooth multilingual text input across all browsers.
|
||||
- 🔍 **Hybrid Search Parameter Handling Fixed**: Fixed an issue where the "hybrid" parameter in collection query requests was not being properly evaluated, causing the system to ignore user-specified hybrid search preferences and only check global configuration. Additionally resolved a division by zero error that occurred in hybrid search when BM25Retriever was called with empty document lists, ensuring robust search functionality across all collection states.
|
||||
- 💬 **RTL Text Orientation in Messages Fixed**: Fixed text alignment issues in user messages and AI responses for Right-to-Left languages, ensuring proper text direction based on user language settings. Code blocks now consistently use Left-to-Right orientation regardless of the user's language preference, maintaining code readability across all supported languages.
|
||||
- 📁 **File Content Preview in Modal Restored**: Fixed an issue where clicking on uploaded files would display an empty preview modal, even when the files were successfully processed and available for AI context. File content now displays correctly in the preview modal, ensuring users can verify and review their uploaded documents as intended.
|
||||
- 🌐 **Playwright Timeout Configuration Corrected**: Fixed an issue where Playwright timeout values were incorrectly converted from milliseconds to seconds with an additional 1000x multiplier, causing excessively long web loading timeouts. The timeout parameter now correctly uses the configured millisecond values as intended, ensuring responsive web search and document loading operations.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔄 **Follow-Up Question Language Constraint Removed**: Follow-up question suggestions no longer strictly adhere to the chat's primary language setting, allowing for more flexible and diverse suggestion generation that may include questions in different languages based on conversation context and relevance rather than enforced language matching.
|
||||
|
||||
## [0.6.25] - 2025-08-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🖼️ **Image Generation Reliability Restored**: Fixed a key issue causing image generation failures.
|
||||
- 🏆 **Reranking Functionality Restored**: Resolved errors with rerank feature.
|
||||
|
||||
## [0.6.24] - 2025-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- ♿ **High Contrast Mode in Chat Messages**: Implemented enhanced High Contrast Mode support for chat messages, making text and important details easier to read and improving accessibility for users with visual preferences or requirements.
|
||||
- 🌎 **Localization & Internationalization Improvements**: Enhanced and expanded translations for a more natural and professional user experience for speakers of these languages across the entire interface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🖼️ **ComfyUI Image Generation Restored**: Fixed a critical bug where ComfyUI-based image generation was not functioning, ensuring users can once again effortlessly create and interact with AI-generated visuals in their workflows.
|
||||
- 🛠️ **Tool Server Loading and Visibility Restored**: Resolved an issue where connected tool servers were not loading or visible, restoring seamless integration and uninterrupted access to all external and custom tools directly within the platform.
|
||||
- 🛡️ **Redis User Session Reliability**: Fixed a problem affecting the saving of user sessions in Redis, ensuring reliable login sessions, stable authentication, and secure multi-user environments.
|
||||
|
||||
## [0.6.23] - 2025-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡ **Asynchronous Chat Payload Processing**: Refactored the chat completion pipeline to return a response immediately for streaming requests involving web search or tool calls. This enables users to stop ongoing generations promptly and preventing network timeouts during lengthy preprocessing phases, thus significantly improving user experience and responsiveness.
|
||||
- 📁 **Asynchronous File Upload with Polling**: Implemented an asynchronous file upload process with frontend polling to resolve gateway timeouts and improve reliability when uploading large files. This ensures that even lengthy file processing, such as embedding or transcription, does not block the user interface or lead to connection timeouts, providing a smoother experience for all file operations.
|
||||
- 📈 **Database Performance Indexes and Migration Script**: Introduced new database indexes on the "chat", "tag", and "function" tables to significantly enhance query performance for SQLite and PostgreSQL installations. For existing deployments, a new Alembic migration script is included to seamlessly apply these indexes, ensuring faster filtering and sorting operations across the platform.
|
||||
- ✨ **Enhanced Database Performance Options**: Introduced new configurable options to significantly improve database performance, especially for SQLite. This includes "DATABASE_ENABLE_SQLITE_WAL" to enable SQLite WAL (Write-Ahead Logging) mode for concurrent operations, and "DATABASE_DEDUPLICATE_INTERVAL" which, in conjunction with a new deduplication mechanism, reduces redundant updates to "user.last_active_at", minimizing write conflicts across all database types.
|
||||
- 💾 **Save Temporary Chats Button**: Introduced a new 'Save Chat' button for conversations initiated in temporary mode. This allows users to permanently save valuable temporary conversations to their chat history, providing greater flexibility and ensuring important discussions are not lost.
|
||||
- 📂 **Chat Movement Options in Menu**: Added the ability to move chats directly to folders from the chat menu. This enhances chat organization and allows users to manage their conversations more efficiently by relocating them between folders with ease.
|
||||
- 💬 **Language-Aware Follow-Up Suggestions**: Enhanced the AI's follow-up question generation to dynamically adapt to the primary language of the current chat. Follow-up prompts will now be suggested in the same language the user and AI are conversing in, ensuring more natural and contextually relevant interactions.
|
||||
- 👤 **Expanded User Profile Details**: Introduced new user profile fields including username, bio, gender, and date of birth, allowing for more comprehensive user customization and information management. This enhancement includes corresponding updates to the database schema, API, and user interface for seamless integration.
|
||||
- 👥 **Direct Navigation to User Groups from User Edit**: Enhanced the user edit modal to include a direct link to the associated user group. This allows administrators to quickly navigate from a user's profile to their group settings, streamlining user and group management workflows.
|
||||
- 🔧 **Enhanced External Tool Server Compatibility**: Improved handling of responses from external tool servers, allowing both the backend and frontend to process plain text content in addition to JSON, ensuring greater flexibility and integration with diverse tool outputs.
|
||||
- 🗣️ **Enhanced Audio Transcription Language Fallback and Deepgram Support**: Implemented a robust language fallback mechanism for both OpenAI and Deepgram Speech-to-Text (STT) API calls. If a specified language parameter is not supported by the model or provider, the system will now intelligently retry the transcription without the language parameter or with a default, ensuring greater reliability and preventing failed API calls. This also specifically adds and refines support for the audio language parameter in Deepgram API integrations.
|
||||
- ⚡ **Optimized Hybrid Search Performance for BM25 Weight Configuration**: Enhanced hybrid search to significantly improve performance when the BM25 weight is set to 0 or less. This optimization intelligently disables unnecessary collection retrieval and BM25 ranking calculations, leading to faster search results without impacting accuracy for configurations that do not utilize lexical search contributions.
|
||||
- 🔒 **Configurable Code Interpreter Module Blacklist**: Introduced the "CODE_INTERPRETER_BLACKLISTED_MODULES" environment variable, allowing administrators to specify Python modules that are forbidden from being imported or executed within the code interpreter. This significantly enhances the security posture by mitigating risks associated with arbitrary code execution, such as unauthorized data access, system manipulation, or outbound connections.
|
||||
- 🔐 **Enhanced OAuth Role Claim Handling**: Improved compatibility with diverse OAuth providers by allowing role claims to be supplied as single strings or integers, in addition to arrays. The system now automatically normalizes these single-value claims into arrays for consistent processing, streamlining integration with identity providers that format role data differently.
|
||||
- ⚙️ **Configurable Tool Call Timeout**: Introduced the "AIOHTTP_CLIENT_TIMEOUT" environment variable, allowing administrators to specify custom timeout durations for external tool calls, which is crucial for integrations with tools that have varying or extended response times.
|
||||
- 🛠️ **Improved Tool Callable Generation for Google genai SDK**: Enhanced the creation of tool callables to directly support native function calling within the Google 'genai' SDK. This refactoring ensures proper signature inference and removes extraneous parameters, enabling seamless integration for advanced AI workflows using Google's generative AI models.
|
||||
- ✨ **Dynamic Loading of 'kokoro-js'**: Implemented dynamic loading for the 'kokoro-js' library, preventing failures and improving compatibility on older iOS browsers that may not support direct imports or certain modern JavaScript APIs like 'DecompressionStream'.
|
||||
- 🖥️ **Improved Command List Visibility on Small Screens**: Resolved an issue where the top items in command lists (e.g., Knowledge Base, Models, Prompts) were hidden or overlapped by the header on smaller screen sizes or specific browser zoom levels. The command option lists now dynamically adjust their height, ensuring all items are fully visible and accessible with proper scrolling.
|
||||
- 📦 **Improved Docker Image Compatibility for Arbitrary UIDs**: Fixed issues preventing the Open WebUI container from running in environments with arbitrary User IDs (UIDs), such as OpenShift's restricted Security Context Constraints (SCC). The Dockerfile has been updated to correctly set file system permissions for "/app" and "/root" directories, ensuring they are writable by processes running with a supplemental GID 0, thus resolving permission errors for Python libraries and application caches.
|
||||
- ♿ **Accessibility Enhancements**: Significantly improved the semantic structure of chat messages by using "section", "h2", "ul", and "li" HTML tags, and enhanced screen reader compatibility by explicitly hiding decorative images with "aria-hidden" attributes. This refactoring provides clearer structural context and improves overall accessibility and web standards compliance for the conversation flow.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Significantly expanded internationalization support throughout the user interface, translating numerous user-facing strings in toast messages, placeholders, and other UI elements. This, alongside continuous refinement and expansion of translations for languages including Brazilian Portuguese, Kabyle (Taqbaylit), Czech, Finnish, Chinese (Simplified), Chinese (Traditional), and German, and general fixes for several other translation files, further enhances linguistic coverage and user experience.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Resolved Critical OIDC SSO Login Failure**: Fixed a critical issue where OIDC Single Sign-On (SSO) logins failed due to an error in setting the authentication token as a cookie during the redirect process. This ensures reliable and seamless authentication for users utilizing OIDC providers, restoring full login functionality that was impacted by previous security hardening.
|
||||
- ⚡ **Prevented UI Blocking by Unreachable Webhooks**: Resolved a critical performance and user experience issue where synchronous webhook calls to unreachable or slow endpoints would block the entire user interface for all users. Webhook requests are now processed asynchronously using "aiohttp", ensuring that the UI remains responsive and functional even if webhook delivery encounters delays or failures.
|
||||
- 🔒 **Password Change Option Hidden for Externally Authenticated Users**: Resolved an issue where the password change dialog was visible to users authenticated via external methods (e.g., LDAP, OIDC, Trusted Header). The option to change a password in user settings is now correctly hidden for these users, as their passwords are managed externally, streamlining the user interface and preventing confusion.
|
||||
- 💬 **Resolved Temporary Chat and Permission Enforcement Issues**: Fixed a bug where temporary chats (identified by "chat_id = local") incorrectly triggered database checks, leading to 404 errors. This also resolves the issue where the 'USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED' setting was not functioning as intended, ensuring temporary chat mode now works correctly for user roles.
|
||||
- 🔐 **Admin Model Visibility for Administrators**: Private models remained visible and usable for administrators in the chat model selector, even when the intended privacy setting ("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" - now renamed to "BYPASS_ADMIN_ACCESS_CONTROL") was disabled. This ensures consistent enforcement of model access controls and adherence to the principle of least privilege.
|
||||
- 🔍 **Clarified Web Search Engine Label for DDGS**: Addressed user confusion and inaccurate labeling by renaming "duckduckgo" to "DDGS" (Dux Distributed Global Search) in the web search engine selector. This clarifies that the system utilizes DDGS, a metasearch library that aggregates results from various search providers, accurately reflecting its underlying functionality rather than implying exclusive use of DuckDuckGo's search engine.
|
||||
- 🛠️ **Improved Settings UI Reactivity and Visibility**: Resolved an issue where settings tabs for 'Connections' and 'Tools' did not dynamically update their visibility based on global administrative feature flags (e.g., 'enable_direct_connections'). The UI now reactively shows or hides these sections, ensuring a consistent and clear experience when administrators control feature availability.
|
||||
- 🎚️ **Restored Model and Banner Reordering Functionality**: Fixed a bug that prevented administrators from reordering models in the Admin Panel's 'Models' settings and banners in the 'Interface' settings via drag-and-drop. The sortable functionality has been restored, allowing for proper customization of display order.
|
||||
- 📝 **Restored Custom Pending User Overlay Visibility**: Fixed an issue where the custom title and description configured for pending users were not visible. The application now correctly exposes these UI configuration settings to pending users, ensuring that the custom onboarding messages are displayed as intended.
|
||||
- 📥 **Fixed Community Function Import Compatibility**: Resolved an issue that prevented the successful import of function files downloaded from openwebui.com due to schema differences. The system now correctly processes these files, allowing for seamless integration of community-contributed functions.
|
||||
- 📦 **Fixed Stale Ollama Version in Docker Images**: Resolved an issue where the Ollama installation within Docker images could become stale due to caching during the build process. The Dockerfile now includes a mechanism to invalidate the build cache for the Ollama installation step, ensuring that the latest version of Ollama is always installed.
|
||||
- 🗄️ **Improved Milvus Query Handling for Large Datasets**: Fixed a "MilvusException" that occurred when attempting to query more than 16384 entries from a Milvus collection. The query logic has been refactored to use "query_iterator()", enabling efficient fetching of larger result sets in batches and resolving the previous limitation on the number of entries that could be retrieved.
|
||||
- 🐛 **Restored Message Toolbar Icons for Empty Messages with Files**: Fixed an issue where the edit, copy, and delete icons were not displayed on user messages that contained an attached file but no text content. This ensures full interaction capabilities for all message types, allowing users to manage their messages consistently.
|
||||
- 💬 **Resolved Streaming Interruption for Kimi-Dev Models**: Fixed an issue where streaming responses from Kimi-Dev models would halt prematurely upon encountering specific 'thinking' tokens (◁think▷, ◁/think▷). The system now correctly processes these tokens, ensuring uninterrupted streaming and proper handling of hidden or collapsible thinking sections.
|
||||
- 🔍 **Enhanced Knowledge Base Search Functionality**: Improved the search capability within the 'Knowledge' section of the Workspace. Previously, searching for knowledge bases required exact term matches or starting with the first letter. Now, the search algorithm has been refined to allow broader, less exact matches, making it easier and more intuitive to find relevant knowledge bases.
|
||||
- 📝 **Resolved Chinese Input 'Enter' Key Issue (macOS & iOS Safari)**: Fixed a bug where pressing the 'Enter' key during text composition with Input Method Editors (IMEs) on macOS and iOS Safari browsers would prematurely send the message. The system now robustly handles the composition state by addressing a 'compositionend' event bug specific to Safari, ensuring a smooth and expected typing experience for users of various languages, including Chinese and Korean.
|
||||
- 🔐 **Resolved OAUTH_GROUPS_CLAIM Configuration Issue**: Fixed a bug where the "OAUTH_GROUPS_CLAIM" environment variable was not correctly parsed due to a typo in the configuration file. This ensures that OAuth group management features, including automatic group creation, now correctly utilize the specified claim from the identity provider, allowing for seamless integration with external user directories like Keycloak.
|
||||
- 🗄️ **Resolved Azure PostgreSQL pgvector Extension Permissions**: Fixed an issue preventing the creation of "pgvector" and "pgcrypto" extensions on Azure PostgreSQL Flexible Servers due to permission limitations (e.g., 'Only members of "azure_pg_admin" are allowed to use "CREATE EXTENSION"'). The extension creation process now includes a conditional check, ensuring seamless deployment and compatibility with Azure PostgreSQL environments even with restricted database user permissions.
|
||||
- 🛠️ **Improved Backend Path Resolution and Alembic Stability**: Fixed issues causing Alembic database migrations to fail due to incorrect path resolution within the application. By implementing canonical path resolution for core directories and refining Alembic configuration, the robustness and correctness of internal pathing have been significantly enhanced, ensuring reliable database operations.
|
||||
- 📊 **Resolved Arena Model Identification in Feedback History**: Fixed an issue where the model used for feedback in arena settings was incorrectly reported as 'arena-model' in the evaluation history. The system now correctly logs and displays the actual model ID that received the feedback, restoring clarity and enabling proper analysis of model performance in arena environments.
|
||||
- 🎨 **Resolved Icon Overlap in 'Her' Theme**: Fixed a visual glitch in the 'Her' theme where icons would overlap on the loading screen and certain icons appeared incongruous. The display has been corrected to ensure proper visual presentation and theme consistency.
|
||||
- 🛠️ **Resolved Model Sorting TypeError with Null Names**: Fixed a "TypeError" that occurred in the "/api/models" endpoint when sorting models with null or missing names. The model sorting logic has been improved to gracefully handle such edge cases by ensuring that model IDs and names are treated as empty strings if their values are null or undefined, preventing comparison errors and improving API stability.
|
||||
- 💬 **Resolved Silently Dropped Streaming Response Chunks**: Fixed an issue where the final partial chunks of streaming chat responses could be silently dropped, leading to incomplete message delivery. The system now reliably flush any pending delta data upon stream termination, early breaks (e.g., code interpreter tags), or connection closure, ensuring complete and accurate response delivery.
|
||||
- 📱 **Disabled Overscroll for iOS Frontend**: Fixed an issue where overscrolling was enabled on iOS devices, causing unexpected scrolling behavior over fixed or sticky elements within the PWA. Overscroll has now been disabled, providing a more native application-like experience for iOS users.
|
||||
- 📝 **Resolved Code Block Input Issue with Shift+Enter**: Fixed a bug where typing three backticks followed by a language and then pressing Shift+Enter would cause the code block prefix to disappear, preventing proper code formatting. The system now correctly preserves the code block syntax, ensuring consistent behavior for multi-line code input.
|
||||
- 🛠️ **Improved OpenAI Model List Handling for Null Names**: Fixed an edge case where some OpenAI-compatible API providers might return models with a null value for their 'name' field. This could lead to issues like broken model list sorting. The system now gracefully handles these instances by removing the null 'name' key, ensuring stable model retrieval and display.
|
||||
- 🔍 **Resolved DDGS Concurrent Request Configuration**: Fixed an issue where the configured number of concurrent requests was not being honored for the DDGS (Dux Distributed Global Search) metasearch engine. The system now correctly applies the specified concurrency setting, improving efficiency for web searches.
|
||||
- 🛠️ **Improved Tool List Synchronization in Multi-Replica Deployments**: Resolved an issue where tool updates were not consistently reflected across all instances in multi-replica environments, leading to stale tool lists for users on other replicas. The tool list in the message input menu is now automatically refreshed each time it is accessed, ensuring all users always see the most current set of available tools.
|
||||
- 🛠️ **Resolved Duplicate Tool Name Collision**: Fixed an issue where tools with identical names from different external servers were silently removed, preventing their simultaneous use. The system now correctly handles tool name collisions by internally prefixing tools with their server identifier, allowing multiple instances of similarly named tools from different servers to be active and usable by LLMs.
|
||||
- 🖼️ **Resolved Image Generation API Size Parameter Issue**: Fixed a bug where the "/api/v1/images/generations" API endpoint did not correctly apply the 'size' parameter specified in the request payload for image generation. The system now properly honors the requested image dimensions (e.g., '1980x1080'), ensuring that generated images match the user's explicit size preference rather than defaulting to settings.
|
||||
- 🗄️ **Resolved S3 Vector Upload Limitations**: Fixed an issue that prevented uploading more than 500 vectors to S3 Vector buckets due to API limitations, which resulted in a "ValidationException". S3 vector uploads are now batched in groups of 500, ensuring successful processing of larger datasets.
|
||||
- 🛠️ **Fixed Tool Installation Error During Startup**: Resolved a "NoneType" error that occurred during tool installation at startup when 'tool.user' was unexpectedly null. The system now includes a check to ensure 'tool.user' exists before attempting to access its properties, preventing crashes and ensuring robust tool initialization.
|
||||
- 🛠️ **Improved Azure OpenAI GPT-5 Parameter Handling**: Fixed an issue with Azure OpenAI SDK parameter handling to correctly support GPT-5 models. The 'max_tokens' parameter is now appropriately converted to 'max_completion_tokens' for GPT-5 models, ensuring consistent behavior and proper function execution similar to existing o-series models.
|
||||
- 🐛 **Resolved Exception with Missing Group Permissions**: Fixed an exception that occurred in the access control logic when group permission objects were missing or null. The system now correctly handles cases where groups may not have explicit permission definitions, ensuring that 'None' checks prevent errors and maintain application stability when processing user permissions.
|
||||
- 🛠️ **Improved OpenAI API Base URL Handling**: Fixed an issue where a trailing slash in the 'OPENAI_API_BASE_URL' configuration could lead to models not being detected or the endpoint failing. The system now automatically removes trailing slashes from the configured URL, ensuring robust and consistent connections to OpenAI-compatible APIs.
|
||||
- 🖼️ **Resolved S3-Compatible Storage Upload Failures**: Fixed an issue where uploads to S3-compatible storage providers would fail with an "XAmzContentSHA256Mismatch" error. The system now correctly handles checksum calculations, ensuring reliable file and image uploads to S3-compatible services.
|
||||
- 🌐 **Corrected 'Releases' Link**: Fixed an issue where the 'Releases' button in the user menu directed to an incorrect URL, now correctly linking to the Open WebUI GitHub releases page.
|
||||
- 🛠️ **Resolved Model Sorting Errors with Null or Undefined Names**: Fixed multiple "TypeError" instances that occurred when attempting to sort model lists where model names were null or undefined. The sorting logic across various UI components (including Ollama model selection, leaderboard, and admin model settings) has been made more robust by gracefully handling absent model names, preventing crashes and ensuring consistent alphabetical sorting based on available name or ID.
|
||||
- 🎨 **Resolved Banner Dismissal Issue with Iteration IDs**: Fixed a bug where dismissing banners could lead to unintended multiple banner dismissals or other incorrect behavior, especially when banners lacked unique iteration IDs. Unique IDs are now assigned during banner iteration, ensuring proper individual dismissal and consistent display behavior.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛂 **Environment Variable for Admin Access Control**: The environment variable "ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" has been renamed to "BYPASS_ADMIN_ACCESS_CONTROL". This new name more accurately reflects its function as a control to allow administrators to bypass model access restrictions. Users are encouraged to update their configurations to use the new variable name; existing configurations using the old name will still be honored for backward compatibility.
|
||||
- 🗂️ **Core Directory Path Resolution Updated**: The internal mechanism for resolving core application directory paths ("OPEN_WEBUI_DIR", "BACKEND_DIR", "BASE_DIR") has been updated to use canonical resolution via "Path().resolve()". This change improves path reliability but may require adjustments for any external scripts or configurations that previously relied on specific non-canonical path interpretations.
|
||||
- 🗃️ **Database Performance Options**: New database performance options, "DATABASE_ENABLE_SQLITE_WAL" and "DATABASE_DEDUPLICATE_INTERVAL", are now available. If "DATABASE_ENABLE_SQLITE_WAL" is enabled, SQLite will operate in WAL mode, which may alter SQLite's file locking behavior. If "DATABASE_DEDUPLICATE_INTERVAL" is set to a non-zero value, the "user.last_active_at" timestamp will be updated less frequently, leading to slightly less real-time accuracy for this specific field but significantly reducing database write conflicts and improving overall performance. Both options are disabled by default.
|
||||
- 🌐 **Renamed Web Search Concurrency Setting**: The environment variable "WEB_SEARCH_CONCURRENT_REQUESTS" has been renamed to "WEB_LOADER_CONCURRENT_REQUESTS". This change clarifies its scope, explicitly applying to the concurrency of the web loader component (which fetches content from search results) rather than the initial search engine query. Users relying on the old environment variable name for configuring web search concurrency must update their configurations to use "WEB_LOADER_CONCURRENT_REQUESTS".
|
||||
|
||||
## [0.6.22] - 2025-08-11
|
||||
|
||||
### Added
|
||||
|
||||
+37
-25
@@ -3,6 +3,8 @@
|
||||
# use build args in the docker build command with --build-arg="BUILDARG=true"
|
||||
ARG USE_CUDA=false
|
||||
ARG USE_OLLAMA=false
|
||||
ARG USE_SLIM=false
|
||||
ARG USE_PERMISSION_HARDENING=false
|
||||
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
|
||||
ARG USE_CUDA_VER=cu128
|
||||
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
|
||||
@@ -24,6 +26,9 @@ ARG GID=0
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
|
||||
ARG BUILD_HASH
|
||||
|
||||
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
|
||||
# ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# to store git revision in build
|
||||
@@ -43,6 +48,8 @@ FROM python:3.11-slim-bookworm AS base
|
||||
ARG USE_CUDA
|
||||
ARG USE_OLLAMA
|
||||
ARG USE_CUDA_VER
|
||||
ARG USE_SLIM
|
||||
ARG USE_PERMISSION_HARDENING
|
||||
ARG USE_EMBEDDING_MODEL
|
||||
ARG USE_RERANKING_MODEL
|
||||
ARG UID
|
||||
@@ -54,6 +61,7 @@ ENV ENV=prod \
|
||||
# pass build args to the build
|
||||
USE_OLLAMA_DOCKER=${USE_OLLAMA} \
|
||||
USE_CUDA_DOCKER=${USE_CUDA} \
|
||||
USE_SLIM_DOCKER=${USE_SLIM} \
|
||||
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
|
||||
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
|
||||
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
|
||||
@@ -108,29 +116,13 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
|
||||
# Make sure the user has access to the app and root directory
|
||||
RUN chown -R $UID:$GID /app $HOME
|
||||
|
||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||
apt-get update && \
|
||||
# Install pandoc and netcat
|
||||
apt-get install -y --no-install-recommends git build-essential pandoc netcat-openbsd curl && \
|
||||
apt-get install -y --no-install-recommends gcc python3-dev && \
|
||||
# for RAG OCR
|
||||
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
|
||||
# install helper tools
|
||||
apt-get install -y --no-install-recommends curl jq && \
|
||||
# install ollama
|
||||
curl -fsSL https://ollama.com/install.sh | sh && \
|
||||
# cleanup
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
else \
|
||||
apt-get update && \
|
||||
# Install pandoc, netcat and gcc
|
||||
apt-get install -y --no-install-recommends git build-essential pandoc gcc netcat-openbsd curl jq && \
|
||||
apt-get install -y --no-install-recommends gcc python3-dev && \
|
||||
# for RAG OCR
|
||||
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
|
||||
# cleanup
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
# Install common system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install python dependencies
|
||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
@@ -146,13 +138,22 @@ RUN pip3 install --no-cache-dir uv && \
|
||||
else \
|
||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||
if [ "$USE_SLIM" != "true" ]; then \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
fi; \
|
||||
chown -R $UID:$GID /app/backend/data/
|
||||
|
||||
fi; \
|
||||
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
# Install Ollama if requested
|
||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||
date +%s > /tmp/ollama_build_hash && \
|
||||
echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \
|
||||
curl -fsSL https://ollama.com/install.sh | sh && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# copy embedding weight from build
|
||||
# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
|
||||
@@ -170,6 +171,17 @@ EXPOSE 8080
|
||||
|
||||
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
|
||||
|
||||
# Minimal, atomic permission hardening for OpenShift (arbitrary UID):
|
||||
# - Group 0 owns /app and /root
|
||||
# - Directories are group-writable and have SGID so new files inherit GID 0
|
||||
RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \
|
||||
set -eux; \
|
||||
chgrp -R 0 /app /root || true; \
|
||||
chmod -R g+rwX /app /root || true; \
|
||||
find /app -type d -exec chmod g+s {} + || true; \
|
||||
find /root -type d -exec chmod g+s {} + || true; \
|
||||
fi
|
||||
|
||||
USER $UID:$GID
|
||||
|
||||
ARG BUILD_HASH
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export CORS_ALLOW_ORIGIN="http://localhost:5173"
|
||||
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||
PORT="${PORT:-8080}"
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
|
||||
|
||||
@@ -10,7 +10,7 @@ script_location = migrations
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
prepend_sys_path = ..
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
|
||||
@@ -313,7 +313,7 @@ JWT_EXPIRES_IN = PersistentConfig(
|
||||
####################################
|
||||
|
||||
ENABLE_OAUTH_PERSISTENT_CONFIG = (
|
||||
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "True").lower() == "true"
|
||||
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_OAUTH_SIGNUP = PersistentConfig(
|
||||
@@ -510,7 +510,31 @@ OAUTH_EMAIL_CLAIM = PersistentConfig(
|
||||
OAUTH_GROUPS_CLAIM = PersistentConfig(
|
||||
"OAUTH_GROUPS_CLAIM",
|
||||
"oauth.oidc.group_claim",
|
||||
os.environ.get("OAUTH_GROUP_CLAIM", "groups"),
|
||||
os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")),
|
||||
)
|
||||
|
||||
FEISHU_CLIENT_ID = PersistentConfig(
|
||||
"FEISHU_CLIENT_ID",
|
||||
"oauth.feishu.client_id",
|
||||
os.environ.get("FEISHU_CLIENT_ID", ""),
|
||||
)
|
||||
|
||||
FEISHU_CLIENT_SECRET = PersistentConfig(
|
||||
"FEISHU_CLIENT_SECRET",
|
||||
"oauth.feishu.client_secret",
|
||||
os.environ.get("FEISHU_CLIENT_SECRET", ""),
|
||||
)
|
||||
|
||||
FEISHU_OAUTH_SCOPE = PersistentConfig(
|
||||
"FEISHU_OAUTH_SCOPE",
|
||||
"oauth.feishu.scope",
|
||||
os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"),
|
||||
)
|
||||
|
||||
FEISHU_REDIRECT_URI = PersistentConfig(
|
||||
"FEISHU_REDIRECT_URI",
|
||||
"oauth.feishu.redirect_uri",
|
||||
os.environ.get("FEISHU_REDIRECT_URI", ""),
|
||||
)
|
||||
|
||||
ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
|
||||
@@ -660,7 +684,7 @@ def load_oauth_providers():
|
||||
|
||||
if (
|
||||
OAUTH_CLIENT_ID.value
|
||||
and OAUTH_CLIENT_SECRET.value
|
||||
and (OAUTH_CLIENT_SECRET.value or OAUTH_CODE_CHALLENGE_METHOD.value)
|
||||
and OPENID_PROVIDER_URL.value
|
||||
):
|
||||
|
||||
@@ -705,6 +729,33 @@ def load_oauth_providers():
|
||||
"register": oidc_oauth_register,
|
||||
}
|
||||
|
||||
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
|
||||
|
||||
def feishu_oauth_register(client: OAuth):
|
||||
client.register(
|
||||
name="feishu",
|
||||
client_id=FEISHU_CLIENT_ID.value,
|
||||
client_secret=FEISHU_CLIENT_SECRET.value,
|
||||
access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize",
|
||||
api_base_url="https://open.feishu.cn/open-apis",
|
||||
userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info",
|
||||
client_kwargs={
|
||||
"scope": FEISHU_OAUTH_SCOPE.value,
|
||||
**(
|
||||
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||
if OAUTH_TIMEOUT.value
|
||||
else {}
|
||||
),
|
||||
},
|
||||
redirect_uri=FEISHU_REDIRECT_URI.value,
|
||||
)
|
||||
|
||||
OAUTH_PROVIDERS["feishu"] = {
|
||||
"register": feishu_oauth_register,
|
||||
"sub_claim": "user_id",
|
||||
}
|
||||
|
||||
configured_providers = []
|
||||
if GOOGLE_CLIENT_ID.value:
|
||||
configured_providers.append("Google")
|
||||
@@ -712,6 +763,8 @@ def load_oauth_providers():
|
||||
configured_providers.append("Microsoft")
|
||||
if GITHUB_CLIENT_ID.value:
|
||||
configured_providers.append("GitHub")
|
||||
if FEISHU_CLIENT_ID.value:
|
||||
configured_providers.append("Feishu")
|
||||
|
||||
if configured_providers and not OPENID_PROVIDER_URL.value:
|
||||
provider_list = ", ".join(configured_providers)
|
||||
@@ -953,6 +1006,9 @@ GEMINI_API_BASE_URL = os.environ.get("GEMINI_API_BASE_URL", "")
|
||||
|
||||
if OPENAI_API_BASE_URL == "":
|
||||
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
||||
else:
|
||||
if OPENAI_API_BASE_URL.endswith("/"):
|
||||
OPENAI_API_BASE_URL = OPENAI_API_BASE_URL[:-1]
|
||||
|
||||
OPENAI_API_KEYS = os.environ.get("OPENAI_API_KEYS", "")
|
||||
OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != "" else OPENAI_API_KEY
|
||||
@@ -1205,6 +1261,23 @@ USER_PERMISSIONS_CHAT_DELETE = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_DELETE_MESSAGE = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_DELETE_MESSAGE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE", "True").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_RATE_RESPONSE = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_RATE_RESPONSE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_EDIT = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
|
||||
)
|
||||
@@ -1287,6 +1360,10 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
"params": USER_PERMISSIONS_CHAT_PARAMS,
|
||||
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
|
||||
"delete": USER_PERMISSIONS_CHAT_DELETE,
|
||||
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
|
||||
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
|
||||
"regenerate_response": USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE,
|
||||
"rate_response": USER_PERMISSIONS_CHAT_RATE_RESPONSE,
|
||||
"edit": USER_PERMISSIONS_CHAT_EDIT,
|
||||
"share": USER_PERMISSIONS_CHAT_SHARE,
|
||||
"export": USER_PERMISSIONS_CHAT_EXPORT,
|
||||
@@ -1355,6 +1432,14 @@ ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS = (
|
||||
os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
BYPASS_ADMIN_ACCESS_CONTROL = (
|
||||
os.environ.get(
|
||||
"BYPASS_ADMIN_ACCESS_CONTROL",
|
||||
os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True"),
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
ENABLE_ADMIN_CHAT_ACCESS = (
|
||||
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
|
||||
)
|
||||
@@ -1857,6 +1942,11 @@ CODE_INTERPRETER_JUPYTER_TIMEOUT = PersistentConfig(
|
||||
),
|
||||
)
|
||||
|
||||
CODE_INTERPRETER_BLOCKED_MODULES = [
|
||||
library.strip()
|
||||
for library in os.environ.get("CODE_INTERPRETER_BLOCKED_MODULES", "").split(",")
|
||||
if library.strip()
|
||||
]
|
||||
|
||||
DEFAULT_CODE_INTERPRETER_PROMPT = """
|
||||
#### Tools Available
|
||||
@@ -1961,6 +2051,9 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
|
||||
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
|
||||
)
|
||||
|
||||
PGVECTOR_CREATE_EXTENSION = (
|
||||
os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true"
|
||||
)
|
||||
PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true"
|
||||
PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None)
|
||||
if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
|
||||
@@ -2075,6 +2168,12 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
|
||||
"onedrive.enable",
|
||||
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
|
||||
)
|
||||
ENABLE_ONEDRIVE_PERSONAL = (
|
||||
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
|
||||
)
|
||||
ENABLE_ONEDRIVE_BUSINESS = (
|
||||
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
ONEDRIVE_CLIENT_ID = PersistentConfig(
|
||||
"ONEDRIVE_CLIENT_ID",
|
||||
@@ -2192,6 +2291,18 @@ DOCLING_SERVER_URL = PersistentConfig(
|
||||
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
|
||||
)
|
||||
|
||||
DOCLING_DO_OCR = PersistentConfig(
|
||||
"DOCLING_DO_OCR",
|
||||
"rag.docling_do_ocr",
|
||||
os.getenv("DOCLING_DO_OCR", "True").lower() == "true",
|
||||
)
|
||||
|
||||
DOCLING_FORCE_OCR = PersistentConfig(
|
||||
"DOCLING_FORCE_OCR",
|
||||
"rag.docling_force_ocr",
|
||||
os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true",
|
||||
)
|
||||
|
||||
DOCLING_OCR_ENGINE = PersistentConfig(
|
||||
"DOCLING_OCR_ENGINE",
|
||||
"rag.docling_ocr_engine",
|
||||
@@ -2204,6 +2315,24 @@ DOCLING_OCR_LANG = PersistentConfig(
|
||||
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
|
||||
)
|
||||
|
||||
DOCLING_PDF_BACKEND = PersistentConfig(
|
||||
"DOCLING_PDF_BACKEND",
|
||||
"rag.docling_pdf_backend",
|
||||
os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"),
|
||||
)
|
||||
|
||||
DOCLING_TABLE_MODE = PersistentConfig(
|
||||
"DOCLING_TABLE_MODE",
|
||||
"rag.docling_table_mode",
|
||||
os.getenv("DOCLING_TABLE_MODE", "accurate"),
|
||||
)
|
||||
|
||||
DOCLING_PIPELINE = PersistentConfig(
|
||||
"DOCLING_PIPELINE",
|
||||
"rag.docling_pipeline",
|
||||
os.getenv("DOCLING_PIPELINE", "standard"),
|
||||
)
|
||||
|
||||
DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
|
||||
"DOCLING_DO_PICTURE_DESCRIPTION",
|
||||
"rag.docling_do_picture_description",
|
||||
@@ -2611,6 +2740,14 @@ WEB_LOADER_ENGINE = PersistentConfig(
|
||||
os.environ.get("WEB_LOADER_ENGINE", ""),
|
||||
)
|
||||
|
||||
|
||||
WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
|
||||
"WEB_LOADER_CONCURRENT_REQUESTS",
|
||||
"rag.web.loader.concurrent_requests",
|
||||
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
|
||||
)
|
||||
|
||||
|
||||
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
|
||||
"ENABLE_WEB_LOADER_SSL_VERIFICATION",
|
||||
"rag.web.loader.ssl_verification",
|
||||
@@ -3052,6 +3189,12 @@ IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
|
||||
"image_generation.openai.api_base_url",
|
||||
os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL),
|
||||
)
|
||||
IMAGES_OPENAI_API_VERSION = PersistentConfig(
|
||||
"IMAGES_OPENAI_API_VERSION",
|
||||
"image_generation.openai.api_version",
|
||||
os.getenv("IMAGES_OPENAI_API_VERSION", ""),
|
||||
)
|
||||
|
||||
IMAGES_OPENAI_API_KEY = PersistentConfig(
|
||||
"IMAGES_OPENAI_API_KEY",
|
||||
"image_generation.openai.api_key",
|
||||
|
||||
@@ -17,14 +17,17 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
# Load .env file
|
||||
####################################
|
||||
|
||||
OPEN_WEBUI_DIR = Path(__file__).parent # the path containing this file
|
||||
print(OPEN_WEBUI_DIR)
|
||||
# Use .resolve() to get the canonical path, removing any '..' or '.' components
|
||||
ENV_FILE_PATH = Path(__file__).resolve()
|
||||
|
||||
BACKEND_DIR = OPEN_WEBUI_DIR.parent # the path containing this file
|
||||
BASE_DIR = BACKEND_DIR.parent # the path containing the backend/
|
||||
# OPEN_WEBUI_DIR should be the directory where env.py resides (open_webui/)
|
||||
OPEN_WEBUI_DIR = ENV_FILE_PATH.parent
|
||||
|
||||
print(BACKEND_DIR)
|
||||
print(BASE_DIR)
|
||||
# BACKEND_DIR is the parent of OPEN_WEBUI_DIR (backend/)
|
||||
BACKEND_DIR = OPEN_WEBUI_DIR.parent
|
||||
|
||||
# BASE_DIR is the parent of BACKEND_DIR (open-webui-dev/)
|
||||
BASE_DIR = BACKEND_DIR.parent
|
||||
|
||||
try:
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
@@ -336,6 +339,21 @@ else:
|
||||
except Exception:
|
||||
DATABASE_POOL_RECYCLE = 3600
|
||||
|
||||
DATABASE_ENABLE_SQLITE_WAL = (
|
||||
os.environ.get("DATABASE_ENABLE_SQLITE_WAL", "False").lower() == "true"
|
||||
)
|
||||
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get(
|
||||
"DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL", None
|
||||
)
|
||||
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
|
||||
try:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||
)
|
||||
except Exception:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
|
||||
|
||||
RESET_CONFIG_ON_START = (
|
||||
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
|
||||
)
|
||||
@@ -344,6 +362,8 @@ ENABLE_REALTIME_CHAT_SAVE = (
|
||||
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
|
||||
|
||||
####################################
|
||||
# REDIS
|
||||
####################################
|
||||
@@ -384,6 +404,10 @@ except ValueError:
|
||||
####################################
|
||||
|
||||
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
|
||||
|
||||
ENABLE_INITIAL_ADMIN_SIGNUP = (
|
||||
os.environ.get("ENABLE_INITIAL_ADMIN_SIGNUP", "False").lower() == "true"
|
||||
)
|
||||
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
|
||||
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
|
||||
)
|
||||
@@ -441,6 +465,19 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
|
||||
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
####################################
|
||||
# OAUTH Configuration
|
||||
####################################
|
||||
|
||||
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE = (
|
||||
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# SCIM Configuration
|
||||
@@ -509,6 +546,19 @@ else:
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
|
||||
|
||||
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
|
||||
)
|
||||
|
||||
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
else:
|
||||
try:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
|
||||
except Exception:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
|
||||
|
||||
####################################
|
||||
# WEBSOCKET SUPPORT
|
||||
####################################
|
||||
@@ -677,6 +727,7 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
|
||||
####################################
|
||||
|
||||
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
|
||||
ENABLE_OTEL_TRACES = os.environ.get("ENABLE_OTEL_TRACES", "False").lower() == "true"
|
||||
ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true"
|
||||
ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true"
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from fastapi import (
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.socket.main import (
|
||||
get_event_call,
|
||||
get_event_emitter,
|
||||
@@ -47,7 +48,7 @@ from open_webui.utils.misc import (
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_model_system_prompt_to_body,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str):
|
||||
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
Valves = function_module.Valves
|
||||
valves = Functions.get_function_valves_by_id(pipe_id)
|
||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
||||
|
||||
if valves:
|
||||
try:
|
||||
function_module.valves = Valves(
|
||||
**{k: v for k, v in valves.items() if v is not None}
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Error loading valves for function {pipe_id}: {e}")
|
||||
raise e
|
||||
else:
|
||||
function_module.valves = Valves()
|
||||
|
||||
return function_module
|
||||
|
||||
|
||||
@@ -70,65 +83,69 @@ async def get_function_models(request):
|
||||
pipe_models = []
|
||||
|
||||
for pipe in pipes:
|
||||
function_module = get_function_module_by_id(request, pipe.id)
|
||||
try:
|
||||
function_module = get_function_module_by_id(request, pipe.id)
|
||||
|
||||
# Check if function is a manifold
|
||||
if hasattr(function_module, "pipes"):
|
||||
sub_pipes = []
|
||||
|
||||
# Handle pipes being a list, sync function, or async function
|
||||
try:
|
||||
if callable(function_module.pipes):
|
||||
if asyncio.iscoroutinefunction(function_module.pipes):
|
||||
sub_pipes = await function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
# Check if function is a manifold
|
||||
if hasattr(function_module, "pipes"):
|
||||
sub_pipes = []
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
||||
)
|
||||
# Handle pipes being a list, sync function, or async function
|
||||
try:
|
||||
if callable(function_module.pipes):
|
||||
if asyncio.iscoroutinefunction(function_module.pipes):
|
||||
sub_pipes = await function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
sub_pipes = []
|
||||
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
sub_pipe_name = p["name"]
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
||||
)
|
||||
|
||||
if hasattr(function_module, "name"):
|
||||
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
sub_pipe_name = p["name"]
|
||||
|
||||
pipe_flag = {"type": pipe.type}
|
||||
if hasattr(function_module, "name"):
|
||||
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
||||
|
||||
pipe_flag = {"type": pipe.type}
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": sub_pipe_id,
|
||||
"name": sub_pipe_name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
}
|
||||
)
|
||||
else:
|
||||
pipe_flag = {"type": "pipe"}
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
)
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": sub_pipe_id,
|
||||
"name": sub_pipe_name,
|
||||
"id": pipe.id,
|
||||
"name": pipe.name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
}
|
||||
)
|
||||
else:
|
||||
pipe_flag = {"type": "pipe"}
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
)
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": pipe.id,
|
||||
"name": pipe.name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
continue
|
||||
|
||||
return pipe_models
|
||||
|
||||
@@ -219,6 +236,16 @@ async def generate_function_chat_completion(
|
||||
__task__ = metadata.get("task", None)
|
||||
__task_body__ = metadata.get("task_body", None)
|
||||
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
extra_params = {
|
||||
"__event_emitter__": __event_emitter__,
|
||||
"__event_call__": __event_call__,
|
||||
@@ -230,9 +257,10 @@ async def generate_function_chat_completion(
|
||||
"__files__": files,
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__oauth_token__": oauth_token,
|
||||
"__request__": request,
|
||||
}
|
||||
extra_params["__tools__"] = get_tools(
|
||||
extra_params["__tools__"] = await get_tools(
|
||||
request,
|
||||
tool_ids,
|
||||
user,
|
||||
@@ -253,9 +281,7 @@ async def generate_function_chat_completion(
|
||||
if params:
|
||||
system = params.pop("system", None)
|
||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||
form_data = apply_model_system_prompt_to_body(
|
||||
system, form_data, metadata, user
|
||||
)
|
||||
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
|
||||
pipe_id = get_pipe_id(form_data)
|
||||
function_module = get_function_module_by_id(request, pipe_id)
|
||||
|
||||
@@ -14,9 +14,10 @@ from open_webui.env import (
|
||||
DATABASE_POOL_RECYCLE,
|
||||
DATABASE_POOL_SIZE,
|
||||
DATABASE_POOL_TIMEOUT,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
)
|
||||
from peewee_migrate import Router
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, types
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool, NullPool
|
||||
@@ -114,6 +115,16 @@ elif "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
def on_connect(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
else:
|
||||
cursor.execute("PRAGMA journal_mode=DELETE")
|
||||
cursor.close()
|
||||
|
||||
event.listen(engine, "connect", on_connect)
|
||||
else:
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
|
||||
+157
-88
@@ -57,6 +57,7 @@ from open_webui.utils.logger import start_logger
|
||||
from open_webui.socket.main import (
|
||||
app as socket_app,
|
||||
periodic_usage_pool_cleanup,
|
||||
get_event_emitter,
|
||||
get_models_in_use,
|
||||
get_active_user_ids,
|
||||
)
|
||||
@@ -109,9 +110,6 @@ from open_webui.config import (
|
||||
OLLAMA_API_CONFIGS,
|
||||
# OpenAI
|
||||
ENABLE_OPENAI_API,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
OPENAI_API_BASE_URLS,
|
||||
OPENAI_API_KEYS,
|
||||
OPENAI_API_CONFIGS,
|
||||
@@ -156,6 +154,7 @@ from open_webui.config import (
|
||||
IMAGE_SIZE,
|
||||
IMAGE_STEPS,
|
||||
IMAGES_OPENAI_API_BASE_URL,
|
||||
IMAGES_OPENAI_API_VERSION,
|
||||
IMAGES_OPENAI_API_KEY,
|
||||
IMAGES_GEMINI_API_BASE_URL,
|
||||
IMAGES_GEMINI_API_KEY,
|
||||
@@ -185,6 +184,7 @@ from open_webui.config import (
|
||||
FIRECRAWL_API_BASE_URL,
|
||||
FIRECRAWL_API_KEY,
|
||||
WEB_LOADER_ENGINE,
|
||||
WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
WHISPER_MODEL,
|
||||
WHISPER_VAD_FILTER,
|
||||
WHISPER_LANGUAGE,
|
||||
@@ -241,8 +241,13 @@ from open_webui.config import (
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL,
|
||||
DOCLING_DO_OCR,
|
||||
DOCLING_FORCE_OCR,
|
||||
DOCLING_OCR_ENGINE,
|
||||
DOCLING_OCR_LANG,
|
||||
DOCLING_PDF_BACKEND,
|
||||
DOCLING_TABLE_MODE,
|
||||
DOCLING_PIPELINE,
|
||||
DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
@@ -295,14 +300,16 @@ from open_webui.config import (
|
||||
GOOGLE_PSE_ENGINE_ID,
|
||||
GOOGLE_DRIVE_CLIENT_ID,
|
||||
GOOGLE_DRIVE_API_KEY,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
ENABLE_ONEDRIVE_PERSONAL,
|
||||
ENABLE_ONEDRIVE_BUSINESS,
|
||||
ENABLE_RAG_HYBRID_SEARCH,
|
||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||
ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
UPLOAD_DIR,
|
||||
EXTERNAL_WEB_SEARCH_URL,
|
||||
EXTERNAL_WEB_SEARCH_API_KEY,
|
||||
@@ -327,6 +334,7 @@ from open_webui.config import (
|
||||
ENABLE_MESSAGE_RATING,
|
||||
ENABLE_USER_WEBHOOKS,
|
||||
ENABLE_EVALUATION_ARENA_MODELS,
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
USER_PERMISSIONS,
|
||||
DEFAULT_USER_ROLE,
|
||||
PENDING_USER_OVERLAY_CONTENT,
|
||||
@@ -375,6 +383,7 @@ from open_webui.config import (
|
||||
RESPONSE_WATERMARK,
|
||||
# Admin
|
||||
ENABLE_ADMIN_CHAT_ACCESS,
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
ENABLE_ADMIN_EXPORT,
|
||||
# Tasks
|
||||
TASK_MODEL,
|
||||
@@ -438,6 +447,7 @@ from open_webui.utils.models import (
|
||||
get_all_models,
|
||||
get_all_base_models,
|
||||
check_model_access,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
@@ -463,6 +473,7 @@ from open_webui.utils.redis import get_redis_connection
|
||||
from open_webui.tasks import (
|
||||
redis_task_command_listener,
|
||||
list_task_ids_by_item_id,
|
||||
create_task,
|
||||
stop_task,
|
||||
list_tasks,
|
||||
) # Import from tasks.py
|
||||
@@ -586,6 +597,7 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
oauth_manager = OAuthManager(app)
|
||||
app.state.oauth_manager = oauth_manager
|
||||
|
||||
app.state.instance_id = None
|
||||
app.state.config = AppConfig(
|
||||
@@ -805,8 +817,13 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
|
||||
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
|
||||
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
|
||||
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
|
||||
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
|
||||
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
|
||||
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
|
||||
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
|
||||
app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND
|
||||
app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE
|
||||
app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE
|
||||
app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
|
||||
app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE
|
||||
app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
|
||||
@@ -853,7 +870,10 @@ app.state.config.WEB_SEARCH_ENGINE = WEB_SEARCH_ENGINE
|
||||
app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = WEB_SEARCH_DOMAIN_FILTER_LIST
|
||||
app.state.config.WEB_SEARCH_RESULT_COUNT = WEB_SEARCH_RESULT_COUNT
|
||||
app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS
|
||||
|
||||
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
|
||||
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
|
||||
|
||||
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
|
||||
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||
@@ -916,14 +936,19 @@ try:
|
||||
app.state.config.RAG_EMBEDDING_MODEL,
|
||||
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
|
||||
)
|
||||
|
||||
app.state.rf = get_rf(
|
||||
app.state.config.RAG_RERANKING_ENGINE,
|
||||
app.state.config.RAG_RERANKING_MODEL,
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE,
|
||||
)
|
||||
if (
|
||||
app.state.config.ENABLE_RAG_HYBRID_SEARCH
|
||||
and not app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||
):
|
||||
app.state.rf = get_rf(
|
||||
app.state.config.RAG_RERANKING_ENGINE,
|
||||
app.state.config.RAG_RERANKING_MODEL,
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE,
|
||||
)
|
||||
else:
|
||||
app.state.rf = None
|
||||
except Exception as e:
|
||||
log.error(f"Error updating models: {e}")
|
||||
pass
|
||||
@@ -1006,6 +1031,7 @@ app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
|
||||
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
|
||||
|
||||
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
|
||||
app.state.config.IMAGES_OPENAI_API_VERSION = IMAGES_OPENAI_API_VERSION
|
||||
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
|
||||
|
||||
app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL
|
||||
@@ -1265,29 +1291,6 @@ if audit_level != AuditLevel.NONE:
|
||||
async def get_models(
|
||||
request: Request, refresh: bool = False, user=Depends(get_verified_user)
|
||||
):
|
||||
def get_filtered_models(models, user):
|
||||
filtered_models = []
|
||||
for model in models:
|
||||
if model.get("arena"):
|
||||
if has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("access_control", {}),
|
||||
):
|
||||
filtered_models.append(model)
|
||||
continue
|
||||
|
||||
model_info = Models.get_model_by_id(model["id"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
|
||||
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||
|
||||
models = []
|
||||
@@ -1317,12 +1320,13 @@ async def get_models(
|
||||
model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)}
|
||||
# Sort models by order list priority, with fallback for those not in the list
|
||||
models.sort(
|
||||
key=lambda x: (model_order_dict.get(x["id"], float("inf")), x["name"])
|
||||
key=lambda model: (
|
||||
model_order_dict.get(model.get("id", ""), float("inf")),
|
||||
(model.get("name", "") or ""),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter out models that the user does not have access to
|
||||
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
models = get_filtered_models(models, user)
|
||||
models = get_filtered_models(models, user)
|
||||
|
||||
log.debug(
|
||||
f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
|
||||
@@ -1392,7 +1396,9 @@ async def chat_completion(
|
||||
model_info = Models.get_model_by_id(model_id)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
|
||||
if not BYPASS_MODEL_ACCESS_CONTROL and (
|
||||
user.role != "admin" or not BYPASS_ADMIN_ACCESS_CONTROL
|
||||
):
|
||||
try:
|
||||
check_model_access(user, model)
|
||||
except Exception as e:
|
||||
@@ -1412,11 +1418,15 @@ async def chat_completion(
|
||||
stream_delta_chunk_size = form_data.get("params", {}).get(
|
||||
"stream_delta_chunk_size"
|
||||
)
|
||||
reasoning_tags = form_data.get("params", {}).get("reasoning_tags")
|
||||
|
||||
# Model Params
|
||||
if model_info_params.get("stream_delta_chunk_size"):
|
||||
stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size")
|
||||
|
||||
if model_info_params.get("reasoning_tags") is not None:
|
||||
reasoning_tags = model_info_params.get("reasoning_tags")
|
||||
|
||||
metadata = {
|
||||
"user_id": user.id,
|
||||
"chat_id": form_data.pop("chat_id", None),
|
||||
@@ -1432,6 +1442,7 @@ async def chat_completion(
|
||||
"direct": model_item.get("direct", False),
|
||||
"params": {
|
||||
"stream_delta_chunk_size": stream_delta_chunk_size,
|
||||
"reasoning_tags": reasoning_tags,
|
||||
"function_calling": (
|
||||
"native"
|
||||
if (
|
||||
@@ -1444,66 +1455,96 @@ async def chat_completion(
|
||||
}
|
||||
|
||||
if metadata.get("chat_id") and (user and user.role != "admin"):
|
||||
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
|
||||
if chat is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
if metadata["chat_id"] != "local":
|
||||
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
|
||||
if chat is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
request.state.metadata = metadata
|
||||
form_data["metadata"] = metadata
|
||||
|
||||
form_data, metadata, events = await process_chat_payload(
|
||||
request, form_data, user, metadata, model
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Error processing chat payload: {e}")
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
# Update the chat message with the error
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"error": {"content": str(e)},
|
||||
},
|
||||
)
|
||||
|
||||
log.debug(f"Error processing chat metadata: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await chat_completion_handler(request, form_data, user)
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"model": model_id,
|
||||
},
|
||||
async def process_chat(request, form_data, user, metadata, model):
|
||||
try:
|
||||
form_data, metadata, events = await process_chat_payload(
|
||||
request, form_data, user, metadata, model
|
||||
)
|
||||
|
||||
return await process_chat_response(
|
||||
request, response, form_data, user, metadata, model, events, tasks
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Error in chat completion: {e}")
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
# Update the chat message with the error
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"error": {"content": str(e)},
|
||||
},
|
||||
)
|
||||
response = await chat_completion_handler(request, form_data, user)
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
try:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"model": model_id,
|
||||
},
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
return await process_chat_response(
|
||||
request, response, form_data, user, metadata, model, events, tasks
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
log.info("Chat processing was cancelled")
|
||||
try:
|
||||
event_emitter = get_event_emitter(metadata)
|
||||
await event_emitter(
|
||||
{"type": "chat:tasks:cancel"},
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug(f"Error processing chat payload: {e}")
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
# Update the chat message with the error
|
||||
try:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"error": {"content": str(e)},
|
||||
},
|
||||
)
|
||||
|
||||
event_emitter = get_event_emitter(metadata)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:message:error",
|
||||
"data": {"error": {"content": str(e)}},
|
||||
}
|
||||
)
|
||||
await event_emitter(
|
||||
{"type": "chat:tasks:cancel"},
|
||||
)
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
if (
|
||||
metadata.get("session_id")
|
||||
and metadata.get("chat_id")
|
||||
and metadata.get("message_id")
|
||||
):
|
||||
# Asynchronous Chat Processing
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
process_chat(request, form_data, user, metadata, model),
|
||||
id=metadata["chat_id"],
|
||||
)
|
||||
return {"status": True, "task_id": task_id}
|
||||
else:
|
||||
return await process_chat(request, form_data, user, metadata, model)
|
||||
|
||||
|
||||
# Alias for chat_completion (Legacy)
|
||||
@@ -1589,8 +1630,18 @@ async def list_tasks_by_chat_id_endpoint(
|
||||
@app.get("/api/config")
|
||||
async def get_app_config(request: Request):
|
||||
user = None
|
||||
if "token" in request.cookies:
|
||||
token = None
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header:
|
||||
cred = get_http_authorization_cred(auth_header)
|
||||
if cred:
|
||||
token = cred.credentials
|
||||
|
||||
if not token and "token" in request.cookies:
|
||||
token = request.cookies.get("token")
|
||||
|
||||
if token:
|
||||
try:
|
||||
data = decode_token(token)
|
||||
except Exception as e:
|
||||
@@ -1647,6 +1698,14 @@ async def get_app_config(request: Request):
|
||||
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
|
||||
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
|
||||
**(
|
||||
{
|
||||
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
|
||||
"enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
|
||||
}
|
||||
if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if user is not None
|
||||
else {}
|
||||
@@ -1704,6 +1763,16 @@ async def get_app_config(request: Request):
|
||||
}
|
||||
if user is not None and (user.role in ["admin", "user"])
|
||||
else {
|
||||
**(
|
||||
{
|
||||
"ui": {
|
||||
"pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
"pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
}
|
||||
}
|
||||
if user and user.role == "pending"
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"metadata": {
|
||||
@@ -1717,7 +1786,7 @@ async def get_app_config(request: Request):
|
||||
}
|
||||
if app.state.LICENSE_METADATA
|
||||
else {}
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add indexes
|
||||
|
||||
Revision ID: 018012973d35
|
||||
Revises: d31026856c01
|
||||
Create Date: 2025-08-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "018012973d35"
|
||||
down_revision = "d31026856c01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Chat table indexes
|
||||
op.create_index("folder_id_idx", "chat", ["folder_id"])
|
||||
op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"])
|
||||
op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"])
|
||||
op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"])
|
||||
op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"])
|
||||
|
||||
# Tag table index
|
||||
op.create_index("user_id_idx", "tag", ["user_id"])
|
||||
|
||||
# Function table index
|
||||
op.create_index("is_global_idx", "function", ["is_global"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Chat table indexes
|
||||
op.drop_index("folder_id_idx", table_name="chat")
|
||||
op.drop_index("user_id_pinned_idx", table_name="chat")
|
||||
op.drop_index("user_id_archived_idx", table_name="chat")
|
||||
op.drop_index("updated_at_user_id_idx", table_name="chat")
|
||||
op.drop_index("folder_id_user_id_idx", table_name="chat")
|
||||
|
||||
# Tag table index
|
||||
op.drop_index("user_id_idx", table_name="tag")
|
||||
|
||||
# Function table index
|
||||
|
||||
op.drop_index("is_global_idx", table_name="function")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Add oauth_session table
|
||||
|
||||
Revision ID: 38d63c18f30f
|
||||
Revises: 3af16a1c9fb6
|
||||
Create Date: 2025-09-08 14:19:59.583921
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "38d63c18f30f"
|
||||
down_revision: Union[str, None] = "3af16a1c9fb6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create oauth_session table
|
||||
op.create_table(
|
||||
"oauth_session",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("provider", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# Create indexes for better performance
|
||||
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
|
||||
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
|
||||
op.create_index(
|
||||
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes first
|
||||
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
|
||||
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
|
||||
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
|
||||
|
||||
# Drop the table
|
||||
op.drop_table("oauth_session")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""update user table
|
||||
|
||||
Revision ID: 3af16a1c9fb6
|
||||
Revises: 018012973d35
|
||||
Create Date: 2025-08-21 02:07:18.078283
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3af16a1c9fb6"
|
||||
down_revision: Union[str, None] = "018012973d35"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user", sa.Column("username", sa.String(length=50), nullable=True))
|
||||
op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
|
||||
op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
|
||||
op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "username")
|
||||
op.drop_column("user", "bio")
|
||||
op.drop_column("user", "gender")
|
||||
op.drop_column("user", "date_of_birth")
|
||||
@@ -73,11 +73,6 @@ class ProfileImageUrlForm(BaseModel):
|
||||
profile_image_url: str
|
||||
|
||||
|
||||
class UpdateProfileForm(BaseModel):
|
||||
profile_image_url: str
|
||||
name: str
|
||||
|
||||
|
||||
class UpdatePasswordForm(BaseModel):
|
||||
password: str
|
||||
new_password: str
|
||||
|
||||
@@ -10,7 +10,7 @@ from open_webui.models.folders import Folders
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, Index
|
||||
from sqlalchemy import or_, func, select, and_, text
|
||||
from sqlalchemy.sql import exists
|
||||
from sqlalchemy.sql.expression import bindparam
|
||||
@@ -41,6 +41,20 @@ class Chat(Base):
|
||||
meta = Column(JSON, server_default="{}")
|
||||
folder_id = Column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Performance indexes for common queries
|
||||
# WHERE folder_id = ...
|
||||
Index("folder_id_idx", "folder_id"),
|
||||
# WHERE user_id = ... AND pinned = ...
|
||||
Index("user_id_pinned_idx", "user_id", "pinned"),
|
||||
# WHERE user_id = ... AND archived = ...
|
||||
Index("user_id_archived_idx", "user_id", "archived"),
|
||||
# WHERE user_id = ... ORDER BY updated_at DESC
|
||||
Index("updated_at_user_id_idx", "updated_at", "user_id"),
|
||||
# WHERE folder_id = ... AND user_id = ...
|
||||
Index("folder_id_user_id_idx", "folder_id", "user_id"),
|
||||
)
|
||||
|
||||
|
||||
class ChatModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -222,7 +236,7 @@ class ChatTable:
|
||||
|
||||
return chat.chat.get("title", "New Chat")
|
||||
|
||||
def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
|
||||
def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
|
||||
chat = self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
@@ -147,6 +147,15 @@ class FilesTable:
|
||||
with get_db() as db:
|
||||
return [FileModel.model_validate(file) for file in db.query(File).all()]
|
||||
|
||||
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
|
||||
file = self.get_file_by_id(id)
|
||||
if not file:
|
||||
return False
|
||||
if file.user_id == user_id:
|
||||
return True
|
||||
# Implement additional access control logic here as needed
|
||||
return False
|
||||
|
||||
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
|
||||
with get_db() as db:
|
||||
return [
|
||||
|
||||
@@ -58,6 +58,14 @@ class FolderModel(BaseModel):
|
||||
class FolderForm(BaseModel):
|
||||
name: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class FolderUpdateForm(BaseModel):
|
||||
name: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
@@ -191,7 +199,7 @@ class FolderTable:
|
||||
return
|
||||
|
||||
def update_folder_by_id_and_user_id(
|
||||
self, id: str, user_id: str, form_data: FolderForm
|
||||
self, id: str, user_id: str, form_data: FolderUpdateForm
|
||||
) -> Optional[FolderModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
@@ -222,8 +230,13 @@ class FolderTable:
|
||||
**form_data["data"],
|
||||
}
|
||||
|
||||
folder.updated_at = int(time.time())
|
||||
if "meta" in form_data:
|
||||
folder.meta = {
|
||||
**(folder.meta or {}),
|
||||
**form_data["meta"],
|
||||
}
|
||||
|
||||
folder.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return FolderModel.model_validate(folder)
|
||||
|
||||
@@ -6,7 +6,7 @@ from open_webui.internal.db import Base, JSONField, get_db
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
@@ -31,10 +31,13 @@ class Function(Base):
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
__table_args__ = (Index("is_global_idx", "is_global"),)
|
||||
|
||||
|
||||
class FunctionMeta(BaseModel):
|
||||
description: Optional[str] = None
|
||||
manifest: Optional[dict] = {}
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class FunctionModel(BaseModel):
|
||||
@@ -52,6 +55,22 @@ class FunctionModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FunctionWithValvesModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
meta: FunctionMeta
|
||||
valves: Optional[dict] = None
|
||||
is_active: bool = False
|
||||
is_global: bool = False
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
@@ -109,8 +128,8 @@ class FunctionsTable:
|
||||
return None
|
||||
|
||||
def sync_functions(
|
||||
self, user_id: str, functions: list[FunctionModel]
|
||||
) -> list[FunctionModel]:
|
||||
self, user_id: str, functions: list[FunctionWithValvesModel]
|
||||
) -> list[FunctionWithValvesModel]:
|
||||
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
|
||||
try:
|
||||
with get_db() as db:
|
||||
@@ -164,17 +183,24 @@ class FunctionsTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_functions(self, active_only=False) -> list[FunctionModel]:
|
||||
def get_functions(
|
||||
self, active_only=False, include_valves=False
|
||||
) -> list[FunctionModel | FunctionWithValvesModel]:
|
||||
with get_db() as db:
|
||||
if active_only:
|
||||
functions = db.query(Function).filter_by(is_active=True).all()
|
||||
|
||||
else:
|
||||
functions = db.query(Function).all()
|
||||
|
||||
if include_valves:
|
||||
return [
|
||||
FunctionModel.model_validate(function)
|
||||
for function in db.query(Function).filter_by(is_active=True).all()
|
||||
FunctionWithValvesModel.model_validate(function)
|
||||
for function in functions
|
||||
]
|
||||
else:
|
||||
return [
|
||||
FunctionModel.model_validate(function)
|
||||
for function in db.query(Function).all()
|
||||
FunctionModel.model_validate(function) for function in functions
|
||||
]
|
||||
|
||||
def get_functions_by_type(
|
||||
@@ -235,6 +261,29 @@ class FunctionsTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_function_metadata_by_id(
|
||||
self, id: str, metadata: dict
|
||||
) -> Optional[FunctionModel]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
function = db.get(Function, id)
|
||||
|
||||
if function:
|
||||
if function.meta:
|
||||
function.meta = {**function.meta, **metadata}
|
||||
else:
|
||||
function.meta = metadata
|
||||
|
||||
function.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(function)
|
||||
return self.get_function_by_id(id)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function metadata by id {id}: {e}")
|
||||
return None
|
||||
|
||||
def get_user_valves_by_id_and_user_id(
|
||||
self, id: str, user_id: str
|
||||
) -> Optional[dict]:
|
||||
@@ -250,9 +299,7 @@ class FunctionsTable:
|
||||
|
||||
return user_settings["functions"]["valves"].get(id, {})
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error getting user values by id {id} and user id {user_id}: {e}"
|
||||
)
|
||||
log.exception(f"Error getting user values by id {id} and user id {user_id}")
|
||||
return None
|
||||
|
||||
def update_user_valves_by_id_and_user_id(
|
||||
|
||||
@@ -288,13 +288,17 @@ class GroupTable:
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if not group.user_ids:
|
||||
group.user_ids = []
|
||||
group_user_ids = group.user_ids
|
||||
if not group_user_ids or not isinstance(group_user_ids, list):
|
||||
group_user_ids = []
|
||||
|
||||
group_user_ids = list(set(group_user_ids)) # Deduplicate
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id not in group.user_ids:
|
||||
group.user_ids.append(user_id)
|
||||
if user_id not in group_user_ids:
|
||||
group_user_ids.append(user_id)
|
||||
|
||||
group.user_ids = group_user_ids
|
||||
group.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
@@ -312,14 +316,20 @@ class GroupTable:
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if not group.user_ids:
|
||||
group_user_ids = group.user_ids
|
||||
|
||||
if not group_user_ids or not isinstance(group_user_ids, list):
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id in group.user_ids:
|
||||
group.user_ids.remove(user_id)
|
||||
group_user_ids = list(set(group_user_ids)) # Deduplicate
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id in group_user_ids:
|
||||
group_user_ids.remove(user_id)
|
||||
|
||||
group.user_ids = group_user_ids
|
||||
group.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
@@ -8,6 +8,7 @@ from open_webui.internal.db import Base, get_db
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.models.files import FileMetadataResponse
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
|
||||
@@ -128,11 +129,18 @@ class KnowledgeTable:
|
||||
|
||||
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
|
||||
with get_db() as db:
|
||||
knowledge_bases = []
|
||||
for knowledge in (
|
||||
all_knowledge = (
|
||||
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
||||
):
|
||||
user = Users.get_user_by_id(knowledge.user_id)
|
||||
)
|
||||
|
||||
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
knowledge_bases = []
|
||||
for knowledge in all_knowledge:
|
||||
user = users_dict.get(knowledge.user_id)
|
||||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
@@ -143,15 +151,27 @@ class KnowledgeTable:
|
||||
)
|
||||
return knowledge_bases
|
||||
|
||||
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
|
||||
knowledge = self.get_knowledge_by_id(id)
|
||||
if not knowledge:
|
||||
return False
|
||||
if knowledge.user_id == user_id:
|
||||
return True
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
|
||||
|
||||
def get_knowledge_bases_by_user_id(
|
||||
self, user_id: str, permission: str = "write"
|
||||
) -> list[KnowledgeUserModel]:
|
||||
knowledge_bases = self.get_knowledge_bases()
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
return [
|
||||
knowledge_base
|
||||
for knowledge_base in knowledge_bases
|
||||
if knowledge_base.user_id == user_id
|
||||
or has_access(user_id, permission, knowledge_base.access_control)
|
||||
or has_access(
|
||||
user_id, permission, knowledge_base.access_control, user_group_ids
|
||||
)
|
||||
]
|
||||
|
||||
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
|
||||
|
||||
@@ -201,8 +201,14 @@ class MessageTable:
|
||||
with get_db() as db:
|
||||
message = db.get(Message, id)
|
||||
message.content = form_data.content
|
||||
message.data = form_data.data
|
||||
message.meta = form_data.meta
|
||||
message.data = {
|
||||
**(message.data if message.data else {}),
|
||||
**(form_data.data if form_data.data else {}),
|
||||
}
|
||||
message.meta = {
|
||||
**(message.meta if message.meta else {}),
|
||||
**(form_data.meta if form_data.meta else {}),
|
||||
}
|
||||
message.updated_at = int(time.time_ns())
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
from open_webui.internal.db import Base, JSONField, get_db
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
|
||||
@@ -175,9 +176,16 @@ class ModelsTable:
|
||||
|
||||
def get_models(self) -> list[ModelUserResponse]:
|
||||
with get_db() as db:
|
||||
all_models = db.query(Model).filter(Model.base_model_id != None).all()
|
||||
|
||||
user_ids = list(set(model.user_id for model in all_models))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
models = []
|
||||
for model in db.query(Model).filter(Model.base_model_id != None).all():
|
||||
user = Users.get_user_by_id(model.user_id)
|
||||
for model in all_models:
|
||||
user = users_dict.get(model.user_id)
|
||||
models.append(
|
||||
ModelUserResponse.model_validate(
|
||||
{
|
||||
@@ -199,11 +207,12 @@ class ModelsTable:
|
||||
self, user_id: str, permission: str = "write"
|
||||
) -> list[ModelUserResponse]:
|
||||
models = self.get_models()
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
return [
|
||||
model
|
||||
for model in models
|
||||
if model.user_id == user_id
|
||||
or has_access(user_id, permission, model.access_control)
|
||||
or has_access(user_id, permission, model.access_control, user_group_ids)
|
||||
]
|
||||
|
||||
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
@@ -96,20 +97,32 @@ class NoteTable:
|
||||
db.commit()
|
||||
return note
|
||||
|
||||
def get_notes(self) -> list[NoteModel]:
|
||||
def get_notes(
|
||||
self, skip: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
|
||||
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_user_id(
|
||||
self, user_id: str, permission: str = "write"
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = "write",
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
notes = self.get_notes()
|
||||
notes = self.get_notes(skip=skip, limit=limit)
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
return [
|
||||
note
|
||||
for note in notes
|
||||
if note.user_id == user_id
|
||||
or has_access(user_id, permission, note.access_control)
|
||||
or has_access(user_id, permission, note.access_control, user_group_ids)
|
||||
]
|
||||
|
||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text, Index
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
####################
|
||||
# DB MODEL
|
||||
####################
|
||||
|
||||
|
||||
class OAuthSession(Base):
|
||||
__tablename__ = "oauth_session"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, nullable=False)
|
||||
provider = Column(Text, nullable=False)
|
||||
token = Column(
|
||||
Text, nullable=False
|
||||
) # JSON with access_token, id_token, refresh_token
|
||||
expires_at = Column(BigInteger, nullable=False)
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
# Add indexes for better performance
|
||||
__table_args__ = (
|
||||
Index("idx_oauth_session_user_id", "user_id"),
|
||||
Index("idx_oauth_session_expires_at", "expires_at"),
|
||||
Index("idx_oauth_session_user_provider", "user_id", "provider"),
|
||||
)
|
||||
|
||||
|
||||
class OAuthSessionModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
provider: str
|
||||
token: dict
|
||||
expires_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class OAuthSessionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
provider: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class OAuthSessionTable:
|
||||
def __init__(self):
|
||||
self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
|
||||
if not self.encryption_key:
|
||||
raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
|
||||
|
||||
# check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
|
||||
if len(self.encryption_key) != 44:
|
||||
key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
|
||||
self.encryption_key = base64.urlsafe_b64encode(key_bytes)
|
||||
else:
|
||||
self.encryption_key = self.encryption_key.encode()
|
||||
|
||||
try:
|
||||
self.fernet = Fernet(self.encryption_key)
|
||||
except Exception as e:
|
||||
log.error(f"Error initializing Fernet with provided key: {e}")
|
||||
raise
|
||||
|
||||
def _encrypt_token(self, token) -> str:
|
||||
"""Encrypt OAuth tokens for storage"""
|
||||
try:
|
||||
token_json = json.dumps(token)
|
||||
encrypted = self.fernet.encrypt(token_json.encode()).decode()
|
||||
return encrypted
|
||||
except Exception as e:
|
||||
log.error(f"Error encrypting tokens: {e}")
|
||||
raise
|
||||
|
||||
def _decrypt_token(self, token: str):
|
||||
"""Decrypt OAuth tokens from storage"""
|
||||
try:
|
||||
decrypted = self.fernet.decrypt(token.encode()).decode()
|
||||
return json.loads(decrypted)
|
||||
except Exception as e:
|
||||
log.error(f"Error decrypting tokens: {e}")
|
||||
raise
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
user_id: str,
|
||||
provider: str,
|
||||
token: dict,
|
||||
) -> Optional[OAuthSessionModel]:
|
||||
"""Create a new OAuth session"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
current_time = int(time.time())
|
||||
id = str(uuid.uuid4())
|
||||
|
||||
result = OAuthSession(
|
||||
**{
|
||||
"id": id,
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
"token": self._encrypt_token(token),
|
||||
"expires_at": token.get("expires_at"),
|
||||
"created_at": current_time,
|
||||
"updated_at": current_time,
|
||||
}
|
||||
)
|
||||
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
if result:
|
||||
result.token = token # Return decrypted token
|
||||
return OAuthSessionModel.model_validate(result)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error creating OAuth session: {e}")
|
||||
return None
|
||||
|
||||
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
|
||||
"""Get OAuth session by ID"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
session = db.query(OAuthSession).filter_by(id=session_id).first()
|
||||
if session:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth session by ID: {e}")
|
||||
return None
|
||||
|
||||
def get_session_by_id_and_user_id(
|
||||
self, session_id: str, user_id: str
|
||||
) -> Optional[OAuthSessionModel]:
|
||||
"""Get OAuth session by ID and user ID"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
session = (
|
||||
db.query(OAuthSession)
|
||||
.filter_by(id=session_id, user_id=user_id)
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth session by ID: {e}")
|
||||
return None
|
||||
|
||||
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
|
||||
"""Get all OAuth sessions for a user"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
|
||||
|
||||
results = []
|
||||
for session in sessions:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
results.append(OAuthSessionModel.model_validate(session))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth sessions by user ID: {e}")
|
||||
return []
|
||||
|
||||
def update_session_by_id(
|
||||
self, session_id: str, token: dict
|
||||
) -> Optional[OAuthSessionModel]:
|
||||
"""Update OAuth session tokens"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
current_time = int(time.time())
|
||||
|
||||
db.query(OAuthSession).filter_by(id=session_id).update(
|
||||
{
|
||||
"token": self._encrypt_token(token),
|
||||
"expires_at": token.get("expires_at"),
|
||||
"updated_at": current_time,
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
session = db.query(OAuthSession).filter_by(id=session_id).first()
|
||||
|
||||
if session:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error updating OAuth session tokens: {e}")
|
||||
return None
|
||||
|
||||
def delete_session_by_id(self, session_id: str) -> bool:
|
||||
"""Delete an OAuth session"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
result = db.query(OAuthSession).filter_by(id=session_id).delete()
|
||||
db.commit()
|
||||
return result > 0
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting OAuth session: {e}")
|
||||
return False
|
||||
|
||||
def delete_sessions_by_user_id(self, user_id: str) -> bool:
|
||||
"""Delete all OAuth sessions for a user"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting OAuth sessions by user ID: {e}")
|
||||
return False
|
||||
|
||||
|
||||
OAuthSessions = OAuthSessionTable()
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -103,10 +104,16 @@ class PromptsTable:
|
||||
|
||||
def get_prompts(self) -> list[PromptUserResponse]:
|
||||
with get_db() as db:
|
||||
prompts = []
|
||||
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
|
||||
|
||||
for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all():
|
||||
user = Users.get_user_by_id(prompt.user_id)
|
||||
user_ids = list(set(prompt.user_id for prompt in all_prompts))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
prompts = []
|
||||
for prompt in all_prompts:
|
||||
user = users_dict.get(prompt.user_id)
|
||||
prompts.append(
|
||||
PromptUserResponse.model_validate(
|
||||
{
|
||||
@@ -122,12 +129,13 @@ class PromptsTable:
|
||||
self, user_id: str, permission: str = "write"
|
||||
) -> list[PromptUserResponse]:
|
||||
prompts = self.get_prompts()
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
|
||||
return [
|
||||
prompt
|
||||
for prompt in prompts
|
||||
if prompt.user_id == user_id
|
||||
or has_access(user_id, permission, prompt.access_control)
|
||||
or has_access(user_id, permission, prompt.access_control, user_group_ids)
|
||||
]
|
||||
|
||||
def update_prompt_by_command(
|
||||
|
||||
@@ -8,7 +8,7 @@ from open_webui.internal.db import Base, get_db
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint
|
||||
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
@@ -24,6 +24,11 @@ class Tag(Base):
|
||||
user_id = Column(String)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),
|
||||
Index("user_id_idx", "user_id"),
|
||||
)
|
||||
|
||||
# Unique constraint ensuring (id, user_id) is unique, not just the `id` column
|
||||
__table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_db
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
@@ -144,9 +146,16 @@ class ToolsTable:
|
||||
|
||||
def get_tools(self) -> list[ToolUserModel]:
|
||||
with get_db() as db:
|
||||
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
|
||||
|
||||
user_ids = list(set(tool.user_id for tool in all_tools))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
tools = []
|
||||
for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all():
|
||||
user = Users.get_user_by_id(tool.user_id)
|
||||
for tool in all_tools:
|
||||
user = users_dict.get(tool.user_id)
|
||||
tools.append(
|
||||
ToolUserModel.model_validate(
|
||||
{
|
||||
@@ -161,12 +170,13 @@ class ToolsTable:
|
||||
self, user_id: str, permission: str = "write"
|
||||
) -> list[ToolUserModel]:
|
||||
tools = self.get_tools()
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
|
||||
return [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.user_id == user_id
|
||||
or has_access(user_id, permission, tool.access_control)
|
||||
or has_access(user_id, permission, tool.access_control, user_group_ids)
|
||||
]
|
||||
|
||||
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
|
||||
@@ -175,7 +185,7 @@ class ToolsTable:
|
||||
tool = db.get(Tool, id)
|
||||
return tool.valves if tool.valves else {}
|
||||
except Exception as e:
|
||||
log.exception(f"Error getting tool valves by id {id}: {e}")
|
||||
log.exception(f"Error getting tool valves by id {id}")
|
||||
return None
|
||||
|
||||
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
|
||||
|
||||
@@ -4,14 +4,17 @@ from typing import Optional
|
||||
from open_webui.internal.db import Base, JSONField, get_db
|
||||
|
||||
|
||||
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.misc import throttle
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text
|
||||
from sqlalchemy import BigInteger, Column, String, Text, Date
|
||||
from sqlalchemy import or_
|
||||
|
||||
import datetime
|
||||
|
||||
####################
|
||||
# User DB Schema
|
||||
@@ -23,20 +26,28 @@ class User(Base):
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String)
|
||||
|
||||
email = Column(String)
|
||||
username = Column(String(50), nullable=True)
|
||||
|
||||
role = Column(String)
|
||||
profile_image_url = Column(Text)
|
||||
|
||||
last_active_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
bio = Column(Text, nullable=True)
|
||||
gender = Column(Text, nullable=True)
|
||||
date_of_birth = Column(Date, nullable=True)
|
||||
|
||||
info = Column(JSONField, nullable=True)
|
||||
settings = Column(JSONField, nullable=True)
|
||||
|
||||
api_key = Column(String, nullable=True, unique=True)
|
||||
settings = Column(JSONField, nullable=True)
|
||||
info = Column(JSONField, nullable=True)
|
||||
|
||||
oauth_sub = Column(Text, unique=True)
|
||||
|
||||
last_active_at = Column(BigInteger)
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
ui: Optional[dict] = {}
|
||||
@@ -47,20 +58,27 @@ class UserSettings(BaseModel):
|
||||
class UserModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
email: str
|
||||
username: Optional[str] = None
|
||||
|
||||
role: str = "pending"
|
||||
profile_image_url: str
|
||||
|
||||
bio: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
info: Optional[dict] = None
|
||||
settings: Optional[UserSettings] = None
|
||||
|
||||
api_key: Optional[str] = None
|
||||
oauth_sub: Optional[str] = None
|
||||
|
||||
last_active_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
api_key: Optional[str] = None
|
||||
settings: Optional[UserSettings] = None
|
||||
info: Optional[dict] = None
|
||||
|
||||
oauth_sub: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -69,6 +87,14 @@ class UserModel(BaseModel):
|
||||
####################
|
||||
|
||||
|
||||
class UpdateProfileForm(BaseModel):
|
||||
profile_image_url: str
|
||||
name: str
|
||||
bio: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserModel]
|
||||
total: int
|
||||
@@ -81,11 +107,21 @@ class UserInfoResponse(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class UserIdNameResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class UserInfoListResponse(BaseModel):
|
||||
users: list[UserInfoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserIdNameListResponse(BaseModel):
|
||||
users: list[UserIdNameResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -184,7 +220,7 @@ class UsersTable:
|
||||
filter: Optional[dict] = None,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> UserListResponse:
|
||||
) -> dict:
|
||||
with get_db() as db:
|
||||
query = db.query(User)
|
||||
|
||||
@@ -311,6 +347,7 @@ class UsersTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
@@ -346,7 +383,8 @@ class UsersTable:
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
return UserModel.model_validate(user)
|
||||
# return UserModel(**user.dict())
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
|
||||
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
|
||||
|
||||
@@ -64,7 +64,7 @@ class DatalabMarkerLoader:
|
||||
return mime_map.get(ext, "application/octet-stream")
|
||||
|
||||
def check_marker_request_status(self, request_id: str) -> dict:
|
||||
url = f"{self.api_base_url}/marker/{request_id}"
|
||||
url = f"{self.api_base_url}/{request_id}"
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
@@ -111,7 +111,7 @@ class DatalabMarkerLoader:
|
||||
with open(self.file_path, "rb") as f:
|
||||
files = {"file": (filename, f, mime_type)}
|
||||
response = requests.post(
|
||||
f"{self.api_base_url}/marker",
|
||||
f"{self.api_base_url}",
|
||||
data=form_data,
|
||||
files=files,
|
||||
headers=headers,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import requests
|
||||
import logging, os
|
||||
from typing import Iterator, List, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
from langchain_core.document_loaders import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
@@ -37,7 +38,7 @@ class ExternalDocumentLoader(BaseLoader):
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
try:
|
||||
headers["X-Filename"] = os.path.basename(self.file_path)
|
||||
headers["X-Filename"] = quote(os.path.basename(self.file_path))
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import ftfy
|
||||
import sys
|
||||
import json
|
||||
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from langchain_community.document_loaders import (
|
||||
AzureAIDocumentIntelligenceLoader,
|
||||
BSHTMLLoader,
|
||||
@@ -147,7 +148,7 @@ class DoclingLoader:
|
||||
)
|
||||
}
|
||||
|
||||
params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
|
||||
params = {"image_export_mode": "placeholder"}
|
||||
|
||||
if self.params:
|
||||
if self.params.get("do_picture_description"):
|
||||
@@ -173,7 +174,15 @@ class DoclingLoader:
|
||||
self.params.get("picture_description_api", {})
|
||||
)
|
||||
|
||||
if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
|
||||
params["do_ocr"] = self.params.get("do_ocr")
|
||||
|
||||
params["force_ocr"] = self.params.get("force_ocr")
|
||||
|
||||
if (
|
||||
self.params.get("do_ocr")
|
||||
and self.params.get("ocr_engine")
|
||||
and self.params.get("ocr_lang")
|
||||
):
|
||||
params["ocr_engine"] = self.params.get("ocr_engine")
|
||||
params["ocr_lang"] = [
|
||||
lang.strip()
|
||||
@@ -181,6 +190,15 @@ class DoclingLoader:
|
||||
if lang.strip()
|
||||
]
|
||||
|
||||
if self.params.get("pdf_backend"):
|
||||
params["pdf_backend"] = self.params.get("pdf_backend")
|
||||
|
||||
if self.params.get("table_mode"):
|
||||
params["table_mode"] = self.params.get("table_mode")
|
||||
|
||||
if self.params.get("pipeline"):
|
||||
params["pipeline"] = self.params.get("pipeline")
|
||||
|
||||
endpoint = f"{self.url}/v1/convert/file"
|
||||
r = requests.post(endpoint, files=files, data=params)
|
||||
|
||||
@@ -283,7 +301,7 @@ class Loader:
|
||||
):
|
||||
api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "")
|
||||
if not api_base_url or api_base_url.strip() == "":
|
||||
api_base_url = "https://www.datalab.to/api/v1"
|
||||
api_base_url = "https://www.datalab.to/api/v1/marker" # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349
|
||||
|
||||
loader = DatalabMarkerLoader(
|
||||
file_path=file_path,
|
||||
@@ -327,7 +345,6 @@ class Loader:
|
||||
elif (
|
||||
self.engine == "document_intelligence"
|
||||
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
|
||||
and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != ""
|
||||
and (
|
||||
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
|
||||
or file_content_type
|
||||
@@ -340,11 +357,18 @@ class Loader:
|
||||
]
|
||||
)
|
||||
):
|
||||
loader = AzureAIDocumentIntelligenceLoader(
|
||||
file_path=file_path,
|
||||
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
|
||||
)
|
||||
if self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "":
|
||||
loader = AzureAIDocumentIntelligenceLoader(
|
||||
file_path=file_path,
|
||||
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
|
||||
)
|
||||
else:
|
||||
loader = AzureAIDocumentIntelligenceLoader(
|
||||
file_path=file_path,
|
||||
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||
azure_credential=DefaultAzureCredential(),
|
||||
)
|
||||
elif (
|
||||
self.engine == "mistral_ocr"
|
||||
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
|
||||
|
||||
@@ -98,10 +98,9 @@ class YoutubeLoader:
|
||||
else:
|
||||
youtube_proxies = None
|
||||
|
||||
transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
|
||||
try:
|
||||
transcript_list = YouTubeTranscriptApi.list_transcripts(
|
||||
self.video_id, proxies=youtube_proxies
|
||||
)
|
||||
transcript_list = transcript_api.list(self.video_id)
|
||||
except Exception as e:
|
||||
log.exception("Loading YouTube transcript failed")
|
||||
return []
|
||||
|
||||
@@ -19,10 +19,13 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.notes import Notes
|
||||
|
||||
from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
|
||||
from open_webui.env import (
|
||||
@@ -124,7 +127,12 @@ def query_doc_with_hybrid_search(
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
try:
|
||||
if not collection_result.documents[0]:
|
||||
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
|
||||
return {"documents": [], "metadatas": [], "distances": []}
|
||||
|
||||
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
|
||||
|
||||
bm25_retriever = BM25Retriever.from_texts(
|
||||
texts=collection_result.documents[0],
|
||||
metadatas=collection_result.metadatas[0],
|
||||
@@ -427,13 +435,14 @@ def get_embedding_function(
|
||||
if isinstance(query, list):
|
||||
embeddings = []
|
||||
for i in range(0, len(query), embedding_batch_size):
|
||||
embeddings.extend(
|
||||
func(
|
||||
query[i : i + embedding_batch_size],
|
||||
prefix=prefix,
|
||||
user=user,
|
||||
)
|
||||
batch_embeddings = func(
|
||||
query[i : i + embedding_batch_size],
|
||||
prefix=prefix,
|
||||
user=user,
|
||||
)
|
||||
|
||||
if isinstance(batch_embeddings, list):
|
||||
embeddings.extend(batch_embeddings)
|
||||
return embeddings
|
||||
else:
|
||||
return func(query, prefix, user)
|
||||
@@ -483,26 +492,39 @@ def get_sources_from_items(
|
||||
|
||||
if item.get("type") == "text":
|
||||
# Raw Text
|
||||
# Used during temporary chat file uploads
|
||||
# Used during temporary chat file uploads or web page & youtube attachements
|
||||
|
||||
if item.get("file"):
|
||||
# if item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [
|
||||
[item.get("file", {}).get("data", {}).get("meta", {})]
|
||||
],
|
||||
}
|
||||
else:
|
||||
# Fallback to item content
|
||||
query_result = {
|
||||
"documents": [[item.get("content")]],
|
||||
"metadatas": [
|
||||
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||
],
|
||||
}
|
||||
if item.get("context") == "full":
|
||||
if item.get("file"):
|
||||
# if item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [[item.get("file", {}).get("meta", {})]],
|
||||
}
|
||||
|
||||
if query_result is None:
|
||||
# Fallback
|
||||
if item.get("collection_name"):
|
||||
# If item has a collection name, use it
|
||||
collection_names.append(item.get("collection_name"))
|
||||
elif item.get("file"):
|
||||
# If item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [[item.get("file", {}).get("meta", {})]],
|
||||
}
|
||||
else:
|
||||
# Fallback to item content
|
||||
query_result = {
|
||||
"documents": [[item.get("content")]],
|
||||
"metadatas": [
|
||||
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||
],
|
||||
}
|
||||
|
||||
elif item.get("type") == "note":
|
||||
# Note Attached
|
||||
@@ -519,6 +541,30 @@ def get_sources_from_items(
|
||||
"metadatas": [[{"file_id": note.id, "name": note.title}]],
|
||||
}
|
||||
|
||||
elif item.get("type") == "chat":
|
||||
# Chat Attached
|
||||
chat = Chats.get_chat_by_id(item.get("id"))
|
||||
|
||||
if chat and (user.role == "admin" or chat.user_id == user.id):
|
||||
messages_map = chat.chat.get("history", {}).get("messages", {})
|
||||
message_id = chat.chat.get("history", {}).get("currentId")
|
||||
|
||||
if messages_map and message_id:
|
||||
# Reconstruct the message list in order
|
||||
message_list = get_message_list(messages_map, message_id)
|
||||
message_history = "\n".join(
|
||||
[
|
||||
f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n"
|
||||
for m in message_list
|
||||
]
|
||||
)
|
||||
|
||||
# User has access to the chat
|
||||
query_result = {
|
||||
"documents": [[message_history]],
|
||||
"metadatas": [[{"file_id": chat.id, "name": chat.title}]],
|
||||
}
|
||||
|
||||
elif item.get("type") == "file":
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
@@ -575,6 +621,7 @@ def get_sources_from_items(
|
||||
|
||||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or knowledge_base.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
):
|
||||
|
||||
@@ -946,6 +993,7 @@ class RerankCompressor(BaseDocumentCompressor):
|
||||
) -> Sequence[Document]:
|
||||
reranking = self.reranking_function is not None
|
||||
|
||||
scores = None
|
||||
if reranking:
|
||||
scores = self.reranking_function(
|
||||
[(query, doc.page_content) for doc in documents]
|
||||
@@ -959,22 +1007,31 @@ class RerankCompressor(BaseDocumentCompressor):
|
||||
)
|
||||
scores = util.cos_sim(query_embedding, document_embedding)[0]
|
||||
|
||||
docs_with_scores = list(
|
||||
zip(documents, scores.tolist() if not isinstance(scores, list) else scores)
|
||||
)
|
||||
if self.r_score:
|
||||
docs_with_scores = [
|
||||
(d, s) for d, s in docs_with_scores if s >= self.r_score
|
||||
]
|
||||
|
||||
result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
|
||||
final_results = []
|
||||
for doc, doc_score in result[: self.top_n]:
|
||||
metadata = doc.metadata
|
||||
metadata["score"] = doc_score
|
||||
doc = Document(
|
||||
page_content=doc.page_content,
|
||||
metadata=metadata,
|
||||
if scores is not None:
|
||||
docs_with_scores = list(
|
||||
zip(
|
||||
documents,
|
||||
scores.tolist() if not isinstance(scores, list) else scores,
|
||||
)
|
||||
)
|
||||
final_results.append(doc)
|
||||
return final_results
|
||||
if self.r_score:
|
||||
docs_with_scores = [
|
||||
(d, s) for d, s in docs_with_scores if s >= self.r_score
|
||||
]
|
||||
|
||||
result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
|
||||
final_results = []
|
||||
for doc, doc_score in result[: self.top_n]:
|
||||
metadata = doc.metadata
|
||||
metadata["score"] = doc_score
|
||||
doc = Document(
|
||||
page_content=doc.page_content,
|
||||
metadata=metadata,
|
||||
)
|
||||
final_results.append(doc)
|
||||
return final_results
|
||||
else:
|
||||
log.warning(
|
||||
"No valid scores found, check your reranking function. Returning original documents."
|
||||
)
|
||||
return documents
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus import FieldSchema, DataType
|
||||
from pymilvus import connections, Collection
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -188,6 +190,8 @@ class MilvusClient(VectorDBBase):
|
||||
return self._result_to_search_result(result)
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
||||
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
# Construct the filter string for querying
|
||||
collection_name = collection_name.replace("-", "_")
|
||||
if not self.has_collection(collection_name):
|
||||
@@ -201,72 +205,36 @@ class MilvusClient(VectorDBBase):
|
||||
for key, value in filter.items()
|
||||
]
|
||||
)
|
||||
max_limit = 16383 # The maximum number of records per request
|
||||
all_results = []
|
||||
if limit is None:
|
||||
# Milvus default limit for query if not specified is 16384, but docs mention iteration.
|
||||
# Let's set a practical high number if "all" is intended, or handle true pagination.
|
||||
# For now, if limit is None, we'll fetch in batches up to a very large number.
|
||||
# This part could be refined based on expected use cases for "get all".
|
||||
# For this function signature, None implies "as many as possible" up to Milvus limits.
|
||||
limit = (
|
||||
16384 * 10
|
||||
) # A large number to signify fetching many, will be capped by actual data or max_limit per call.
|
||||
log.info(
|
||||
f"Limit not specified for query, fetching up to {limit} results in batches."
|
||||
)
|
||||
|
||||
# Initialize offset and remaining to handle pagination
|
||||
offset = 0
|
||||
remaining = limit
|
||||
collection = Collection(f"{self.collection_prefix}_{collection_name}")
|
||||
collection.load()
|
||||
all_results = []
|
||||
|
||||
try:
|
||||
log.info(
|
||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||
)
|
||||
# Loop until there are no more items to fetch or the desired limit is reached
|
||||
while remaining > 0:
|
||||
current_fetch = min(
|
||||
max_limit, remaining if isinstance(remaining, int) else max_limit
|
||||
)
|
||||
log.debug(
|
||||
f"Querying with offset: {offset}, current_fetch: {current_fetch}"
|
||||
)
|
||||
|
||||
results = self.client.query(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
filter=filter_string,
|
||||
output_fields=[
|
||||
"id",
|
||||
"data",
|
||||
"metadata",
|
||||
], # Explicitly list needed fields. Vector not usually needed in query.
|
||||
limit=current_fetch,
|
||||
offset=offset,
|
||||
)
|
||||
iterator = collection.query_iterator(
|
||||
filter=filter_string,
|
||||
output_fields=[
|
||||
"id",
|
||||
"data",
|
||||
"metadata",
|
||||
],
|
||||
limit=limit, # Pass the limit directly; None means no limit.
|
||||
)
|
||||
|
||||
if not results:
|
||||
log.debug("No more results from query.")
|
||||
break
|
||||
|
||||
all_results.extend(results)
|
||||
results_count = len(results)
|
||||
log.debug(f"Fetched {results_count} results in this batch.")
|
||||
|
||||
if isinstance(remaining, int):
|
||||
remaining -= results_count
|
||||
|
||||
offset += results_count
|
||||
|
||||
# Break the loop if the results returned are less than the requested fetch count (means end of data)
|
||||
if results_count < current_fetch:
|
||||
log.debug(
|
||||
"Fetched less than requested, assuming end of results for this query."
|
||||
)
|
||||
while True:
|
||||
result = iterator.next()
|
||||
if not result:
|
||||
iterator.close()
|
||||
break
|
||||
all_results += result
|
||||
|
||||
log.info(f"Total results from query: {len(all_results)}")
|
||||
return self._result_to_get_result([all_results])
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}"
|
||||
|
||||
@@ -37,6 +37,7 @@ from open_webui.retrieval.vector.main import (
|
||||
from open_webui.config import (
|
||||
PGVECTOR_DB_URL,
|
||||
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
||||
PGVECTOR_CREATE_EXTENSION,
|
||||
PGVECTOR_PGCRYPTO,
|
||||
PGVECTOR_PGCRYPTO_KEY,
|
||||
PGVECTOR_POOL_SIZE,
|
||||
@@ -111,11 +112,36 @@ class PgvectorClient(VectorDBBase):
|
||||
|
||||
try:
|
||||
# Ensure the pgvector extension is available
|
||||
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
if PGVECTOR_CREATE_EXTENSION:
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
# Ensure the pgcrypto extension is available for encryption
|
||||
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto;"))
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if not PGVECTOR_PGCRYPTO_KEY:
|
||||
raise ValueError(
|
||||
|
||||
@@ -197,13 +197,23 @@ class S3VectorClient(VectorDBBase):
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
# Insert vectors
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
vectors=vectors,
|
||||
|
||||
# Insert vectors in batches of 500 (S3 Vector API limit)
|
||||
batch_size = 500
|
||||
for i in range(0, len(vectors), batch_size):
|
||||
batch = vectors[i : i + batch_size]
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
vectors=batch,
|
||||
)
|
||||
log.info(
|
||||
f"Inserted batch {i//batch_size + 1}: {len(batch)} vectors into index '{collection_name}'."
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'."
|
||||
)
|
||||
log.info(f"Inserted {len(vectors)} vectors into index '{collection_name}'.")
|
||||
except Exception as e:
|
||||
log.error(f"Error inserting vectors: {e}")
|
||||
raise
|
||||
@@ -258,16 +268,29 @@ class S3VectorClient(VectorDBBase):
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
# Upsert vectors (using put_vectors for upsert semantics)
|
||||
|
||||
# Upsert vectors in batches of 500 (S3 Vector API limit)
|
||||
batch_size = 500
|
||||
for i in range(0, len(vectors), batch_size):
|
||||
batch = vectors[i : i + batch_size]
|
||||
if i == 0: # Log sample info for first batch only
|
||||
log.info(
|
||||
f"Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]['key']}, data_type={type(batch[0]['data']['float32'])}, data_len={len(batch[0]['data']['float32'])}"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
f"Upserting batch {i//batch_size + 1}: {len(batch)} vectors."
|
||||
)
|
||||
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
vectors=batch,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Upserting {len(vectors)} vectors. First vector sample: key={vectors[0]['key']}, data_type={type(vectors[0]['data']['float32'])}, data_len={len(vectors[0]['data']['float32'])}"
|
||||
f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'."
|
||||
)
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
vectors=vectors,
|
||||
)
|
||||
log.info(f"Upserted {len(vectors)} vectors into index '{collection_name}'.")
|
||||
except Exception as e:
|
||||
log.error(f"Error upserting vectors: {e}")
|
||||
raise
|
||||
|
||||
@@ -11,7 +11,10 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_duckduckgo(
|
||||
query: str, count: int, filter_list: Optional[list[str]] = None
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
concurrent_requests: Optional[int] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""
|
||||
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
|
||||
@@ -25,6 +28,9 @@ def search_duckduckgo(
|
||||
# Use the DDGS context manager to create a DDGS object
|
||||
search_results = []
|
||||
with DDGS() as ddgs:
|
||||
if concurrent_requests:
|
||||
ddgs.threads = concurrent_requests
|
||||
|
||||
# Use the ddgs.text() method to perform the search
|
||||
try:
|
||||
search_results = ddgs.text(
|
||||
|
||||
@@ -11,7 +11,7 @@ def get_filtered_results(results, filter_list):
|
||||
return results
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
url = result.get("url") or result.get("link", "")
|
||||
url = result.get("url") or result.get("link", "") or result.get("href", "")
|
||||
if not validators.url(url):
|
||||
continue
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
@@ -517,6 +517,7 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
async with session.get(
|
||||
url,
|
||||
**(self.requests_kwargs | kwargs),
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if self.raise_for_status:
|
||||
response.raise_for_status()
|
||||
@@ -614,7 +615,7 @@ def get_web_loader(
|
||||
WebLoaderClass = SafeWebBaseLoader
|
||||
if WEB_LOADER_ENGINE.value == "playwright":
|
||||
WebLoaderClass = SafePlaywrightURLLoader
|
||||
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
|
||||
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
|
||||
if PLAYWRIGHT_WS_URL.value:
|
||||
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -15,7 +14,7 @@ import aiohttp
|
||||
import aiofiles
|
||||
import requests
|
||||
import mimetypes
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urljoin, quote
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -550,6 +549,11 @@ def transcription_handler(request, file_path, metadata):
|
||||
|
||||
metadata = metadata or {}
|
||||
|
||||
languages = [
|
||||
metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
|
||||
None, # Always fallback to None in case transcription fails
|
||||
]
|
||||
|
||||
if request.app.state.config.STT_ENGINE == "":
|
||||
if request.app.state.faster_whisper_model is None:
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||
@@ -561,11 +565,7 @@ def transcription_handler(request, file_path, metadata):
|
||||
file_path,
|
||||
beam_size=5,
|
||||
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
|
||||
language=(
|
||||
metadata.get("language", None)
|
||||
if WHISPER_LANGUAGE == ""
|
||||
else WHISPER_LANGUAGE
|
||||
),
|
||||
language=languages[0],
|
||||
)
|
||||
log.info(
|
||||
"Detected language '%s' with probability %f"
|
||||
@@ -585,21 +585,26 @@ def transcription_handler(request, file_path, metadata):
|
||||
elif request.app.state.config.STT_ENGINE == "openai":
|
||||
r = None
|
||||
try:
|
||||
r = requests.post(
|
||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
||||
},
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data={
|
||||
for language in languages:
|
||||
payload = {
|
||||
"model": request.app.state.config.STT_MODEL,
|
||||
**(
|
||||
{"language": metadata.get("language")}
|
||||
if metadata.get("language")
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if language:
|
||||
payload["language"] = language
|
||||
|
||||
r = requests.post(
|
||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
||||
},
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data=payload,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
# Successful transcription
|
||||
break
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
@@ -641,18 +646,26 @@ def transcription_handler(request, file_path, metadata):
|
||||
"Content-Type": mime,
|
||||
}
|
||||
|
||||
# Add model if specified
|
||||
params = {}
|
||||
if request.app.state.config.STT_MODEL:
|
||||
params["model"] = request.app.state.config.STT_MODEL
|
||||
for language in languages:
|
||||
params = {}
|
||||
if request.app.state.config.STT_MODEL:
|
||||
params["model"] = request.app.state.config.STT_MODEL
|
||||
|
||||
if language:
|
||||
params["language"] = language
|
||||
|
||||
# Make request to Deepgram API
|
||||
r = requests.post(
|
||||
"https://api.deepgram.com/v1/listen?smart_format=true",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
# Successful transcription
|
||||
break
|
||||
|
||||
# Make request to Deepgram API
|
||||
r = requests.post(
|
||||
"https://api.deepgram.com/v1/listen?smart_format=true",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
response_data = r.json()
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ from open_webui.models.auths import (
|
||||
SigninResponse,
|
||||
SignupForm,
|
||||
UpdatePasswordForm,
|
||||
UpdateProfileForm,
|
||||
UserResponse,
|
||||
)
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.users import Users, UpdateProfileForm
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import (
|
||||
@@ -30,6 +30,7 @@ from open_webui.env import (
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
ENABLE_INITIAL_ADMIN_SIGNUP,
|
||||
SRC_LOG_LEVELS,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
@@ -73,7 +74,13 @@ class SessionUserResponse(Token, UserResponse):
|
||||
permissions: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/", response_model=SessionUserResponse)
|
||||
class SessionUserInfoResponse(SessionUserResponse):
|
||||
bio: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
|
||||
@router.get("/", response_model=SessionUserInfoResponse)
|
||||
async def get_session_user(
|
||||
request: Request, response: Response, user=Depends(get_current_user)
|
||||
):
|
||||
@@ -121,6 +128,9 @@ async def get_session_user(
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"bio": user.bio,
|
||||
"gender": user.gender,
|
||||
"date_of_birth": user.date_of_birth,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
|
||||
@@ -137,7 +147,7 @@ async def update_profile(
|
||||
if session_user:
|
||||
user = Users.update_user_by_id(
|
||||
session_user.id,
|
||||
{"profile_image_url": form_data.profile_image_url, "name": form_data.name},
|
||||
form_data.model_dump(),
|
||||
)
|
||||
if user:
|
||||
return user
|
||||
@@ -561,9 +571,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
not request.app.state.config.ENABLE_SIGNUP
|
||||
or not request.app.state.config.ENABLE_LOGIN_FORM
|
||||
):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||
)
|
||||
if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||
)
|
||||
else:
|
||||
if has_users:
|
||||
raise HTTPException(
|
||||
@@ -625,7 +636,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
post_webhook(
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
@@ -666,19 +677,29 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
async def signout(request: Request, response: Response):
|
||||
response.delete_cookie("token")
|
||||
response.delete_cookie("oui-session")
|
||||
response.delete_cookie("oauth_id_token")
|
||||
|
||||
if ENABLE_OAUTH_SIGNUP.value:
|
||||
oauth_id_token = request.cookies.get("oauth_id_token")
|
||||
if oauth_id_token and OPENID_PROVIDER_URL.value:
|
||||
oauth_session_id = request.cookies.get("oauth_session_id")
|
||||
if oauth_session_id:
|
||||
response.delete_cookie("oauth_session_id")
|
||||
|
||||
session = OAuthSessions.get_session_by_id(oauth_session_id)
|
||||
oauth_server_metadata_url = (
|
||||
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
|
||||
if session
|
||||
else None
|
||||
) or OPENID_PROVIDER_URL.value
|
||||
|
||||
if session and oauth_server_metadata_url:
|
||||
oauth_id_token = session.token.get("id_token")
|
||||
try:
|
||||
async with ClientSession(trust_env=True) as session:
|
||||
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
||||
if resp.status == 200:
|
||||
openid_data = await resp.json()
|
||||
async with session.get(oauth_server_metadata_url) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
logout_url = openid_data.get("end_session_endpoint")
|
||||
if logout_url:
|
||||
response.delete_cookie("oauth_id_token")
|
||||
|
||||
if logout_url:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
@@ -693,15 +714,14 @@ async def signout(request: Request, response: Response):
|
||||
headers=response.headers,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=resp.status,
|
||||
detail="Failed to fetch OpenID configuration",
|
||||
)
|
||||
raise Exception("Failed to fetch OpenID configuration")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"OpenID signout error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to sign out from the OpenID provider.",
|
||||
headers=response.headers,
|
||||
)
|
||||
|
||||
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
|
||||
|
||||
@@ -24,9 +24,17 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
from open_webui.utils.models import (
|
||||
get_all_models,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, get_users_with_access
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from open_webui.utils.channels import extract_mentions, replace_mentions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
@@ -200,16 +208,13 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
|
||||
users = get_users_with_access("read", channel.access_control)
|
||||
|
||||
for user in users:
|
||||
if user.id in active_user_ids:
|
||||
continue
|
||||
else:
|
||||
if user.id not in active_user_ids:
|
||||
if user.settings:
|
||||
webhook_url = user.settings.ui.get("notifications", {}).get(
|
||||
"webhook_url", None
|
||||
)
|
||||
|
||||
if webhook_url:
|
||||
post_webhook(
|
||||
await post_webhook(
|
||||
name,
|
||||
webhook_url,
|
||||
f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
|
||||
@@ -221,14 +226,134 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
|
||||
async def post_new_message(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: MessageForm,
|
||||
background_tasks: BackgroundTasks,
|
||||
user=Depends(get_verified_user),
|
||||
|
||||
async def model_response_handler(request, channel, message, user):
|
||||
MODELS = {
|
||||
model["id"]: model
|
||||
for model in get_filtered_models(await get_all_models(request, user=user), user)
|
||||
}
|
||||
|
||||
mentions = extract_mentions(message.content)
|
||||
message_content = replace_mentions(message.content)
|
||||
|
||||
# check if any of the mentions are models
|
||||
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
|
||||
if not model_mentions:
|
||||
return False
|
||||
|
||||
for mention in model_mentions:
|
||||
model_id = mention["id"]
|
||||
model = MODELS.get(model_id, None)
|
||||
|
||||
if model:
|
||||
try:
|
||||
# reverse to get in chronological order
|
||||
thread_messages = Messages.get_messages_by_parent_id(
|
||||
channel.id,
|
||||
message.parent_id if message.parent_id else message.id,
|
||||
)[::-1]
|
||||
|
||||
response_message, channel = await new_message_handler(
|
||||
request,
|
||||
channel.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"parent_id": (
|
||||
message.parent_id if message.parent_id else message.id
|
||||
),
|
||||
"content": f"",
|
||||
"data": {},
|
||||
"meta": {
|
||||
"model_id": model_id,
|
||||
"model_name": model.get("name", model_id),
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
|
||||
thread_history = []
|
||||
message_users = {}
|
||||
|
||||
for thread_message in thread_messages:
|
||||
message_user = None
|
||||
if thread_message.user_id not in message_users:
|
||||
message_user = Users.get_user_by_id(thread_message.user_id)
|
||||
message_users[thread_message.user_id] = message_user
|
||||
else:
|
||||
message_user = message_users[thread_message.user_id]
|
||||
|
||||
if thread_message.meta and thread_message.meta.get(
|
||||
"model_id", None
|
||||
):
|
||||
# If the message was sent by a model, use the model name
|
||||
message_model_id = thread_message.meta.get("model_id", None)
|
||||
message_model = MODELS.get(message_model_id, None)
|
||||
username = (
|
||||
message_model.get("name", message_model_id)
|
||||
if message_model
|
||||
else message_model_id
|
||||
)
|
||||
else:
|
||||
username = message_user.name if message_user else "Unknown"
|
||||
|
||||
thread_history.append(
|
||||
f"{username}: {replace_mentions(thread_message.content)}"
|
||||
)
|
||||
|
||||
system_message = {
|
||||
"role": "system",
|
||||
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
|
||||
+ (
|
||||
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
|
||||
if thread_history
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
form_data = {
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
system_message,
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{user.name if user else 'User'}: {message_content}",
|
||||
},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
res = await generate_chat_completion(
|
||||
request,
|
||||
form_data=form_data,
|
||||
user=user,
|
||||
)
|
||||
|
||||
if res:
|
||||
await update_message_by_id(
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"content": res["choices"][0]["message"]["content"],
|
||||
"meta": {
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.info(e)
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def new_message_handler(
|
||||
request: Request, id: str, form_data: MessageForm, user=Depends(get_verified_user)
|
||||
):
|
||||
channel = Channels.get_channel_by_id(id)
|
||||
if not channel:
|
||||
@@ -302,11 +427,30 @@ async def post_new_message(
|
||||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
return MessageModel(**message.model_dump()), channel
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
||||
|
||||
background_tasks.add_task(
|
||||
send_notification,
|
||||
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
|
||||
async def post_new_message(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: MessageForm,
|
||||
background_tasks: BackgroundTasks,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
|
||||
try:
|
||||
message, channel = await new_message_handler(request, id, form_data, user)
|
||||
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
||||
|
||||
async def background_handler():
|
||||
await model_response_handler(request, channel, message, user)
|
||||
await send_notification(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBUI_URL,
|
||||
channel,
|
||||
@@ -314,7 +458,12 @@ async def post_new_message(
|
||||
active_user_ids,
|
||||
)
|
||||
|
||||
return MessageModel(**message.model_dump())
|
||||
background_tasks.add_task(background_handler)
|
||||
|
||||
return message
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
|
||||
@@ -36,7 +36,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ChatTitleIdResponse])
|
||||
@router.get("/list", response_model=list[ChatTitleIdResponse])
|
||||
async def get_session_user_chat_list(
|
||||
def get_session_user_chat_list(
|
||||
user=Depends(get_verified_user), page: Optional[int] = None
|
||||
):
|
||||
try:
|
||||
@@ -166,7 +166,7 @@ async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[ChatTitleIdResponse])
|
||||
async def search_user_chats(
|
||||
def search_user_chats(
|
||||
text: str, page: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if page is None:
|
||||
|
||||
@@ -9,8 +9,8 @@ from open_webui.config import BannerModel
|
||||
|
||||
from open_webui.utils.tools import (
|
||||
get_tool_server_data,
|
||||
get_tool_servers_data,
|
||||
get_tool_server_url,
|
||||
set_tool_servers,
|
||||
)
|
||||
|
||||
|
||||
@@ -114,10 +114,7 @@ async def set_tool_servers_config(
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
|
||||
]
|
||||
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
)
|
||||
await set_tool_servers(request)
|
||||
|
||||
return {
|
||||
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
|
||||
@@ -6,8 +6,10 @@ from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
import asyncio
|
||||
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
@@ -18,6 +20,7 @@ from fastapi import (
|
||||
status,
|
||||
Query,
|
||||
)
|
||||
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
@@ -42,7 +45,6 @@ from pydantic import BaseModel
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -83,14 +85,81 @@ def has_access_to_file(
|
||||
############################
|
||||
|
||||
|
||||
def process_uploaded_file(request, file, file_path, file_item, file_metadata, user):
|
||||
try:
|
||||
if file.content_type:
|
||||
stt_supported_content_types = getattr(
|
||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||
)
|
||||
|
||||
if any(
|
||||
fnmatch(file.content_type, content_type)
|
||||
for content_type in (
|
||||
stt_supported_content_types
|
||||
if stt_supported_content_types
|
||||
and any(t.strip() for t in stt_supported_content_types)
|
||||
else ["audio/*", "video/webm"]
|
||||
)
|
||||
):
|
||||
file_path = Storage.get_file(file_path)
|
||||
result = transcribe(request, file_path, file_metadata)
|
||||
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(
|
||||
file_id=file_item.id, content=result.get("text", "")
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
elif (not file.content_type.startswith(("image/", "video/"))) or (
|
||||
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
|
||||
):
|
||||
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
||||
else:
|
||||
log.info(
|
||||
f"File type {file.content_type} is not provided, but trying to process anyway"
|
||||
)
|
||||
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
||||
except Exception as e:
|
||||
log.error(f"Error processing file: {file_item.id}")
|
||||
Files.update_file_data_by_id(
|
||||
file_item.id,
|
||||
{
|
||||
"status": "failed",
|
||||
"error": str(e.detail) if hasattr(e, "detail") else str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=FileModelResponse)
|
||||
def upload_file(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
metadata: Optional[dict | str] = Form(None),
|
||||
process: bool = Query(True),
|
||||
process_in_background: bool = Query(True),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return upload_file_handler(
|
||||
request,
|
||||
file=file,
|
||||
metadata=metadata,
|
||||
process=process,
|
||||
process_in_background=process_in_background,
|
||||
user=user,
|
||||
background_tasks=background_tasks,
|
||||
)
|
||||
|
||||
|
||||
def upload_file_handler(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
metadata: Optional[dict | str] = Form(None),
|
||||
process: bool = Query(True),
|
||||
internal: bool = False,
|
||||
process_in_background: bool = Query(True),
|
||||
user=Depends(get_verified_user),
|
||||
background_tasks: Optional[BackgroundTasks] = None,
|
||||
):
|
||||
log.info(f"file.content_type: {file.content_type}")
|
||||
|
||||
@@ -112,7 +181,7 @@ def upload_file(
|
||||
# Remove the leading dot from the file extension
|
||||
file_extension = file_extension[1:] if file_extension else ""
|
||||
|
||||
if (not internal) and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
|
||||
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
|
||||
]
|
||||
@@ -129,13 +198,16 @@ def upload_file(
|
||||
id = str(uuid.uuid4())
|
||||
name = filename
|
||||
filename = f"{id}_{filename}"
|
||||
tags = {
|
||||
"OpenWebUI-User-Email": user.email,
|
||||
"OpenWebUI-User-Id": user.id,
|
||||
"OpenWebUI-User-Name": user.name,
|
||||
"OpenWebUI-File-Id": id,
|
||||
}
|
||||
contents, file_path = Storage.upload_file(file.file, filename, tags)
|
||||
contents, file_path = Storage.upload_file(
|
||||
file.file,
|
||||
filename,
|
||||
{
|
||||
"OpenWebUI-User-Email": user.email,
|
||||
"OpenWebUI-User-Id": user.id,
|
||||
"OpenWebUI-User-Name": user.name,
|
||||
"OpenWebUI-File-Id": id,
|
||||
},
|
||||
)
|
||||
|
||||
file_item = Files.insert_new_file(
|
||||
user.id,
|
||||
@@ -144,6 +216,9 @@ def upload_file(
|
||||
"id": id,
|
||||
"filename": name,
|
||||
"path": file_path,
|
||||
"data": {
|
||||
**({"status": "pending"} if process else {}),
|
||||
},
|
||||
"meta": {
|
||||
"name": name,
|
||||
"content_type": file.content_type,
|
||||
@@ -153,58 +228,37 @@ def upload_file(
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
if process:
|
||||
try:
|
||||
if file.content_type:
|
||||
stt_supported_content_types = getattr(
|
||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||
)
|
||||
|
||||
if any(
|
||||
fnmatch(file.content_type, content_type)
|
||||
for content_type in (
|
||||
stt_supported_content_types
|
||||
if stt_supported_content_types
|
||||
and any(t.strip() for t in stt_supported_content_types)
|
||||
else ["audio/*", "video/webm"]
|
||||
)
|
||||
):
|
||||
file_path = Storage.get_file(file_path)
|
||||
result = transcribe(request, file_path, file_metadata)
|
||||
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=id, content=result.get("text", "")),
|
||||
user=user,
|
||||
)
|
||||
elif (not file.content_type.startswith(("image/", "video/"))) or (
|
||||
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
|
||||
):
|
||||
process_file(request, ProcessFileForm(file_id=id), user=user)
|
||||
else:
|
||||
log.info(
|
||||
f"File type {file.content_type} is not provided, but trying to process anyway"
|
||||
)
|
||||
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 background_tasks and process_in_background:
|
||||
background_tasks.add_task(
|
||||
process_uploaded_file,
|
||||
request,
|
||||
file,
|
||||
file_path,
|
||||
file_item,
|
||||
file_metadata,
|
||||
user,
|
||||
)
|
||||
|
||||
if file_item:
|
||||
return file_item
|
||||
return {"status": True, **file_item.model_dump()}
|
||||
else:
|
||||
process_uploaded_file(
|
||||
request,
|
||||
file,
|
||||
file_path,
|
||||
file_item,
|
||||
file_metadata,
|
||||
user,
|
||||
)
|
||||
return {"status": True, **file_item.model_dump()}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
if file_item:
|
||||
return file_item
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -331,6 +385,63 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{id}/process/status")
|
||||
async def get_file_process_status(
|
||||
id: str, stream: bool = Query(False), user=Depends(get_verified_user)
|
||||
):
|
||||
file = Files.get_file_by_id(id)
|
||||
|
||||
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)
|
||||
):
|
||||
if stream:
|
||||
MAX_FILE_PROCESSING_DURATION = 3600 * 2
|
||||
|
||||
async def event_stream(file_item):
|
||||
if file_item:
|
||||
for _ in range(MAX_FILE_PROCESSING_DURATION):
|
||||
file_item = Files.get_file_by_id(file_item.id)
|
||||
if file_item:
|
||||
data = file_item.model_dump().get("data", {})
|
||||
status = data.get("status")
|
||||
|
||||
if status:
|
||||
event = {"status": status}
|
||||
if status == "failed":
|
||||
event["error"] = data.get("error")
|
||||
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if status in ("completed", "failed"):
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(file),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
else:
|
||||
return {"status": file.data.get("status", "pending")}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# Get File Data Content By Id
|
||||
############################
|
||||
|
||||
@@ -10,10 +10,14 @@ import mimetypes
|
||||
|
||||
from open_webui.models.folders import (
|
||||
FolderForm,
|
||||
FolderUpdateForm,
|
||||
FolderModel,
|
||||
Folders,
|
||||
)
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
|
||||
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
@@ -44,6 +48,31 @@ router = APIRouter()
|
||||
async def get_folders(user=Depends(get_verified_user)):
|
||||
folders = Folders.get_folders_by_user_id(user.id)
|
||||
|
||||
# Verify folder data integrity
|
||||
for folder in folders:
|
||||
if folder.data:
|
||||
if "files" in folder.data:
|
||||
valid_files = []
|
||||
for file in folder.data["files"]:
|
||||
|
||||
if file.get("type") == "file":
|
||||
if Files.check_access_by_user_id(
|
||||
file.get("id"), user.id, "read"
|
||||
):
|
||||
valid_files.append(file)
|
||||
elif file.get("type") == "collection":
|
||||
if Knowledges.check_access_by_user_id(
|
||||
file.get("id"), user.id, "read"
|
||||
):
|
||||
valid_files.append(file)
|
||||
else:
|
||||
valid_files.append(file)
|
||||
|
||||
folder.data["files"] = valid_files
|
||||
Folders.update_folder_by_id_and_user_id(
|
||||
folder.id, user.id, FolderUpdateForm(data=folder.data)
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
**folder.model_dump(),
|
||||
@@ -113,22 +142,24 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
|
||||
|
||||
@router.post("/{id}/update")
|
||||
async def update_folder_name_by_id(
|
||||
id: str, form_data: FolderForm, user=Depends(get_verified_user)
|
||||
id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user)
|
||||
):
|
||||
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
|
||||
if folder:
|
||||
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
folder.parent_id, user.id, form_data.name
|
||||
)
|
||||
if existing_folder and existing_folder.id != id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||
|
||||
if form_data.name is not None:
|
||||
# Check if folder with same name exists
|
||||
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
folder.parent_id, user.id, form_data.name
|
||||
)
|
||||
if existing_folder and existing_folder.id != id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||
)
|
||||
|
||||
try:
|
||||
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
|
||||
|
||||
return folder
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
@@ -10,6 +10,7 @@ from open_webui.models.functions import (
|
||||
FunctionForm,
|
||||
FunctionModel,
|
||||
FunctionResponse,
|
||||
FunctionWithValvesModel,
|
||||
Functions,
|
||||
)
|
||||
from open_webui.utils.plugin import (
|
||||
@@ -46,9 +47,9 @@ async def get_functions(user=Depends(get_verified_user)):
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/export", response_model=list[FunctionModel])
|
||||
async def get_functions(user=Depends(get_admin_user)):
|
||||
return Functions.get_functions()
|
||||
@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
|
||||
async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
|
||||
return Functions.get_functions(include_valves=include_valves)
|
||||
|
||||
|
||||
############################
|
||||
@@ -132,10 +133,10 @@ async def load_function_from_url(
|
||||
|
||||
|
||||
class SyncFunctionsForm(BaseModel):
|
||||
functions: list[FunctionModel] = []
|
||||
functions: list[FunctionWithValvesModel] = []
|
||||
|
||||
|
||||
@router.post("/sync", response_model=list[FunctionModel])
|
||||
@router.post("/sync", response_model=list[FunctionWithValvesModel])
|
||||
async def sync_functions(
|
||||
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
|
||||
):
|
||||
@@ -147,6 +148,18 @@ async def sync_functions(
|
||||
content=function.content,
|
||||
)
|
||||
|
||||
if hasattr(function_module, "Valves") and function.valves:
|
||||
Valves = function_module.Valves
|
||||
try:
|
||||
Valves(
|
||||
**{k: v for k, v in function.valves.items() if v is not None}
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error validating valves for function {function.id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return Functions.sync_functions(user.id, form_data.functions)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to load a function: {e}")
|
||||
@@ -191,6 +204,9 @@ async def create_new_function(
|
||||
function_cache_dir = CACHE_DIR / "functions" / form_data.id
|
||||
function_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if function_type == "filter" and getattr(function_module, "toggle", None):
|
||||
Functions.update_function_metadata_by_id(id, {"toggle": True})
|
||||
|
||||
if function:
|
||||
return function
|
||||
else:
|
||||
@@ -307,6 +323,9 @@ async def update_function_by_id(
|
||||
|
||||
function = Functions.update_function_by_id(id, updated)
|
||||
|
||||
if function_type == "filter" and getattr(function_module, "toggle", None):
|
||||
Functions.update_function_metadata_by_id(id, {"toggle": True})
|
||||
|
||||
if function:
|
||||
return function
|
||||
else:
|
||||
|
||||
@@ -10,11 +10,18 @@ from typing import Optional
|
||||
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
|
||||
from open_webui.routers.files import upload_file
|
||||
from open_webui.routers.files import upload_file_handler
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.images.comfyui import (
|
||||
ComfyUIGenerateImageForm,
|
||||
@@ -41,6 +48,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
"openai": {
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
|
||||
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
|
||||
},
|
||||
"automatic1111": {
|
||||
@@ -65,6 +73,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
|
||||
class OpenAIConfigForm(BaseModel):
|
||||
OPENAI_API_BASE_URL: str
|
||||
OPENAI_API_VERSION: str
|
||||
OPENAI_API_KEY: str
|
||||
|
||||
|
||||
@@ -112,6 +121,9 @@ async def update_config(
|
||||
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
|
||||
form_data.openai.OPENAI_API_BASE_URL
|
||||
)
|
||||
request.app.state.config.IMAGES_OPENAI_API_VERSION = (
|
||||
form_data.openai.OPENAI_API_VERSION
|
||||
)
|
||||
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY
|
||||
|
||||
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
|
||||
@@ -158,6 +170,7 @@ async def update_config(
|
||||
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
"openai": {
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
|
||||
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
|
||||
},
|
||||
"automatic1111": {
|
||||
@@ -469,7 +482,13 @@ def upload_image(request, image_data, content_type, metadata, user):
|
||||
"content-type": content_type,
|
||||
},
|
||||
)
|
||||
file_item = upload_file(request, file, metadata=metadata, internal=True, user=user)
|
||||
file_item = upload_file_handler(
|
||||
request,
|
||||
file=file,
|
||||
metadata=metadata,
|
||||
process=False,
|
||||
user=user,
|
||||
)
|
||||
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
|
||||
return url
|
||||
|
||||
@@ -483,11 +502,18 @@ async def image_generations(
|
||||
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
|
||||
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
|
||||
# image model other than gpt-image-1, which is warned about on settings save
|
||||
width, height = (
|
||||
tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
|
||||
if "x" in request.app.state.config.IMAGE_SIZE
|
||||
else (512, 512)
|
||||
)
|
||||
|
||||
size = "512x512"
|
||||
if (
|
||||
request.app.state.config.IMAGE_SIZE
|
||||
and "x" in request.app.state.config.IMAGE_SIZE
|
||||
):
|
||||
size = request.app.state.config.IMAGE_SIZE
|
||||
|
||||
if form_data.size and "x" in form_data.size:
|
||||
size = form_data.size
|
||||
|
||||
width, height = tuple(map(int, size.split("x")))
|
||||
|
||||
r = None
|
||||
try:
|
||||
@@ -524,10 +550,16 @@ async def image_generations(
|
||||
),
|
||||
}
|
||||
|
||||
api_version_query_param = ""
|
||||
if request.app.state.config.IMAGES_OPENAI_API_VERSION:
|
||||
api_version_query_param = (
|
||||
f"?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}"
|
||||
)
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
r = await asyncio.to_thread(
|
||||
requests.post,
|
||||
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations",
|
||||
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations{api_version_query_param}",
|
||||
json=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
|
||||
import logging
|
||||
|
||||
from open_webui.models.knowledge import (
|
||||
@@ -25,7 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.models.models import Models, ModelForm
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ router = APIRouter()
|
||||
async def get_knowledge(user=Depends(get_verified_user)):
|
||||
knowledge_bases = []
|
||||
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
else:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
|
||||
@@ -91,7 +91,7 @@ async def get_knowledge(user=Depends(get_verified_user)):
|
||||
async def get_knowledge_list(user=Depends(get_verified_user)):
|
||||
knowledge_bases = []
|
||||
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
else:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
|
||||
@@ -151,6 +151,18 @@ async def create_new_knowledge(
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
|
||||
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
|
||||
|
||||
if knowledge:
|
||||
@@ -285,6 +297,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
|
||||
|
||||
@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
|
||||
async def update_knowledge_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: KnowledgeForm,
|
||||
user=Depends(get_verified_user),
|
||||
@@ -306,10 +319,22 @@ async def update_knowledge_by_id(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
|
||||
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
|
||||
if knowledge:
|
||||
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
|
||||
files = Files.get_files_by_ids(file_ids)
|
||||
files = Files.get_file_metadatas_by_ids(file_ids)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
@@ -492,6 +517,7 @@ def update_file_from_knowledge_by_id(
|
||||
def remove_file_from_knowledge_by_id(
|
||||
id: str,
|
||||
form_data: KnowledgeFileIdForm,
|
||||
delete_file: bool = Query(True),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id)
|
||||
@@ -528,18 +554,19 @@ def remove_file_from_knowledge_by_id(
|
||||
log.debug(e)
|
||||
pass
|
||||
|
||||
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
|
||||
if delete_file:
|
||||
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)
|
||||
# Delete file from database
|
||||
Files.delete_file_by_id(form_data.file_id)
|
||||
|
||||
if knowledge:
|
||||
data = knowledge.data or {}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import Optional
|
||||
import io
|
||||
import base64
|
||||
|
||||
from open_webui.models.models import (
|
||||
ModelForm,
|
||||
@@ -10,12 +12,13 @@ from open_webui.models.models import (
|
||||
|
||||
from pydantic import BaseModel
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
|
||||
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_access, has_permission
|
||||
from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -27,7 +30,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ModelUserResponse])
|
||||
async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)):
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return Models.get_models()
|
||||
else:
|
||||
return Models.get_models_by_user_id(user.id)
|
||||
@@ -117,7 +120,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
|
||||
model = Models.get_model_by_id(id)
|
||||
if model:
|
||||
if (
|
||||
user.role == "admin"
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or model.user_id == user.id
|
||||
or has_access(user.id, "read", model.access_control)
|
||||
):
|
||||
@@ -129,6 +132,39 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
|
||||
###########################
|
||||
# GetModelById
|
||||
###########################
|
||||
|
||||
|
||||
@router.get("/model/profile/image")
|
||||
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
|
||||
model = Models.get_model_by_id(id)
|
||||
if model:
|
||||
if model.meta.profile_image_url:
|
||||
if model.meta.profile_image_url.startswith("http"):
|
||||
return Response(
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
headers={"Location": model.meta.profile_image_url},
|
||||
)
|
||||
elif model.meta.profile_image_url.startswith("data:image"):
|
||||
try:
|
||||
header, base64_data = model.meta.profile_image_url.split(",", 1)
|
||||
image_data = base64.b64decode(base64_data)
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
|
||||
return StreamingResponse(
|
||||
image_buffer,
|
||||
media_type="image/png",
|
||||
headers={"Content-Disposition": "inline; filename=image.png"},
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
return FileResponse(f"{STATIC_DIR}/favicon.png")
|
||||
else:
|
||||
return FileResponse(f"{STATIC_DIR}/favicon.png")
|
||||
|
||||
|
||||
############################
|
||||
# ToggleModelById
|
||||
############################
|
||||
|
||||
@@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[NoteTitleIdResponse])
|
||||
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
async def get_note_list(
|
||||
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
@@ -72,9 +73,15 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
limit = None
|
||||
skip = None
|
||||
if page is not None:
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
notes = [
|
||||
NoteTitleIdResponse(**note.model_dump())
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit)
|
||||
]
|
||||
|
||||
return notes
|
||||
|
||||
@@ -47,7 +47,7 @@ from open_webui.utils.misc import (
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_ollama,
|
||||
apply_model_params_to_body_openai,
|
||||
apply_model_system_prompt_to_body,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
@@ -329,17 +329,21 @@ def merge_ollama_models_lists(model_lists):
|
||||
for idx, model_list in enumerate(model_lists):
|
||||
if model_list is not None:
|
||||
for model in model_list:
|
||||
id = model["model"]
|
||||
if id not in merged_models:
|
||||
model["urls"] = [idx]
|
||||
merged_models[id] = model
|
||||
else:
|
||||
merged_models[id]["urls"].append(idx)
|
||||
id = model.get("model")
|
||||
if id is not None:
|
||||
if id not in merged_models:
|
||||
model["urls"] = [idx]
|
||||
merged_models[id] = model
|
||||
else:
|
||||
merged_models[id]["urls"].append(idx)
|
||||
|
||||
return list(merged_models.values())
|
||||
|
||||
|
||||
@cached(ttl=MODELS_CACHE_TTL)
|
||||
@cached(
|
||||
ttl=MODELS_CACHE_TTL,
|
||||
key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models",
|
||||
)
|
||||
async def get_all_models(request: Request, user: UserModel = None):
|
||||
log.info("get_all_models()")
|
||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||
@@ -415,15 +419,15 @@ async def get_all_models(request: Request, user: UserModel = None):
|
||||
try:
|
||||
loaded_models = await get_ollama_loaded_models(request, user=user)
|
||||
expires_map = {
|
||||
m["name"]: m["expires_at"]
|
||||
m["model"]: m["expires_at"]
|
||||
for m in loaded_models["models"]
|
||||
if "expires_at" in m
|
||||
}
|
||||
|
||||
for m in models["models"]:
|
||||
if m["name"] in expires_map:
|
||||
if m["model"] in expires_map:
|
||||
# Parse ISO8601 datetime with offset, get unix timestamp as int
|
||||
dt = datetime.fromisoformat(expires_map[m["name"]])
|
||||
dt = datetime.fromisoformat(expires_map[m["model"]])
|
||||
m["expires_at"] = int(dt.timestamp())
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to get loaded models: {e}")
|
||||
@@ -1330,7 +1334,7 @@ async def generate_chat_completion(
|
||||
system = params.pop("system", None)
|
||||
|
||||
payload = apply_model_params_to_body_ollama(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
|
||||
payload = apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
@@ -1519,7 +1523,7 @@ async def generate_openai_chat_completion(
|
||||
system = params.pop("system", None)
|
||||
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
|
||||
payload = apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if user.role == "user":
|
||||
|
||||
@@ -9,6 +9,8 @@ from aiocache import cached
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, APIRouter
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
@@ -39,7 +41,7 @@ from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_model_system_prompt_to_body,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.misc import (
|
||||
convert_logit_bias_input_to_json,
|
||||
@@ -119,6 +121,93 @@ def openai_reasoning_model_handler(payload):
|
||||
return payload
|
||||
|
||||
|
||||
def get_headers_and_cookies(
|
||||
request: Request,
|
||||
url,
|
||||
key=None,
|
||||
config=None,
|
||||
metadata: Optional[dict] = None,
|
||||
user: UserModel = None,
|
||||
):
|
||||
cookies = {}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"HTTP-Referer": "https://openwebui.com/",
|
||||
"X-Title": "Open WebUI",
|
||||
}
|
||||
if "openrouter.ai" in url
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
**(
|
||||
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
|
||||
if metadata and metadata.get("chat_id")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
token = None
|
||||
auth_type = config.get("auth_type")
|
||||
|
||||
if auth_type == "bearer" or auth_type is None:
|
||||
# Default to bearer if not specified
|
||||
token = f"{key}"
|
||||
elif auth_type == "none":
|
||||
token = None
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
token = request.state.token.credentials
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
if oauth_token:
|
||||
token = f"{oauth_token.get('access_token', '')}"
|
||||
|
||||
elif auth_type in ("azure_ad", "microsoft_entra_id"):
|
||||
token = get_microsoft_entra_id_access_token()
|
||||
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
return headers, cookies
|
||||
|
||||
|
||||
def get_microsoft_entra_id_access_token():
|
||||
"""
|
||||
Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI.
|
||||
Returns the token string or None if authentication fails.
|
||||
"""
|
||||
try:
|
||||
token_provider = get_bearer_token_provider(
|
||||
DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
|
||||
)
|
||||
return token_provider()
|
||||
except Exception as e:
|
||||
log.error(f"Error getting Microsoft Entra ID access token: {e}")
|
||||
return None
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
# API routes
|
||||
@@ -210,34 +299,23 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
return FileResponse(file_path)
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
r = None
|
||||
try:
|
||||
r = requests.post(
|
||||
url=f"{url}/audio/speech",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}",
|
||||
**(
|
||||
{
|
||||
"HTTP-Referer": "https://openwebui.com/",
|
||||
"X-Title": "Open WebUI",
|
||||
}
|
||||
if "openrouter.ai" in url
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
},
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
@@ -361,9 +439,18 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
tags = api_config.get("tags", [])
|
||||
|
||||
for model in (
|
||||
model_list = (
|
||||
response if isinstance(response, list) else response.get("data", [])
|
||||
):
|
||||
)
|
||||
if not isinstance(model_list, list):
|
||||
# Catch non-list responses
|
||||
model_list = []
|
||||
|
||||
for model in model_list:
|
||||
# Remove name key if its value is None #16689
|
||||
if "name" in model and model["name"] is None:
|
||||
del model["name"]
|
||||
|
||||
if prefix_id:
|
||||
model["id"] = (
|
||||
f"{prefix_id}.{model.get('id', model.get('name', ''))}"
|
||||
@@ -392,7 +479,10 @@ async def get_filtered_models(models, user):
|
||||
return filtered_models
|
||||
|
||||
|
||||
@cached(ttl=MODELS_CACHE_TTL)
|
||||
@cached(
|
||||
ttl=MODELS_CACHE_TTL,
|
||||
key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models",
|
||||
)
|
||||
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
log.info("get_all_models()")
|
||||
|
||||
@@ -480,19 +570,9 @@ async def get_models(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
models = {
|
||||
@@ -500,11 +580,10 @@ async def get_models(
|
||||
"object": "list",
|
||||
}
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
@@ -563,7 +642,9 @@ class ConnectionVerificationForm(BaseModel):
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_connection(
|
||||
form_data: ConnectionVerificationForm, user=Depends(get_admin_user)
|
||||
request: Request,
|
||||
form_data: ConnectionVerificationForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
url = form_data.url
|
||||
key = form_data.key
|
||||
@@ -575,27 +656,21 @@ async def verify_connection(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
headers["api-key"] = key
|
||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
# Only set api-key header if not using Azure Entra ID authentication
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
async with session.get(
|
||||
url=f"{url}/openai/models?api-version={api_version}",
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
try:
|
||||
@@ -615,11 +690,10 @@ async def verify_connection(
|
||||
|
||||
return response_data
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
try:
|
||||
@@ -693,6 +767,10 @@ def get_azure_allowed_params(api_version: str) -> set[str]:
|
||||
return allowed_params
|
||||
|
||||
|
||||
def is_openai_reasoning_model(model: str) -> bool:
|
||||
return model.lower().startswith(("o1", "o3", "o4", "gpt-5"))
|
||||
|
||||
|
||||
def convert_to_azure_payload(url, payload: dict, api_version: str):
|
||||
model = payload.get("model", "")
|
||||
|
||||
@@ -700,7 +778,7 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
|
||||
allowed_params = get_azure_allowed_params(api_version)
|
||||
|
||||
# Special handling for o-series models
|
||||
if model.startswith("o") and model.endswith("-mini"):
|
||||
if is_openai_reasoning_model(model):
|
||||
# Convert max_tokens to max_completion_tokens for o-series models
|
||||
if "max_tokens" in payload:
|
||||
payload["max_completion_tokens"] = payload["max_tokens"]
|
||||
@@ -750,7 +828,7 @@ async def generate_chat_completion(
|
||||
system = params.pop("system", None)
|
||||
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
|
||||
payload = apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
@@ -806,10 +884,7 @@ async def generate_chat_completion(
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
|
||||
# Check if model is a reasoning model that needs special handling
|
||||
is_reasoning_model = (
|
||||
payload["model"].lower().startswith(("o1", "o3", "o4", "gpt-5"))
|
||||
)
|
||||
if is_reasoning_model:
|
||||
if is_openai_reasoning_model(payload["model"]):
|
||||
payload = openai_reasoning_model_handler(payload)
|
||||
elif "api.openai.com" not in url:
|
||||
# Remove "max_completion_tokens" from the payload for backward compatibility
|
||||
@@ -826,42 +901,23 @@ async def generate_chat_completion(
|
||||
convert_logit_bias_input_to_json(payload["logit_bias"])
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"HTTP-Referer": "https://openwebui.com/",
|
||||
"X-Title": "Open WebUI",
|
||||
}
|
||||
if "openrouter.ai" in url
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
**(
|
||||
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
|
||||
if metadata and metadata.get("chat_id")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
request, url, key, api_config, metadata, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
request_url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||
headers["api-key"] = key
|
||||
|
||||
# Only set api-key header if not using Azure Entra ID authentication
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||
else:
|
||||
request_url = f"{url}/chat/completions"
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
payload = json.dumps(payload)
|
||||
|
||||
@@ -880,6 +936,7 @@ async def generate_chat_completion(
|
||||
url=request_url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
@@ -941,31 +998,27 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if model_id in models:
|
||||
idx = models[model_id]["urlIdx"]
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
r = None
|
||||
session = None
|
||||
streaming = False
|
||||
|
||||
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
try:
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
r = await session.request(
|
||||
method="POST",
|
||||
url=f"{url}/embeddings",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
)
|
||||
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
@@ -1027,23 +1080,18 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
headers["api-key"] = key
|
||||
|
||||
# Only set api-key header if not using Azure Entra ID authentication
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
|
||||
payload = json.loads(body)
|
||||
@@ -1052,7 +1100,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
request_url = f"{url}/{path}?api-version={api_version}"
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
request_url = f"{url}/{path}"
|
||||
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
@@ -1061,6 +1108,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
url=request_url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from open_webui.models.prompts import (
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,7 +21,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[PromptModel])
|
||||
async def get_prompts(user=Depends(get_verified_user)):
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
prompts = Prompts.get_prompts()
|
||||
else:
|
||||
prompts = Prompts.get_prompts_by_user_id(user.id, "read")
|
||||
@@ -31,7 +31,7 @@ async def get_prompts(user=Depends(get_verified_user)):
|
||||
|
||||
@router.get("/list", response_model=list[PromptUserResponse])
|
||||
async def get_prompt_list(user=Depends(get_verified_user)):
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
prompts = Prompts.get_prompts()
|
||||
else:
|
||||
prompts = Prompts.get_prompts_by_user_id(user.id, "write")
|
||||
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
import shutil
|
||||
import asyncio
|
||||
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -281,6 +280,18 @@ async def update_embedding_config(
|
||||
log.info(
|
||||
f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}"
|
||||
)
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "":
|
||||
# unloads current internal embedding model and clears VRAM cache
|
||||
request.app.state.ef = None
|
||||
request.app.state.EMBEDDING_FUNCTION = None
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
if DEVICE_TYPE == "cuda":
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
try:
|
||||
request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine
|
||||
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model
|
||||
@@ -415,8 +426,13 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
|
||||
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
|
||||
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
|
||||
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
|
||||
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
@@ -449,6 +465,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV,
|
||||
"WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
"WEB_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
@@ -504,6 +521,7 @@ class WebConfig(BaseModel):
|
||||
WEB_SEARCH_TRUST_ENV: Optional[bool] = None
|
||||
WEB_SEARCH_RESULT_COUNT: Optional[int] = None
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
|
||||
WEB_LOADER_CONCURRENT_REQUESTS: Optional[int] = None
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
|
||||
@@ -583,8 +601,13 @@ class ConfigForm(BaseModel):
|
||||
|
||||
TIKA_SERVER_URL: Optional[str] = None
|
||||
DOCLING_SERVER_URL: Optional[str] = None
|
||||
DOCLING_DO_OCR: Optional[bool] = None
|
||||
DOCLING_FORCE_OCR: Optional[bool] = None
|
||||
DOCLING_OCR_ENGINE: Optional[str] = None
|
||||
DOCLING_OCR_LANG: Optional[str] = None
|
||||
DOCLING_PDF_BACKEND: Optional[str] = None
|
||||
DOCLING_TABLE_MODE: Optional[str] = None
|
||||
DOCLING_PIPELINE: Optional[str] = None
|
||||
DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
|
||||
DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
|
||||
DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
|
||||
@@ -651,9 +674,6 @@ async def update_rag_config(
|
||||
if form_data.ENABLE_RAG_HYBRID_SEARCH is not None
|
||||
else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH
|
||||
)
|
||||
# Free up memory if hybrid search is disabled
|
||||
if not request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
|
||||
request.app.state.rf = None
|
||||
|
||||
request.app.state.config.TOP_K_RERANKER = (
|
||||
form_data.TOP_K_RERANKER
|
||||
@@ -757,6 +777,16 @@ async def update_rag_config(
|
||||
if form_data.DOCLING_SERVER_URL is not None
|
||||
else request.app.state.config.DOCLING_SERVER_URL
|
||||
)
|
||||
request.app.state.config.DOCLING_DO_OCR = (
|
||||
form_data.DOCLING_DO_OCR
|
||||
if form_data.DOCLING_DO_OCR is not None
|
||||
else request.app.state.config.DOCLING_DO_OCR
|
||||
)
|
||||
request.app.state.config.DOCLING_FORCE_OCR = (
|
||||
form_data.DOCLING_FORCE_OCR
|
||||
if form_data.DOCLING_FORCE_OCR is not None
|
||||
else request.app.state.config.DOCLING_FORCE_OCR
|
||||
)
|
||||
request.app.state.config.DOCLING_OCR_ENGINE = (
|
||||
form_data.DOCLING_OCR_ENGINE
|
||||
if form_data.DOCLING_OCR_ENGINE is not None
|
||||
@@ -767,7 +797,21 @@ async def update_rag_config(
|
||||
if form_data.DOCLING_OCR_LANG is not None
|
||||
else request.app.state.config.DOCLING_OCR_LANG
|
||||
)
|
||||
|
||||
request.app.state.config.DOCLING_PDF_BACKEND = (
|
||||
form_data.DOCLING_PDF_BACKEND
|
||||
if form_data.DOCLING_PDF_BACKEND is not None
|
||||
else request.app.state.config.DOCLING_PDF_BACKEND
|
||||
)
|
||||
request.app.state.config.DOCLING_TABLE_MODE = (
|
||||
form_data.DOCLING_TABLE_MODE
|
||||
if form_data.DOCLING_TABLE_MODE is not None
|
||||
else request.app.state.config.DOCLING_TABLE_MODE
|
||||
)
|
||||
request.app.state.config.DOCLING_PIPELINE = (
|
||||
form_data.DOCLING_PIPELINE
|
||||
if form_data.DOCLING_PIPELINE is not None
|
||||
else request.app.state.config.DOCLING_PIPELINE
|
||||
)
|
||||
request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
|
||||
form_data.DOCLING_DO_PICTURE_DESCRIPTION
|
||||
if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
|
||||
@@ -807,6 +851,18 @@ async def update_rag_config(
|
||||
)
|
||||
|
||||
# Reranking settings
|
||||
if request.app.state.config.RAG_RERANKING_ENGINE == "":
|
||||
# Unloading the internal reranker and clear VRAM memory
|
||||
request.app.state.rf = None
|
||||
request.app.state.RERANKING_FUNCTION = None
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
if DEVICE_TYPE == "cuda":
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
request.app.state.config.RAG_RERANKING_ENGINE = (
|
||||
form_data.RAG_RERANKING_ENGINE
|
||||
if form_data.RAG_RERANKING_ENGINE is not None
|
||||
@@ -836,19 +892,23 @@ async def update_rag_config(
|
||||
)
|
||||
|
||||
try:
|
||||
request.app.state.rf = get_rf(
|
||||
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
True,
|
||||
)
|
||||
if (
|
||||
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH
|
||||
and not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||
):
|
||||
request.app.state.rf = get_rf(
|
||||
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
True,
|
||||
)
|
||||
|
||||
request.app.state.RERANKING_FUNCTION = get_reranking_function(
|
||||
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
request.app.state.rf,
|
||||
)
|
||||
request.app.state.RERANKING_FUNCTION = get_reranking_function(
|
||||
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
request.app.state.rf,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error loading reranking model: {e}")
|
||||
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
|
||||
@@ -916,6 +976,9 @@ async def update_rag_config(
|
||||
request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = (
|
||||
form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS
|
||||
)
|
||||
request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = (
|
||||
form_data.web.WEB_LOADER_CONCURRENT_REQUESTS
|
||||
)
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = (
|
||||
form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST
|
||||
)
|
||||
@@ -1033,8 +1096,13 @@ async def update_rag_config(
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
|
||||
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
|
||||
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
|
||||
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
|
||||
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
@@ -1067,6 +1135,7 @@ async def update_rag_config(
|
||||
"WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV,
|
||||
"WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
"WEB_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
@@ -1265,7 +1334,7 @@ def save_docs_to_vector_db(
|
||||
)
|
||||
return True
|
||||
|
||||
log.info(f"adding to collection {collection_name}")
|
||||
log.info(f"generating embeddings for {collection_name}")
|
||||
embedding_function = get_embedding_function(
|
||||
request.app.state.config.RAG_EMBEDDING_ENGINE,
|
||||
request.app.state.config.RAG_EMBEDDING_MODEL,
|
||||
@@ -1301,6 +1370,7 @@ def save_docs_to_vector_db(
|
||||
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
user=user,
|
||||
)
|
||||
log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
|
||||
|
||||
items = [
|
||||
{
|
||||
@@ -1312,11 +1382,13 @@ def save_docs_to_vector_db(
|
||||
for idx, text in enumerate(texts)
|
||||
]
|
||||
|
||||
log.info(f"adding to collection {collection_name}")
|
||||
VECTOR_DB_CLIENT.insert(
|
||||
collection_name=collection_name,
|
||||
items=items,
|
||||
)
|
||||
|
||||
log.info(f"added {len(items)} items to collection {collection_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -1423,8 +1495,13 @@ def process_file(
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
|
||||
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"pipeline": request.app.state.config.DOCLING_PIPELINE,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
@@ -1472,11 +1549,18 @@ def process_file(
|
||||
file.id,
|
||||
{"content": text_content},
|
||||
)
|
||||
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
|
||||
if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
Files.update_file_data_by_id(file.id, {"status": "completed"})
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
@@ -1490,6 +1574,7 @@ def process_file(
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
@@ -1499,21 +1584,21 @@ def process_file(
|
||||
},
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
raise Exception("Error saving document to vector database")
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -1624,7 +1709,7 @@ def process_web(
|
||||
loader = get_web_loader(
|
||||
form_data.url,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
)
|
||||
docs = loader.load()
|
||||
content = " ".join([doc.page_content for doc in docs])
|
||||
@@ -1798,6 +1883,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
)
|
||||
elif engine == "tavily":
|
||||
if request.app.state.config.TAVILY_API_KEY:
|
||||
@@ -1914,6 +2000,8 @@ async def process_web_search(
|
||||
):
|
||||
|
||||
urls = []
|
||||
result_items = []
|
||||
|
||||
try:
|
||||
logging.info(
|
||||
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
|
||||
@@ -1935,6 +2023,7 @@ async def process_web_search(
|
||||
if result:
|
||||
for item in result:
|
||||
if item and item.link:
|
||||
result_items.append(item)
|
||||
urls.append(item.link)
|
||||
|
||||
urls = list(dict.fromkeys(urls))
|
||||
@@ -1971,7 +2060,7 @@ async def process_web_search(
|
||||
loader = get_web_loader(
|
||||
urls,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
|
||||
)
|
||||
docs = await loader.aload()
|
||||
@@ -1979,12 +2068,16 @@ async def process_web_search(
|
||||
urls = [
|
||||
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
|
||||
] # only keep the urls returned by the loader
|
||||
result_items = [
|
||||
dict(item) for item in result_items if item.link in urls
|
||||
] # only keep the search results that have been loaded
|
||||
|
||||
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filenames": urls,
|
||||
"items": result_items,
|
||||
"docs": [
|
||||
{
|
||||
"content": doc.page_content,
|
||||
@@ -2017,6 +2110,7 @@ async def process_web_search(
|
||||
return {
|
||||
"status": True,
|
||||
"collection_names": [collection_name],
|
||||
"items": result_items,
|
||||
"filenames": urls,
|
||||
"loaded_count": len(docs),
|
||||
}
|
||||
@@ -2044,7 +2138,9 @@ def query_doc_handler(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
try:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
|
||||
form_data.hybrid is None or form_data.hybrid
|
||||
):
|
||||
collection_results = {}
|
||||
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
|
||||
collection_name=form_data.collection_name
|
||||
@@ -2114,7 +2210,9 @@ def query_collection_handler(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
try:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
|
||||
form_data.hybrid is None or form_data.hybrid
|
||||
):
|
||||
return query_collection_with_hybrid_search(
|
||||
collection_names=form_data.collection_names,
|
||||
queries=[form_data.query],
|
||||
|
||||
@@ -198,14 +198,7 @@ async def generate_title(
|
||||
else:
|
||||
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = title_generation_template(
|
||||
template,
|
||||
form_data["messages"],
|
||||
{
|
||||
"name": user.name,
|
||||
"location": user.info.get("location") if user.info else None,
|
||||
},
|
||||
)
|
||||
content = title_generation_template(template, form_data["messages"], user)
|
||||
|
||||
max_tokens = (
|
||||
models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000)
|
||||
@@ -289,14 +282,7 @@ async def generate_follow_ups(
|
||||
else:
|
||||
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = follow_up_generation_template(
|
||||
template,
|
||||
form_data["messages"],
|
||||
{
|
||||
"name": user.name,
|
||||
"location": user.info.get("location") if user.info else None,
|
||||
},
|
||||
)
|
||||
content = follow_up_generation_template(template, form_data["messages"], user)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
@@ -369,9 +355,7 @@ async def generate_chat_tags(
|
||||
else:
|
||||
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = tags_generation_template(
|
||||
template, form_data["messages"], {"name": user.name}
|
||||
)
|
||||
content = tags_generation_template(template, form_data["messages"], user)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
@@ -437,13 +421,7 @@ async def generate_image_prompt(
|
||||
else:
|
||||
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = image_prompt_generation_template(
|
||||
template,
|
||||
form_data["messages"],
|
||||
user={
|
||||
"name": user.name,
|
||||
},
|
||||
)
|
||||
content = image_prompt_generation_template(template, form_data["messages"], user)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
@@ -492,6 +470,10 @@ async def generate_queries(
|
||||
detail=f"Query generation is disabled",
|
||||
)
|
||||
|
||||
if getattr(request.state, "cached_queries", None):
|
||||
log.info(f"Reusing cached queries: {request.state.cached_queries}")
|
||||
return request.state.cached_queries
|
||||
|
||||
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
|
||||
models = {
|
||||
request.state.model["id"]: request.state.model,
|
||||
@@ -524,9 +506,7 @@ async def generate_queries(
|
||||
else:
|
||||
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = query_generation_template(
|
||||
template, form_data["messages"], {"name": user.name}
|
||||
)
|
||||
content = query_generation_template(template, form_data["messages"], user)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
@@ -611,9 +591,7 @@ async def generate_autocompletion(
|
||||
else:
|
||||
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = autocomplete_generation_template(
|
||||
template, prompt, messages, type, {"name": user.name}
|
||||
)
|
||||
content = autocomplete_generation_template(template, prompt, messages, type, user)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
@@ -675,14 +653,7 @@ async def generate_emoji(
|
||||
|
||||
template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = emoji_generation_template(
|
||||
template,
|
||||
form_data["prompt"],
|
||||
{
|
||||
"name": user.name,
|
||||
"location": user.info.get("location") if user.info else None,
|
||||
},
|
||||
)
|
||||
content = emoji_generation_template(template, form_data["prompt"], user)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Optional
|
||||
import time
|
||||
import re
|
||||
import aiohttp
|
||||
from open_webui.models.groups import Groups
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
@@ -19,10 +20,10 @@ from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
|
||||
from open_webui.utils.tools import get_tool_specs
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.tools import get_tool_servers_data
|
||||
from open_webui.utils.tools import get_tool_servers
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.config import CACHE_DIR, ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
|
||||
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
@@ -32,6 +33,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# GetTools
|
||||
############################
|
||||
@@ -39,23 +41,14 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ToolUserResponse])
|
||||
async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
if not request.app.state.TOOL_SERVERS:
|
||||
# If the tool servers are not set, we need to set them
|
||||
# This is done only once when the server starts
|
||||
# This is done to avoid loading the tool servers every time
|
||||
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
)
|
||||
|
||||
tools = Tools.get_tools()
|
||||
for server in request.app.state.TOOL_SERVERS:
|
||||
|
||||
for server in await get_tool_servers(request):
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:{server['idx']}",
|
||||
"user_id": f"server:{server['idx']}",
|
||||
"id": f"server:{server.get('id')}",
|
||||
"user_id": f"server:{server.get('id')}",
|
||||
"name": server.get("openapi", {})
|
||||
.get("info", {})
|
||||
.get("title", "Tool Server"),
|
||||
@@ -65,7 +58,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
.get("description", ""),
|
||||
},
|
||||
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
server["idx"]
|
||||
server.get("idx", 0)
|
||||
]
|
||||
.get("config", {})
|
||||
.get("access_control", None),
|
||||
@@ -75,15 +68,16 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
)
|
||||
)
|
||||
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
# Admin can see all tools
|
||||
return tools
|
||||
else:
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
tools = [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.user_id == user.id
|
||||
or has_access(user.id, "read", tool.access_control)
|
||||
or has_access(user.id, "read", tool.access_control, user_group_ids)
|
||||
]
|
||||
return tools
|
||||
|
||||
@@ -95,7 +89,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
@router.get("/list", response_model=list[ToolUserResponse])
|
||||
async def get_tool_list(user=Depends(get_verified_user)):
|
||||
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
tools = Tools.get_tools()
|
||||
else:
|
||||
tools = Tools.get_tools_by_user_id(user.id, "write")
|
||||
|
||||
@@ -10,12 +10,15 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import (
|
||||
UserModel,
|
||||
UserListResponse,
|
||||
UserInfoListResponse,
|
||||
UserIdNameListResponse,
|
||||
UserRoleUpdateForm,
|
||||
Users,
|
||||
UserSettings,
|
||||
@@ -98,6 +101,23 @@ async def get_all_users(
|
||||
return Users.get_users()
|
||||
|
||||
|
||||
@router.get("/search", response_model=UserIdNameListResponse)
|
||||
async def search_users(
|
||||
query: Optional[str] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
|
||||
page = 1 # Always return the first page for search
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
|
||||
return Users.get_users(filter=filter, skip=skip, limit=limit)
|
||||
|
||||
|
||||
############################
|
||||
# User Groups
|
||||
############################
|
||||
@@ -146,6 +166,10 @@ class ChatPermissions(BaseModel):
|
||||
params: bool = True
|
||||
file_upload: bool = True
|
||||
delete: bool = True
|
||||
delete_message: bool = True
|
||||
continue_response: bool = True
|
||||
regenerate_response: bool = True
|
||||
rate_response: bool = True
|
||||
edit: bool = True
|
||||
share: bool = True
|
||||
export: bool = True
|
||||
@@ -336,6 +360,18 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict])
|
||||
async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
|
||||
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
|
||||
if sessions and len(sessions) > 0:
|
||||
return sessions
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserProfileImageById
|
||||
############################
|
||||
|
||||
@@ -115,7 +115,7 @@ if WEBSOCKET_MANAGER == "redis":
|
||||
|
||||
clean_up_lock = RedisLock(
|
||||
redis_url=WEBSOCKET_REDIS_URL,
|
||||
lock_name="usage_cleanup_lock",
|
||||
lock_name=f"{REDIS_KEY_PREFIX}:usage_cleanup_lock",
|
||||
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
|
||||
redis_sentinels=redis_sentinels,
|
||||
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
||||
@@ -266,7 +266,9 @@ async def connect(sid, environ, auth):
|
||||
user = Users.get_user_by_id(data["id"])
|
||||
|
||||
if user:
|
||||
SESSION_POOL[sid] = user.model_dump()
|
||||
SESSION_POOL[sid] = user.model_dump(
|
||||
exclude=["date_of_birth", "bio", "gender"]
|
||||
)
|
||||
if user.id in USER_POOL:
|
||||
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
|
||||
else:
|
||||
@@ -288,7 +290,7 @@ async def user_join(sid, data):
|
||||
if not user:
|
||||
return
|
||||
|
||||
SESSION_POOL[sid] = user.model_dump()
|
||||
SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"])
|
||||
if user.id in USER_POOL:
|
||||
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
|
||||
else:
|
||||
@@ -703,6 +705,42 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "files":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
|
||||
files = event_data.get("data", {}).get("files", [])
|
||||
files.extend(message.get("files", []))
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
"files": files,
|
||||
},
|
||||
)
|
||||
|
||||
if event_data.get("type") in ["source", "citation"]:
|
||||
data = event_data.get("data", {})
|
||||
if data.get("type") == None:
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
|
||||
sources = message.get("sources", [])
|
||||
sources.append(data)
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
"sources": sources,
|
||||
},
|
||||
)
|
||||
|
||||
return __event_emitter__
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ class S3StorageProvider(StorageProvider):
|
||||
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
|
||||
"addressing_style": S3_ADDRESSING_STYLE,
|
||||
},
|
||||
# KIT change - see https://github.com/boto/boto3/issues/4400#issuecomment-2600742103∆
|
||||
request_checksum_calculation="when_required",
|
||||
response_checksum_validation="when_required",
|
||||
)
|
||||
|
||||
# If access key and secret are provided, use them for authentication
|
||||
|
||||
@@ -153,9 +153,9 @@ async def stop_task(redis, task_id: str):
|
||||
# Optionally check if task_id still in Redis a few moments later for feedback?
|
||||
return {"status": True, "message": f"Stop signal sent for {task_id}"}
|
||||
|
||||
task = tasks.pop(task_id)
|
||||
task = tasks.pop(task_id, None)
|
||||
if not task:
|
||||
raise ValueError(f"Task with ID {task_id} not found.")
|
||||
return {"status": False, "message": f"Task with ID {task_id} not found."}
|
||||
|
||||
task.cancel() # Request task cancellation
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional, Union, List, Dict, Any
|
||||
from typing import Optional, Set, Union, List, Dict, Any
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
@@ -60,8 +60,7 @@ def get_permissions(
|
||||
|
||||
# Combine permissions from all user groups
|
||||
for group in user_groups:
|
||||
group_permissions = group.permissions or {}
|
||||
permissions = combine_permissions(permissions, group_permissions)
|
||||
permissions = combine_permissions(permissions, group.permissions or {})
|
||||
|
||||
# Ensure all fields from default_permissions are present and filled in
|
||||
permissions = fill_missing_permissions(permissions, default_permissions)
|
||||
@@ -96,8 +95,7 @@ def has_permission(
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
|
||||
for group in user_groups:
|
||||
group_permissions = group.permissions
|
||||
if get_permission(group_permissions, permission_hierarchy):
|
||||
if get_permission(group.permissions or {}, permission_hierarchy):
|
||||
return True
|
||||
|
||||
# Check default permissions afterward if the group permissions don't allow it
|
||||
@@ -111,12 +109,15 @@ def has_access(
|
||||
user_id: str,
|
||||
type: str = "write",
|
||||
access_control: Optional[dict] = None,
|
||||
user_group_ids: Optional[Set[str]] = None,
|
||||
) -> bool:
|
||||
if access_control is None:
|
||||
return type == "read"
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
if user_group_ids is None:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
permission_access = access_control.get(type, {})
|
||||
permitted_group_ids = permission_access.get("group_ids", [])
|
||||
permitted_user_ids = permission_access.get("user_ids", [])
|
||||
@@ -129,9 +130,10 @@ def has_access(
|
||||
# Get all users with access to a resource
|
||||
def get_users_with_access(
|
||||
type: str = "write", access_control: Optional[dict] = None
|
||||
) -> List[UserModel]:
|
||||
) -> list[UserModel]:
|
||||
if access_control is None:
|
||||
return Users.get_users()
|
||||
result = Users.get_users()
|
||||
return result.get("users", [])
|
||||
|
||||
permission_access = access_control.get(type, {})
|
||||
permitted_group_ids = permission_access.get("group_ids", [])
|
||||
|
||||
@@ -261,55 +261,67 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
# auth by jwt token
|
||||
try:
|
||||
data = decode_token(token)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
if data is not None and "id" in data:
|
||||
user = Users.get_user_by_id(data["id"])
|
||||
if user is None:
|
||||
try:
|
||||
try:
|
||||
data = decode_token(token)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
detail="Invalid token",
|
||||
)
|
||||
else:
|
||||
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
||||
trusted_email = request.headers.get(
|
||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
|
||||
).lower()
|
||||
if trusted_email and user.email != trusted_email:
|
||||
# Delete the token cookie
|
||||
response.delete_cookie("token")
|
||||
# Delete OAuth token if present
|
||||
if request.cookies.get("oauth_id_token"):
|
||||
response.delete_cookie("oauth_id_token")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User mismatch. Please sign in again.",
|
||||
|
||||
if data is not None and "id" in data:
|
||||
user = Users.get_user_by_id(data["id"])
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
else:
|
||||
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
||||
trusted_email = request.headers.get(
|
||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
|
||||
).lower()
|
||||
if trusted_email and user.email != trusted_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User mismatch. Please sign in again.",
|
||||
)
|
||||
|
||||
# Add user info to current span
|
||||
current_span = trace.get_current_span()
|
||||
if current_span:
|
||||
current_span.set_attribute("client.user.id", user.id)
|
||||
current_span.set_attribute("client.user.email", user.email)
|
||||
current_span.set_attribute("client.user.role", user.role)
|
||||
current_span.set_attribute("client.auth.type", "jwt")
|
||||
|
||||
# Refresh the user's last active timestamp asynchronously
|
||||
# to prevent blocking the request
|
||||
if background_tasks:
|
||||
background_tasks.add_task(
|
||||
Users.update_user_last_active_by_id, user.id
|
||||
)
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
except Exception as e:
|
||||
# Delete the token cookie
|
||||
if request.cookies.get("token"):
|
||||
response.delete_cookie("token")
|
||||
|
||||
# Add user info to current span
|
||||
current_span = trace.get_current_span()
|
||||
if current_span:
|
||||
current_span.set_attribute("client.user.id", user.id)
|
||||
current_span.set_attribute("client.user.email", user.email)
|
||||
current_span.set_attribute("client.user.role", user.role)
|
||||
current_span.set_attribute("client.auth.type", "jwt")
|
||||
if request.cookies.get("oauth_id_token"):
|
||||
response.delete_cookie("oauth_id_token")
|
||||
|
||||
# Refresh the user's last active timestamp asynchronously
|
||||
# to prevent blocking the request
|
||||
if background_tasks:
|
||||
background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
# Delete OAuth session if present
|
||||
if request.cookies.get("oauth_session_id"):
|
||||
response.delete_cookie("oauth_session_id")
|
||||
|
||||
raise e
|
||||
|
||||
|
||||
def get_current_user_by_api_key(api_key: str):
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import re
|
||||
|
||||
|
||||
def extract_mentions(message: str, triggerChar: str = "@"):
|
||||
# Escape triggerChar in case it's a regex special character
|
||||
triggerChar = re.escape(triggerChar)
|
||||
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
|
||||
|
||||
matches = re.findall(pattern, message)
|
||||
return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
|
||||
|
||||
|
||||
def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
|
||||
"""
|
||||
Replace mentions in the message with either their label (after the pipe `|`)
|
||||
or their id if no label exists.
|
||||
|
||||
Example:
|
||||
"<@M:gpt-4.1|GPT-4>" -> "GPT-4" (if use_label=True)
|
||||
"<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False)
|
||||
"""
|
||||
# Escape triggerChar
|
||||
triggerChar = re.escape(triggerChar)
|
||||
|
||||
def replacer(match):
|
||||
id_type, id_value, label = match.groups()
|
||||
return label if use_label and label else id_value
|
||||
|
||||
# Regex captures: idType, id, optional label
|
||||
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
|
||||
return re.sub(pattern, replacer, message)
|
||||
@@ -127,8 +127,10 @@ async def process_filter_functions(
|
||||
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"]
|
||||
if skip_files:
|
||||
if "files" in form_data.get("metadata", {}):
|
||||
del form_data["metadata"]["files"]
|
||||
if "files" in form_data:
|
||||
del form_data["files"]
|
||||
|
||||
return form_data, {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import sys
|
||||
import os
|
||||
import base64
|
||||
import textwrap
|
||||
|
||||
import asyncio
|
||||
from aiocache import cached
|
||||
@@ -73,6 +74,7 @@ from open_webui.utils.misc import (
|
||||
add_or_update_user_message,
|
||||
get_last_user_message,
|
||||
get_last_assistant_message,
|
||||
get_system_message,
|
||||
prepend_to_first_user_message_content,
|
||||
convert_logit_bias_input_to_json,
|
||||
)
|
||||
@@ -83,21 +85,23 @@ from open_webui.utils.filter import (
|
||||
process_filter_functions,
|
||||
)
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
from open_webui.utils.payload import apply_model_system_prompt_to_body
|
||||
from open_webui.utils.payload import apply_system_prompt_to_body
|
||||
|
||||
from open_webui.tasks import create_task
|
||||
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
DEFAULT_CODE_INTERPRETER_PROMPT,
|
||||
CODE_INTERPRETER_BLOCKED_MODULES,
|
||||
)
|
||||
from open_webui.env import (
|
||||
SRC_LOG_LEVELS,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
ENABLE_REALTIME_CHAT_SAVE,
|
||||
ENABLE_QUERIES_CACHE,
|
||||
)
|
||||
from open_webui.constants import TASKS
|
||||
|
||||
@@ -107,6 +111,20 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
DEFAULT_REASONING_TAGS = [
|
||||
("<think>", "</think>"),
|
||||
("<thinking>", "</thinking>"),
|
||||
("<reason>", "</reason>"),
|
||||
("<reasoning>", "</reasoning>"),
|
||||
("<thought>", "</thought>"),
|
||||
("<Thought>", "</Thought>"),
|
||||
("<|begin_of_thought|>", "<|end_of_thought|>"),
|
||||
("◁think▷", "◁/think▷"),
|
||||
]
|
||||
DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
|
||||
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
|
||||
|
||||
|
||||
async def chat_completion_tools_handler(
|
||||
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
|
||||
) -> tuple[dict, dict]:
|
||||
@@ -351,7 +369,7 @@ async def chat_web_search_handler(
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "web_search",
|
||||
"description": "Generating search query",
|
||||
"description": "Searching the web",
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
@@ -388,6 +406,9 @@ async def chat_web_search_handler(
|
||||
except Exception as e:
|
||||
queries = [response]
|
||||
|
||||
if ENABLE_QUERIES_CACHE:
|
||||
request.state.cached_queries = queries
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
queries = [user_message]
|
||||
@@ -414,8 +435,8 @@ async def chat_web_search_handler(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "web_search",
|
||||
"description": "Searching the web",
|
||||
"action": "web_search_queries_generated",
|
||||
"queries": queries,
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
@@ -466,6 +487,7 @@ async def chat_web_search_handler(
|
||||
"action": "web_search",
|
||||
"description": "Searched {{count}} sites",
|
||||
"urls": results["filenames"],
|
||||
"items": results.get("items", []),
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
@@ -508,7 +530,7 @@ async def chat_image_generation_handler(
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "Generating an image", "done": False},
|
||||
"data": {"description": "Creating image", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -560,7 +582,7 @@ async def chat_image_generation_handler(
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "Generated an image", "done": True},
|
||||
"data": {"description": "Image created", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -603,8 +625,9 @@ async def chat_image_generation_handler(
|
||||
|
||||
|
||||
async def chat_completion_files_handler(
|
||||
request: Request, body: dict, user: UserModel
|
||||
request: Request, body: dict, extra_params: dict, user: UserModel
|
||||
) -> tuple[dict, dict[str, list]]:
|
||||
__event_emitter__ = extra_params["__event_emitter__"]
|
||||
sources = []
|
||||
|
||||
if files := body.get("metadata", {}).get("files", None):
|
||||
@@ -640,6 +663,17 @@ async def chat_completion_files_handler(
|
||||
if len(queries) == 0:
|
||||
queries = [get_last_user_message(body["messages"])]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "queries_generated",
|
||||
"queries": queries,
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# Offload get_sources_from_items to a separate thread
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -676,6 +710,38 @@ async def chat_completion_files_handler(
|
||||
|
||||
log.debug(f"rag_contexts:sources: {sources}")
|
||||
|
||||
unique_ids = set()
|
||||
|
||||
for source in sources or []:
|
||||
if not source or len(source.keys()) == 0:
|
||||
continue
|
||||
|
||||
documents = source.get("document") or []
|
||||
metadatas = source.get("metadata") or []
|
||||
src_info = source.get("source") or {}
|
||||
|
||||
for index, _ in enumerate(documents):
|
||||
metadata = metadatas[index] if index < len(metadatas) else None
|
||||
_id = (
|
||||
(metadata or {}).get("source")
|
||||
or (src_info or {}).get("id")
|
||||
or "N/A"
|
||||
)
|
||||
unique_ids.add(_id)
|
||||
|
||||
sources_count = len(unique_ids)
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "sources_retrieved",
|
||||
"count": sources_count,
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body, {"sources": sources}
|
||||
|
||||
|
||||
@@ -687,6 +753,7 @@ def apply_params_to_form_data(form_data, model):
|
||||
"stream_response": bool,
|
||||
"stream_delta_chunk_size": int,
|
||||
"function_calling": str,
|
||||
"reasoning_tags": list,
|
||||
"system": str,
|
||||
}
|
||||
|
||||
@@ -736,9 +803,28 @@ 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}")
|
||||
|
||||
system_message = get_system_message(form_data.get("messages", []))
|
||||
if system_message:
|
||||
try:
|
||||
form_data = apply_system_prompt_to_body(
|
||||
system_message.get("content"), form_data, metadata, user
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
event_emitter = get_event_emitter(metadata)
|
||||
event_call = get_event_call(metadata)
|
||||
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
extra_params = {
|
||||
"__event_emitter__": event_emitter,
|
||||
"__event_call__": event_call,
|
||||
@@ -746,6 +832,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
"__metadata__": metadata,
|
||||
"__request__": request,
|
||||
"__model__": model,
|
||||
"__oauth_token__": oauth_token,
|
||||
}
|
||||
|
||||
# Initialize events to store additional event to be sent to the client
|
||||
@@ -777,7 +864,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
if folder and folder.data:
|
||||
if "system_prompt" in folder.data:
|
||||
form_data = apply_model_system_prompt_to_body(
|
||||
form_data = apply_system_prompt_to_body(
|
||||
folder.data["system_prompt"], form_data, metadata, user
|
||||
)
|
||||
if "files" in folder.data:
|
||||
@@ -854,7 +941,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
extra_params=extra_params,
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
raise Exception(f"{e}")
|
||||
|
||||
features = form_data.pop("features", None)
|
||||
if features:
|
||||
@@ -908,7 +995,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
tools_dict = {}
|
||||
|
||||
if tool_ids:
|
||||
tools_dict = get_tools(
|
||||
tools_dict = await get_tools(
|
||||
request,
|
||||
tool_ids,
|
||||
user,
|
||||
@@ -950,7 +1037,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
log.exception(e)
|
||||
|
||||
try:
|
||||
form_data, flags = await chat_completion_files_handler(request, form_data, user)
|
||||
form_data, flags = await chat_completion_files_handler(
|
||||
request, form_data, extra_params, user
|
||||
)
|
||||
sources.extend(flags.get("sources", []))
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -989,25 +1078,24 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
if prompt is None:
|
||||
raise Exception("No user message found")
|
||||
|
||||
if context_string == "":
|
||||
if request.app.state.config.RELEVANCE_THRESHOLD == 0:
|
||||
log.debug(
|
||||
f"With a 0 relevancy threshold for RAG, the context cannot be empty"
|
||||
)
|
||||
else:
|
||||
if context_string != "":
|
||||
# Workaround for Ollama 2.0+ system prompt issue
|
||||
# TODO: replace with add_or_update_system_message
|
||||
if model.get("owned_by") == "ollama":
|
||||
form_data["messages"] = prepend_to_first_user_message_content(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
context_string,
|
||||
prompt,
|
||||
),
|
||||
form_data["messages"],
|
||||
)
|
||||
else:
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
context_string,
|
||||
prompt,
|
||||
),
|
||||
form_data["messages"],
|
||||
)
|
||||
@@ -1043,11 +1131,11 @@ async def process_chat_response(
|
||||
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"])
|
||||
message = message_map.get(metadata["message_id"]) if message_map else None
|
||||
messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"])
|
||||
message = messages_map.get(metadata["message_id"]) if messages_map else None
|
||||
|
||||
if message:
|
||||
message_list = get_message_list(message_map, metadata["message_id"])
|
||||
message_list = get_message_list(messages_map, metadata["message_id"])
|
||||
|
||||
# Remove details tags and files from the messages.
|
||||
# as get_message_list creates a new list, it does not affect
|
||||
@@ -1254,111 +1342,134 @@ async def process_chat_response(
|
||||
# Non-streaming response
|
||||
if not isinstance(response, StreamingResponse):
|
||||
if event_emitter:
|
||||
if isinstance(response, dict) or isinstance(response, JSONResponse):
|
||||
try:
|
||||
if isinstance(response, dict) or isinstance(response, JSONResponse):
|
||||
if isinstance(response, list) and len(response) == 1:
|
||||
# If the response is a single-item list, unwrap it #17213
|
||||
response = response[0]
|
||||
|
||||
if isinstance(response, JSONResponse) and isinstance(
|
||||
response.body, bytes
|
||||
):
|
||||
try:
|
||||
response_data = json.loads(response.body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
response_data = {"error": {"detail": "Invalid JSON response"}}
|
||||
else:
|
||||
response_data = response
|
||||
|
||||
if "error" in response_data:
|
||||
error = response_data["error"].get("detail", response_data["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_data:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"selectedModelId": response_data["selected_model_id"],
|
||||
},
|
||||
)
|
||||
|
||||
choices = response_data.get("choices", [])
|
||||
if choices and choices[0].get("message", {}).get("content"):
|
||||
content = response_data["choices"][0]["message"]["content"]
|
||||
|
||||
if content:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": response_data,
|
||||
if isinstance(response, JSONResponse) and isinstance(
|
||||
response.body, bytes
|
||||
):
|
||||
try:
|
||||
response_data = json.loads(response.body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
response_data = {
|
||||
"error": {"detail": "Invalid JSON response"}
|
||||
}
|
||||
)
|
||||
else:
|
||||
response_data = response
|
||||
|
||||
title = Chats.get_chat_title_by_id(metadata["chat_id"])
|
||||
if "error" in response_data:
|
||||
error = response_data.get("error")
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"done": True,
|
||||
"content": content,
|
||||
"title": title,
|
||||
},
|
||||
}
|
||||
)
|
||||
if isinstance(error, dict):
|
||||
error = error.get("detail", error)
|
||||
else:
|
||||
error = str(error)
|
||||
|
||||
# Save message in the database
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"error": {"content": error},
|
||||
},
|
||||
)
|
||||
if isinstance(error, str) or isinstance(error, dict):
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:message:error",
|
||||
"data": {"error": {"content": error}},
|
||||
}
|
||||
)
|
||||
|
||||
if "selected_model_id" in response_data:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"selectedModelId": response_data["selected_model_id"],
|
||||
},
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if not get_active_status_by_user_id(user.id):
|
||||
webhook_url = Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
|
||||
{
|
||||
"action": "chat",
|
||||
"message": content,
|
||||
choices = response_data.get("choices", [])
|
||||
if choices and choices[0].get("message", {}).get("content"):
|
||||
content = response_data["choices"][0]["message"]["content"]
|
||||
|
||||
if content:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": response_data,
|
||||
}
|
||||
)
|
||||
|
||||
title = Chats.get_chat_title_by_id(metadata["chat_id"])
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"done": True,
|
||||
"content": content,
|
||||
"title": title,
|
||||
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
await background_tasks_handler()
|
||||
# Save message in the database
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
|
||||
if events and isinstance(events, list):
|
||||
extra_response = {}
|
||||
for event in events:
|
||||
if isinstance(event, dict):
|
||||
extra_response.update(event)
|
||||
else:
|
||||
extra_response[event] = True
|
||||
# Send a webhook notification if the user is not active
|
||||
if not get_active_status_by_user_id(user.id):
|
||||
webhook_url = Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
|
||||
{
|
||||
"action": "chat",
|
||||
"message": content,
|
||||
"title": title,
|
||||
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {
|
||||
**extra_response,
|
||||
**response_data,
|
||||
}
|
||||
await background_tasks_handler()
|
||||
|
||||
if isinstance(response, dict):
|
||||
response = response_data
|
||||
if isinstance(response, JSONResponse):
|
||||
response = JSONResponse(
|
||||
content=response_data,
|
||||
headers=response.headers,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
if events and isinstance(events, list):
|
||||
extra_response = {}
|
||||
for event in events:
|
||||
if isinstance(event, dict):
|
||||
extra_response.update(event)
|
||||
else:
|
||||
extra_response[event] = True
|
||||
|
||||
response_data = {
|
||||
**extra_response,
|
||||
**response_data,
|
||||
}
|
||||
|
||||
if isinstance(response, dict):
|
||||
response = response_data
|
||||
if isinstance(response, JSONResponse):
|
||||
response = JSONResponse(
|
||||
content=response_data,
|
||||
headers=response.headers,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Error occurred while processing request: {e}")
|
||||
pass
|
||||
|
||||
return response
|
||||
else:
|
||||
@@ -1384,11 +1495,22 @@ async def process_chat_response(
|
||||
):
|
||||
return response
|
||||
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
extra_params = {
|
||||
"__event_emitter__": event_emitter,
|
||||
"__event_call__": event_caller,
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__oauth_token__": oauth_token,
|
||||
"__request__": request,
|
||||
"__model__": model,
|
||||
}
|
||||
@@ -1458,7 +1580,7 @@ async def process_chat_response(
|
||||
tool_result_files = result.get("files", None)
|
||||
break
|
||||
|
||||
if tool_result:
|
||||
if tool_result is not None:
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||
else:
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
@@ -1573,7 +1695,7 @@ async def process_chat_response(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": result["tool_call_id"],
|
||||
"content": result["content"],
|
||||
"content": result.get("content", "") or "",
|
||||
}
|
||||
)
|
||||
temp_blocks = []
|
||||
@@ -1620,9 +1742,13 @@ async def process_chat_response(
|
||||
|
||||
match = re.search(start_tag_pattern, content)
|
||||
if match:
|
||||
attr_content = (
|
||||
match.group(1) if match.group(1) else ""
|
||||
) # Ensure it's not None
|
||||
try:
|
||||
attr_content = (
|
||||
match.group(1) if match.group(1) else ""
|
||||
) # Ensure it's not None
|
||||
except:
|
||||
attr_content = ""
|
||||
|
||||
attributes = extract_attributes(
|
||||
attr_content
|
||||
) # Extract attributes safely
|
||||
@@ -1792,27 +1918,23 @@ async def process_chat_response(
|
||||
}
|
||||
]
|
||||
|
||||
# We might want to disable this by default
|
||||
DETECT_REASONING = True
|
||||
DETECT_SOLUTION = True
|
||||
reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags")
|
||||
DETECT_REASONING_TAGS = reasoning_tags_param is not False
|
||||
DETECT_CODE_INTERPRETER = metadata.get("features", {}).get(
|
||||
"code_interpreter", False
|
||||
)
|
||||
|
||||
reasoning_tags = [
|
||||
("<think>", "</think>"),
|
||||
("<thinking>", "</thinking>"),
|
||||
("<reason>", "</reason>"),
|
||||
("<reasoning>", "</reasoning>"),
|
||||
("<thought>", "</thought>"),
|
||||
("<Thought>", "</Thought>"),
|
||||
("<|begin_of_thought|>", "<|end_of_thought|>"),
|
||||
("◁think▷", "◁/think▷"),
|
||||
]
|
||||
|
||||
code_interpreter_tags = [("<code_interpreter>", "</code_interpreter>")]
|
||||
|
||||
solution_tags = [("<|begin_of_solution|>", "<|end_of_solution|>")]
|
||||
reasoning_tags = []
|
||||
if DETECT_REASONING_TAGS:
|
||||
if (
|
||||
isinstance(reasoning_tags_param, list)
|
||||
and len(reasoning_tags_param) == 2
|
||||
):
|
||||
reasoning_tags = [
|
||||
(reasoning_tags_param[0], reasoning_tags_param[1])
|
||||
]
|
||||
else:
|
||||
reasoning_tags = DEFAULT_REASONING_TAGS
|
||||
|
||||
try:
|
||||
for event in events:
|
||||
@@ -1846,6 +1968,21 @@ async def process_chat_response(
|
||||
or 1
|
||||
),
|
||||
)
|
||||
last_delta_data = None
|
||||
|
||||
async def flush_pending_delta_data(threshold: int = 0):
|
||||
nonlocal delta_count
|
||||
nonlocal last_delta_data
|
||||
|
||||
if delta_count >= threshold and last_delta_data:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": last_delta_data,
|
||||
}
|
||||
)
|
||||
delta_count = 0
|
||||
last_delta_data = None
|
||||
|
||||
async for line in response.body_iterator:
|
||||
line = line.decode("utf-8") if isinstance(line, bytes) else line
|
||||
@@ -1886,8 +2023,28 @@ async def process_chat_response(
|
||||
"selectedModelId": model_id,
|
||||
},
|
||||
)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
else:
|
||||
choices = data.get("choices", [])
|
||||
|
||||
# 17421
|
||||
usage = data.get("usage", {}) or {}
|
||||
usage.update(data.get("timings", {})) # llama.cpp
|
||||
if usage:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"usage": usage,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if not choices:
|
||||
error = data.get("error", {})
|
||||
if error:
|
||||
@@ -1899,16 +2056,6 @@ async def process_chat_response(
|
||||
},
|
||||
}
|
||||
)
|
||||
usage = data.get("usage", {})
|
||||
if usage:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"usage": usage,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
delta = choices[0].get("delta", {})
|
||||
@@ -2043,7 +2190,7 @@ async def process_chat_response(
|
||||
content_blocks[-1]["content"] + value
|
||||
)
|
||||
|
||||
if DETECT_REASONING:
|
||||
if DETECT_REASONING_TAGS:
|
||||
content, content_blocks, _ = (
|
||||
tag_content_handler(
|
||||
"reasoning",
|
||||
@@ -2053,11 +2200,20 @@ async def process_chat_response(
|
||||
)
|
||||
)
|
||||
|
||||
content, content_blocks, _ = (
|
||||
tag_content_handler(
|
||||
"solution",
|
||||
DEFAULT_SOLUTION_TAGS,
|
||||
content,
|
||||
content_blocks,
|
||||
)
|
||||
)
|
||||
|
||||
if DETECT_CODE_INTERPRETER:
|
||||
content, content_blocks, end = (
|
||||
tag_content_handler(
|
||||
"code_interpreter",
|
||||
code_interpreter_tags,
|
||||
DEFAULT_CODE_INTERPRETER_TAGS,
|
||||
content,
|
||||
content_blocks,
|
||||
)
|
||||
@@ -2066,16 +2222,6 @@ async def process_chat_response(
|
||||
if end:
|
||||
break
|
||||
|
||||
if DETECT_SOLUTION:
|
||||
content, content_blocks, _ = (
|
||||
tag_content_handler(
|
||||
"solution",
|
||||
solution_tags,
|
||||
content,
|
||||
content_blocks,
|
||||
)
|
||||
)
|
||||
|
||||
if ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
@@ -2096,14 +2242,9 @@ async def process_chat_response(
|
||||
|
||||
if delta:
|
||||
delta_count += 1
|
||||
last_delta_data = data
|
||||
if delta_count >= delta_chunk_size:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
delta_count = 0
|
||||
await flush_pending_delta_data(delta_chunk_size)
|
||||
else:
|
||||
await event_emitter(
|
||||
{
|
||||
@@ -2118,6 +2259,7 @@ async def process_chat_response(
|
||||
else:
|
||||
log.debug(f"Error: {e}")
|
||||
continue
|
||||
await flush_pending_delta_data()
|
||||
|
||||
if content_blocks:
|
||||
# Clean up the last text block
|
||||
@@ -2154,10 +2296,12 @@ async def process_chat_response(
|
||||
|
||||
await stream_body_handler(response, form_data)
|
||||
|
||||
MAX_TOOL_CALL_RETRIES = 10
|
||||
tool_call_retries = 0
|
||||
|
||||
while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES:
|
||||
while (
|
||||
len(tool_calls) > 0
|
||||
and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES
|
||||
):
|
||||
|
||||
tool_call_retries += 1
|
||||
|
||||
@@ -2276,7 +2420,7 @@ async def process_chat_response(
|
||||
results.append(
|
||||
{
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": tool_result,
|
||||
"content": tool_result or "",
|
||||
**(
|
||||
{"files": tool_result_files}
|
||||
if tool_result_files
|
||||
@@ -2355,6 +2499,27 @@ async def process_chat_response(
|
||||
try:
|
||||
if content_blocks[-1]["attributes"].get("type") == "code":
|
||||
code = content_blocks[-1]["content"]
|
||||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
import builtins
|
||||
|
||||
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
|
||||
|
||||
_real_import = builtins.__import__
|
||||
def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name.split('.')[0] in BLOCKED_MODULES:
|
||||
importer_name = globals.get('__name__') if globals else None
|
||||
if importer_name == '__main__':
|
||||
raise ImportError(
|
||||
f"Direct import of module {{name}} is restricted."
|
||||
)
|
||||
return _real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = restricted_import
|
||||
"""
|
||||
)
|
||||
code = blocking_code + "\n" + code
|
||||
|
||||
if (
|
||||
request.app.state.config.CODE_INTERPRETER_ENGINE
|
||||
@@ -2520,7 +2685,7 @@ async def process_chat_response(
|
||||
if not get_active_status_by_user_id(user.id):
|
||||
webhook_url = Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
post_webhook(
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
|
||||
@@ -2542,7 +2707,7 @@ async def process_chat_response(
|
||||
await background_tasks_handler()
|
||||
except asyncio.CancelledError:
|
||||
log.warning("Task was cancelled!")
|
||||
await event_emitter({"type": "task-cancelled"})
|
||||
await event_emitter({"type": "chat:tasks:cancel"})
|
||||
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
@@ -2557,13 +2722,7 @@ async def process_chat_response(
|
||||
if response.background is not None:
|
||||
await response.background()
|
||||
|
||||
# background_tasks.add_task(response_handler, response, events)
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
response_handler(response, events),
|
||||
id=metadata["chat_id"],
|
||||
)
|
||||
return {"status": True, "task_id": task_id}
|
||||
return await response_handler(response, events)
|
||||
|
||||
else:
|
||||
# Fallback to the original response
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
@@ -25,7 +26,7 @@ def deep_update(d, u):
|
||||
return d
|
||||
|
||||
|
||||
def get_message_list(messages, message_id):
|
||||
def get_message_list(messages_map, message_id):
|
||||
"""
|
||||
Reconstructs a list of messages in order up to the specified message_id.
|
||||
|
||||
@@ -35,11 +36,11 @@ def get_message_list(messages, message_id):
|
||||
"""
|
||||
|
||||
# Handle case where messages is None
|
||||
if not messages:
|
||||
if not messages_map:
|
||||
return [] # Return empty list instead of None to prevent iteration errors
|
||||
|
||||
# Find the message by its id
|
||||
current_message = messages.get(message_id)
|
||||
current_message = messages_map.get(message_id)
|
||||
|
||||
if not current_message:
|
||||
return [] # Return empty list instead of None to prevent iteration errors
|
||||
@@ -52,7 +53,7 @@ def get_message_list(messages, message_id):
|
||||
0, current_message
|
||||
) # Insert the message at the beginning of the list
|
||||
parent_id = current_message.get("parentId") # Use .get() for safety
|
||||
current_message = messages.get(parent_id) if parent_id else None
|
||||
current_message = messages_map.get(parent_id) if parent_id else None
|
||||
|
||||
return message_list
|
||||
|
||||
@@ -478,3 +479,46 @@ def convert_logit_bias_input_to_json(user_input):
|
||||
bias = 100 if bias > 100 else -100 if bias < -100 else bias
|
||||
logit_bias_json[token] = bias
|
||||
return json.dumps(logit_bias_json)
|
||||
|
||||
|
||||
def freeze(value):
|
||||
"""
|
||||
Freeze a value to make it hashable.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return frozenset((k, freeze(v)) for k, v in value.items())
|
||||
elif isinstance(value, list):
|
||||
return tuple(freeze(v) for v in value)
|
||||
return value
|
||||
|
||||
|
||||
def throttle(interval: float = 10.0):
|
||||
"""
|
||||
Decorator to prevent a function from being called more than once within a specified duration.
|
||||
If the function is called again within the duration, it returns None. To avoid returning
|
||||
different types, the return type of the function should be Optional[T].
|
||||
|
||||
:param interval: Duration in seconds to wait before allowing the function to be called again.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
last_calls = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
if interval is None:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
key = (args, freeze(kwargs))
|
||||
now = time.time()
|
||||
if now - last_calls.get(key, 0) < interval:
|
||||
return None
|
||||
with lock:
|
||||
if now - last_calls.get(key, 0) < interval:
|
||||
return None
|
||||
last_calls[key] = now
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -22,10 +22,11 @@ from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
from open_webui.config import (
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
)
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
|
||||
@@ -332,3 +333,40 @@ def check_model_access(user, model):
|
||||
)
|
||||
):
|
||||
raise Exception("Model not found")
|
||||
|
||||
|
||||
def get_filtered_models(models, user):
|
||||
# Filter out models that the user does not have access to
|
||||
if (
|
||||
user.role == "user"
|
||||
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
) and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
filtered_models = []
|
||||
for model in models:
|
||||
if model.get("arena"):
|
||||
if has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("access_control", {}),
|
||||
):
|
||||
filtered_models.append(model)
|
||||
continue
|
||||
|
||||
model_info = Models.get_model_by_id(model["id"])
|
||||
if model_info:
|
||||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
)
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
else:
|
||||
return models
|
||||
|
||||
+478
-172
@@ -4,6 +4,11 @@ import mimetypes
|
||||
import sys
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import re
|
||||
import fnmatch
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
@@ -14,8 +19,12 @@ from fastapi import (
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import Users
|
||||
|
||||
|
||||
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
|
||||
from open_webui.config import (
|
||||
DEFAULT_USER_ROLE,
|
||||
@@ -46,6 +55,7 @@ from open_webui.env import (
|
||||
WEBUI_NAME,
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE,
|
||||
)
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.auth import get_password_hash, create_token
|
||||
@@ -79,15 +89,235 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||
|
||||
|
||||
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
|
||||
"""
|
||||
Check if a group name matches any blocked pattern.
|
||||
Supports exact matches, shell-style wildcards (*, ?), and regex patterns.
|
||||
|
||||
Args:
|
||||
group_name: The group name to check
|
||||
groups: List of patterns to match against
|
||||
|
||||
Returns:
|
||||
True if the group is blocked, False otherwise
|
||||
"""
|
||||
if not groups:
|
||||
return False
|
||||
|
||||
for group_pattern in groups:
|
||||
if not group_pattern: # Skip empty patterns
|
||||
continue
|
||||
|
||||
# Exact match
|
||||
if group_name == group_pattern:
|
||||
return True
|
||||
|
||||
# Try as regex pattern first if it contains regex-specific characters
|
||||
if any(
|
||||
char in group_pattern
|
||||
for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"]
|
||||
):
|
||||
try:
|
||||
# Use the original pattern as-is for regex matching
|
||||
if re.search(group_pattern, group_name):
|
||||
return True
|
||||
except re.error:
|
||||
# If regex is invalid, fall through to wildcard check
|
||||
pass
|
||||
|
||||
# Shell-style wildcard match (supports * and ?)
|
||||
if "*" in group_pattern or "?" in group_pattern:
|
||||
if fnmatch.fnmatch(group_name, group_pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class OAuthManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
self.app = app
|
||||
|
||||
self._clients = {}
|
||||
for _, provider_config in OAUTH_PROVIDERS.items():
|
||||
provider_config["register"](self.oauth)
|
||||
|
||||
def get_client(self, provider_name):
|
||||
return self.oauth.create_client(provider_name)
|
||||
if provider_name not in self._clients:
|
||||
self._clients[provider_name] = self.oauth.create_client(provider_name)
|
||||
return self._clients[provider_name]
|
||||
|
||||
def get_server_metadata_url(self, provider_name):
|
||||
if provider_name in self._clients:
|
||||
client = self._clients[provider_name]
|
||||
return (
|
||||
client.server_metadata_url
|
||||
if hasattr(client, "server_metadata_url")
|
||||
else None
|
||||
)
|
||||
return None
|
||||
|
||||
def get_oauth_token(
|
||||
self, user_id: str, session_id: str, force_refresh: bool = False
|
||||
):
|
||||
"""
|
||||
Get a valid OAuth token for the user, automatically refreshing if needed.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
provider: Optional provider name. If None, gets the most recent session.
|
||||
force_refresh: Force token refresh even if current token appears valid
|
||||
|
||||
Returns:
|
||||
dict: OAuth token data with access_token, or None if no valid token available
|
||||
"""
|
||||
try:
|
||||
# Get the OAuth session
|
||||
session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id)
|
||||
if not session:
|
||||
log.warning(
|
||||
f"No OAuth session found for user {user_id}, session {session_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
if force_refresh or datetime.now() + timedelta(
|
||||
minutes=5
|
||||
) >= datetime.fromtimestamp(session.expires_at):
|
||||
log.debug(
|
||||
f"Token refresh needed for user {user_id}, provider {session.provider}"
|
||||
)
|
||||
refreshed_token = self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
return refreshed_token
|
||||
else:
|
||||
log.warning(
|
||||
f"Token refresh failed for user {user_id}, provider {session.provider}"
|
||||
)
|
||||
return None
|
||||
return session.token
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _refresh_token(self, session) -> dict:
|
||||
"""
|
||||
Refresh an OAuth token if needed, with concurrency protection.
|
||||
|
||||
Args:
|
||||
session: The OAuth session object
|
||||
|
||||
Returns:
|
||||
dict: Refreshed token data, or None if refresh failed
|
||||
"""
|
||||
try:
|
||||
# Perform the actual refresh
|
||||
refreshed_token = await self._perform_token_refresh(session)
|
||||
|
||||
if refreshed_token:
|
||||
# Update the session with new token data
|
||||
session = OAuthSessions.update_session_by_id(
|
||||
session.id, refreshed_token
|
||||
)
|
||||
log.info(f"Successfully refreshed token for session {session.id}")
|
||||
return session.token
|
||||
else:
|
||||
log.error(f"Failed to refresh token for session {session.id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error refreshing token for session {session.id}: {e}")
|
||||
return None
|
||||
|
||||
async def _perform_token_refresh(self, session) -> dict:
|
||||
"""
|
||||
Perform the actual OAuth token refresh.
|
||||
|
||||
Args:
|
||||
session: The OAuth session object
|
||||
|
||||
Returns:
|
||||
dict: New token data, or None if refresh failed
|
||||
"""
|
||||
provider = session.provider
|
||||
token_data = session.token
|
||||
|
||||
if not token_data.get("refresh_token"):
|
||||
log.warning(f"No refresh token available for session {session.id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
client = self.get_client(provider)
|
||||
if not client:
|
||||
log.error(f"No OAuth client found for provider {provider}")
|
||||
return None
|
||||
|
||||
token_endpoint = None
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.get(client.gserver_metadata_url) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
token_endpoint = openid_data.get("token_endpoint")
|
||||
else:
|
||||
log.error(
|
||||
f"Failed to fetch OpenID configuration for provider {provider}"
|
||||
)
|
||||
if not token_endpoint:
|
||||
log.error(f"No token endpoint found for provider {provider}")
|
||||
return None
|
||||
|
||||
# Prepare refresh request
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"client_id": client.client_id,
|
||||
}
|
||||
# Add client_secret if available (some providers require it)
|
||||
if hasattr(client, "client_secret") and client.client_secret:
|
||||
refresh_data["client_secret"] = client.client_secret
|
||||
|
||||
# Make refresh request
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.post(
|
||||
token_endpoint,
|
||||
data=refresh_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status == 200:
|
||||
new_token_data = await r.json()
|
||||
|
||||
# Merge with existing token data (preserve refresh_token if not provided)
|
||||
if "refresh_token" not in new_token_data:
|
||||
new_token_data["refresh_token"] = token_data[
|
||||
"refresh_token"
|
||||
]
|
||||
|
||||
# Add timestamp for tracking
|
||||
new_token_data["issued_at"] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if (
|
||||
"expires_in" in new_token_data
|
||||
and "expires_at" not in new_token_data
|
||||
):
|
||||
new_token_data["expires_at"] = (
|
||||
datetime.now().timestamp()
|
||||
+ new_token_data["expires_in"]
|
||||
)
|
||||
|
||||
log.debug(f"Token refresh successful for provider {provider}")
|
||||
return new_token_data
|
||||
else:
|
||||
error_text = await r.text()
|
||||
log.error(
|
||||
f"Token refresh failed for provider {provider}: {r.status} - {error_text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Exception during token refresh for provider {provider}: {e}")
|
||||
return None
|
||||
|
||||
def get_user_role(self, user, user_data):
|
||||
user_count = Users.get_num_users()
|
||||
@@ -115,7 +345,13 @@ 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 []
|
||||
|
||||
oauth_roles = []
|
||||
|
||||
if isinstance(claim_data, list):
|
||||
oauth_roles = claim_data
|
||||
if isinstance(claim_data, str) or isinstance(claim_data, int):
|
||||
oauth_roles = [str(claim_data)]
|
||||
|
||||
log.debug(f"Oauth Roles claim: {oauth_claim}")
|
||||
log.debug(f"User roles from oauth: {oauth_roles}")
|
||||
@@ -232,7 +468,7 @@ class OAuthManager:
|
||||
if (
|
||||
user_oauth_groups
|
||||
and group_model.name not in user_oauth_groups
|
||||
and group_model.name not in blocked_groups
|
||||
and not is_in_blocked_groups(group_model.name, blocked_groups)
|
||||
):
|
||||
# Remove group from user
|
||||
log.debug(
|
||||
@@ -263,7 +499,7 @@ class OAuthManager:
|
||||
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)
|
||||
and group_model.name not in blocked_groups
|
||||
and not is_in_blocked_groups(group_model.name, blocked_groups)
|
||||
):
|
||||
# Add user to group
|
||||
log.debug(
|
||||
@@ -348,184 +584,226 @@ class OAuthManager:
|
||||
async def handle_callback(self, request, provider, response):
|
||||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(404)
|
||||
client = self.get_client(provider)
|
||||
|
||||
error_message = None
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except Exception as e:
|
||||
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 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}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
client = self.get_client(provider)
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except Exception as e:
|
||||
log.warning(f"OAuth callback error: {e}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
if auth_manager_config.OAUTH_SUB_CLAIM:
|
||||
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
|
||||
else:
|
||||
# Fallback to the default sub claim if not configured
|
||||
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
|
||||
# Try to get userinfo from the token first, some providers include it there
|
||||
user_data: UserInfo = token.get("userinfo")
|
||||
if (
|
||||
(not user_data)
|
||||
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
|
||||
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
|
||||
):
|
||||
user_data: UserInfo = await client.userinfo(token=token)
|
||||
if (
|
||||
provider == "feishu"
|
||||
and isinstance(user_data, dict)
|
||||
and "data" in user_data
|
||||
):
|
||||
user_data = user_data["data"]
|
||||
if not user_data:
|
||||
log.warning(f"OAuth callback failed, user data is missing: {token}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
if not sub:
|
||||
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
# Extract the "sub" claim, using custom claim if configured
|
||||
if auth_manager_config.OAUTH_SUB_CLAIM:
|
||||
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
|
||||
else:
|
||||
# Fallback to the default sub claim if not configured
|
||||
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
|
||||
if not sub:
|
||||
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
provider_sub = f"{provider}@{sub}"
|
||||
provider_sub = f"{provider}@{sub}"
|
||||
|
||||
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
|
||||
email = user_data.get(email_claim, "")
|
||||
# We currently mandate that email addresses are provided
|
||||
if not email:
|
||||
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
|
||||
if provider == "github":
|
||||
try:
|
||||
access_token = token.get("access_token")
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
"https://api.github.com/user/emails",
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
emails = await resp.json()
|
||||
# use the primary email as the user's email
|
||||
primary_email = next(
|
||||
(e["email"] for e in emails if e.get("primary")),
|
||||
None,
|
||||
)
|
||||
if primary_email:
|
||||
email = primary_email
|
||||
else:
|
||||
log.warning(
|
||||
"No primary email found in GitHub response"
|
||||
# Email extraction
|
||||
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
|
||||
email = user_data.get(email_claim, "")
|
||||
# We currently mandate that email addresses are provided
|
||||
if not email:
|
||||
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
|
||||
if provider == "github":
|
||||
try:
|
||||
access_token = token.get("access_token")
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
"https://api.github.com/user/emails",
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
emails = await resp.json()
|
||||
# use the primary email as the user's email
|
||||
primary_email = next(
|
||||
(
|
||||
e["email"]
|
||||
for e in emails
|
||||
if e.get("primary")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if primary_email:
|
||||
email = primary_email
|
||||
else:
|
||||
log.warning(
|
||||
"No primary email found in GitHub response"
|
||||
)
|
||||
raise HTTPException(
|
||||
400, detail=ERROR_MESSAGES.INVALID_CRED
|
||||
)
|
||||
else:
|
||||
log.warning("Failed to fetch GitHub email")
|
||||
raise HTTPException(
|
||||
400, detail=ERROR_MESSAGES.INVALID_CRED
|
||||
)
|
||||
else:
|
||||
log.warning("Failed to fetch GitHub email")
|
||||
raise HTTPException(
|
||||
400, detail=ERROR_MESSAGES.INVALID_CRED
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error fetching GitHub email: {e}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
else:
|
||||
log.warning(f"OAuth callback failed, email is missing: {user_data}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
email = email.lower()
|
||||
if (
|
||||
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
):
|
||||
log.warning(
|
||||
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
|
||||
)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
# Check if the user exists
|
||||
user = Users.get_user_by_oauth_sub(provider_sub)
|
||||
|
||||
if not user:
|
||||
# If the user does not exist, check if merging is enabled
|
||||
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
||||
# Check if the user exists by email
|
||||
user = Users.get_user_by_email(email)
|
||||
if user:
|
||||
# Update the user with the new oauth sub
|
||||
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
|
||||
|
||||
if user:
|
||||
determined_role = self.get_user_role(user, user_data)
|
||||
if user.role != determined_role:
|
||||
Users.update_user_role_by_id(user.id, determined_role)
|
||||
|
||||
# Update profile picture if enabled and different from current
|
||||
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
new_picture_url = user_data.get(
|
||||
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
|
||||
)
|
||||
processed_picture_url = await self._process_picture_url(
|
||||
new_picture_url, token.get("access_token")
|
||||
)
|
||||
if processed_picture_url != user.profile_image_url:
|
||||
Users.update_user_profile_image_url_by_id(
|
||||
user.id, processed_picture_url
|
||||
)
|
||||
log.debug(f"Updated profile picture for user {user.email}")
|
||||
|
||||
if not user:
|
||||
# If the user does not exist, check if signups are enabled
|
||||
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
|
||||
# Check if an existing user with the same email already exists
|
||||
existing_user = Users.get_user_by_email(email)
|
||||
if existing_user:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
picture_url = user_data.get(
|
||||
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
|
||||
)
|
||||
picture_url = await self._process_picture_url(
|
||||
picture_url, token.get("access_token")
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error fetching GitHub email: {e}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
else:
|
||||
picture_url = "/user.png"
|
||||
log.warning(f"OAuth callback failed, email is missing: {user_data}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
email = email.lower()
|
||||
|
||||
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
|
||||
|
||||
name = user_data.get(username_claim)
|
||||
if not name:
|
||||
log.warning("Username claim is missing, using email as name")
|
||||
name = email
|
||||
|
||||
role = self.get_user_role(None, user_data)
|
||||
|
||||
user = Auths.insert_new_auth(
|
||||
email=email,
|
||||
password=get_password_hash(
|
||||
str(uuid.uuid4())
|
||||
), # Random password, not used
|
||||
name=name,
|
||||
profile_image_url=picture_url,
|
||||
role=role,
|
||||
oauth_sub=provider_sub,
|
||||
# If allowed domains are configured, check if the email domain is in the list
|
||||
if (
|
||||
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
and email.split("@")[-1]
|
||||
not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
):
|
||||
log.warning(
|
||||
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
|
||||
)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
if auth_manager_config.WEBHOOK_URL:
|
||||
post_webhook(
|
||||
WEBUI_NAME,
|
||||
auth_manager_config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
# Check if the user exists
|
||||
user = Users.get_user_by_oauth_sub(provider_sub)
|
||||
if not user:
|
||||
# If the user does not exist, check if merging is enabled
|
||||
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
||||
# Check if the user exists by email
|
||||
user = Users.get_user_by_email(email)
|
||||
if user:
|
||||
# Update the user with the new oauth sub
|
||||
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
|
||||
|
||||
if user:
|
||||
determined_role = self.get_user_role(user, user_data)
|
||||
if user.role != determined_role:
|
||||
Users.update_user_role_by_id(user.id, determined_role)
|
||||
# Update profile picture if enabled and different from current
|
||||
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
new_picture_url = user_data.get(
|
||||
picture_claim,
|
||||
OAUTH_PROVIDERS[provider].get("picture_url", ""),
|
||||
)
|
||||
processed_picture_url = await self._process_picture_url(
|
||||
new_picture_url, token.get("access_token")
|
||||
)
|
||||
if processed_picture_url != user.profile_image_url:
|
||||
Users.update_user_profile_image_url_by_id(
|
||||
user.id, processed_picture_url
|
||||
)
|
||||
log.debug(f"Updated profile picture for user {user.email}")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||
# If the user does not exist, check if signups are enabled
|
||||
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
|
||||
# Check if an existing user with the same email already exists
|
||||
existing_user = Users.get_user_by_email(email)
|
||||
if existing_user:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
picture_url = user_data.get(
|
||||
picture_claim,
|
||||
OAUTH_PROVIDERS[provider].get("picture_url", ""),
|
||||
)
|
||||
picture_url = await self._process_picture_url(
|
||||
picture_url, token.get("access_token")
|
||||
)
|
||||
else:
|
||||
picture_url = "/user.png"
|
||||
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
|
||||
|
||||
name = user_data.get(username_claim)
|
||||
if not name:
|
||||
log.warning("Username claim is missing, using email as name")
|
||||
name = email
|
||||
|
||||
user = Auths.insert_new_auth(
|
||||
email=email,
|
||||
password=get_password_hash(
|
||||
str(uuid.uuid4())
|
||||
), # Random password, not used
|
||||
name=name,
|
||||
profile_image_url=picture_url,
|
||||
role=self.get_user_role(None, user_data),
|
||||
oauth_sub=provider_sub,
|
||||
)
|
||||
|
||||
if auth_manager_config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
WEBUI_NAME,
|
||||
auth_manager_config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
jwt_token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
|
||||
)
|
||||
if (
|
||||
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT
|
||||
and user.role != "admin"
|
||||
):
|
||||
self.update_user_groups(
|
||||
user=user,
|
||||
user_data=user_data,
|
||||
default_permissions=request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
|
||||
jwt_token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
|
||||
)
|
||||
|
||||
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
|
||||
self.update_user_groups(
|
||||
user=user,
|
||||
user_data=user_data,
|
||||
default_permissions=request.app.state.config.USER_PERMISSIONS,
|
||||
except Exception as e:
|
||||
log.error(f"Error during OAuth process: {e}")
|
||||
error_message = (
|
||||
e.detail
|
||||
if isinstance(e, HTTPException) and e.detail
|
||||
else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
|
||||
)
|
||||
|
||||
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
if redirect_base_url.endswith("/"):
|
||||
redirect_base_url = redirect_base_url[:-1]
|
||||
redirect_url = f"{redirect_base_url}/auth"
|
||||
|
||||
if error_message:
|
||||
redirect_url = f"{redirect_url}?error={error_message}"
|
||||
return RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
|
||||
response = RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
|
||||
# Set the cookie token
|
||||
# Redirect back to the frontend with the JWT token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=jwt_token,
|
||||
@@ -534,20 +812,48 @@ class OAuthManager:
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
if ENABLE_OAUTH_SIGNUP.value:
|
||||
oauth_id_token = token.get("id_token")
|
||||
# Legacy cookies for compatibility with older frontend versions
|
||||
if ENABLE_OAUTH_ID_TOKEN_COOKIE:
|
||||
response.set_cookie(
|
||||
key="oauth_id_token",
|
||||
value=oauth_id_token,
|
||||
value=token.get("id_token"),
|
||||
httponly=True,
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
# Redirect back to the frontend with the JWT token
|
||||
|
||||
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
if redirect_base_url.endswith("/"):
|
||||
redirect_base_url = redirect_base_url[:-1]
|
||||
redirect_url = f"{redirect_base_url}/auth"
|
||||
try:
|
||||
# Add timestamp for tracking
|
||||
token["issued_at"] = datetime.now().timestamp()
|
||||
|
||||
return RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
# Calculate expires_at if we have expires_in
|
||||
if "expires_in" in token and "expires_at" not in token:
|
||||
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
|
||||
|
||||
# Clean up any existing sessions for this user/provider first
|
||||
sessions = OAuthSessions.get_sessions_by_user_id(user.id)
|
||||
for session in sessions:
|
||||
if session.provider == provider:
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
|
||||
session = OAuthSessions.create_session(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
token=token,
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="oauth_session_id",
|
||||
value=session.id,
|
||||
httponly=True,
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to store OAuth session server-side: {e}")
|
||||
|
||||
return response
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
|
||||
|
||||
# inplace function: form_data is modified
|
||||
def apply_model_system_prompt_to_body(
|
||||
def apply_system_prompt_to_body(
|
||||
system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None
|
||||
) -> dict:
|
||||
if not system:
|
||||
@@ -22,15 +22,7 @@ def apply_model_system_prompt_to_body(
|
||||
system = prompt_variables_template(system, variables)
|
||||
|
||||
# Legacy (API Usage)
|
||||
if user:
|
||||
template_params = {
|
||||
"user_name": user.name,
|
||||
"user_location": user.info.get("location") if user.info else None,
|
||||
}
|
||||
else:
|
||||
template_params = {}
|
||||
|
||||
system = prompt_template(system, **template_params)
|
||||
system = prompt_template(system, user)
|
||||
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
system, form_data.get("messages", [])
|
||||
@@ -71,6 +63,7 @@ def remove_open_webui_params(params: dict) -> dict:
|
||||
"stream_response": bool,
|
||||
"stream_delta_chunk_size": int,
|
||||
"function_calling": str,
|
||||
"reasoning_tags": list,
|
||||
"system": str,
|
||||
}
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ def install_tool_and_function_dependencies():
|
||||
all_dependencies += f"{dependencies}, "
|
||||
for tool in tool_list:
|
||||
# Only install requirements for admin tools
|
||||
if tool.user.role == "admin":
|
||||
if tool.user and tool.user.role == "admin":
|
||||
frontmatter = extract_frontmatter(replace_imports(tool.content))
|
||||
if dependencies := frontmatter.get("requirements"):
|
||||
all_dependencies += f"{dependencies}, "
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -38,9 +38,46 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
|
||||
return template
|
||||
|
||||
|
||||
def prompt_template(
|
||||
template: str, user_name: Optional[str] = None, user_location: Optional[str] = None
|
||||
) -> str:
|
||||
def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
||||
|
||||
USER_VARIABLES = {}
|
||||
|
||||
if user:
|
||||
if hasattr(user, "model_dump"):
|
||||
user = user.model_dump()
|
||||
|
||||
if isinstance(user, dict):
|
||||
user_info = user.get("info", {}) or {}
|
||||
birth_date = user.get("date_of_birth")
|
||||
age = None
|
||||
|
||||
if birth_date:
|
||||
try:
|
||||
# If birth_date is str, convert to datetime
|
||||
if isinstance(birth_date, str):
|
||||
birth_date = datetime.strptime(birth_date, "%Y-%m-%d")
|
||||
|
||||
today = datetime.now()
|
||||
age = (
|
||||
today.year
|
||||
- birth_date.year
|
||||
- (
|
||||
(today.month, today.day)
|
||||
< (birth_date.month, birth_date.day)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
USER_VARIABLES = {
|
||||
"name": str(user.get("name")),
|
||||
"location": str(user_info.get("location")),
|
||||
"bio": str(user.get("bio")),
|
||||
"gender": str(user.get("gender")),
|
||||
"birth_date": str(birth_date),
|
||||
"age": str(age),
|
||||
}
|
||||
|
||||
# Get the current date
|
||||
current_date = datetime.now()
|
||||
|
||||
@@ -56,19 +93,20 @@ def prompt_template(
|
||||
)
|
||||
template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday)
|
||||
|
||||
if user_name:
|
||||
# Replace {{USER_NAME}} in the template with the user's name
|
||||
template = template.replace("{{USER_NAME}}", user_name)
|
||||
else:
|
||||
# Replace {{USER_NAME}} in the template with "Unknown"
|
||||
template = template.replace("{{USER_NAME}}", "Unknown")
|
||||
|
||||
if user_location:
|
||||
# Replace {{USER_LOCATION}} in the template with the current location
|
||||
template = template.replace("{{USER_LOCATION}}", user_location)
|
||||
else:
|
||||
# Replace {{USER_LOCATION}} in the template with "Unknown"
|
||||
template = template.replace("{{USER_LOCATION}}", "Unknown")
|
||||
template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown"))
|
||||
template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown"))
|
||||
template = template.replace(
|
||||
"{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown")
|
||||
)
|
||||
template = template.replace(
|
||||
"{{USER_BIRTH_DATE}}", USER_VARIABLES.get("birth_date", "Unknown")
|
||||
)
|
||||
template = template.replace(
|
||||
"{{USER_AGE}}", str(USER_VARIABLES.get("age", "Unknown"))
|
||||
)
|
||||
template = template.replace(
|
||||
"{{USER_LOCATION}}", USER_VARIABLES.get("location", "Unknown")
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
@@ -189,90 +227,56 @@ def rag_template(template: str, context: str, query: str):
|
||||
|
||||
|
||||
def title_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
template: str, messages: list[dict], user: Optional[Any] = None
|
||||
) -> str:
|
||||
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def follow_up_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
template: str, messages: list[dict], user: Optional[Any] = None
|
||||
) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def tags_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
template: str, messages: list[dict], user: Optional[Any] = None
|
||||
) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def image_prompt_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
template: str, messages: list[dict], user: Optional[Any] = None
|
||||
) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def emoji_generation_template(
|
||||
template: str, prompt: str, user: Optional[dict] = None
|
||||
template: str, prompt: str, user: Optional[Any] = None
|
||||
) -> str:
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
|
||||
return template
|
||||
|
||||
@@ -282,38 +286,24 @@ def autocomplete_generation_template(
|
||||
prompt: str,
|
||||
messages: Optional[list[dict]] = None,
|
||||
type: Optional[str] = None,
|
||||
user: Optional[dict] = None,
|
||||
user: Optional[Any] = None,
|
||||
) -> str:
|
||||
template = template.replace("{{TYPE}}", type if type else "")
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def query_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
template: str, messages: list[dict], user: Optional[Any] = None
|
||||
) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
template = prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
|
||||
@@ -163,20 +163,27 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
|
||||
@app.middleware("http")
|
||||
async def _metrics_middleware(request: Request, call_next):
|
||||
start_time = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
||||
|
||||
# Route template e.g. "/items/{item_id}" instead of real path.
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", request.url.path)
|
||||
status_code = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = getattr(response, "status_code", 500)
|
||||
return response
|
||||
except Exception:
|
||||
status_code = 500
|
||||
raise
|
||||
finally:
|
||||
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
||||
|
||||
attrs: Dict[str, str | int] = {
|
||||
"http.method": request.method,
|
||||
"http.route": route_path,
|
||||
"http.status_code": response.status_code,
|
||||
}
|
||||
# Route template e.g. "/items/{item_id}" instead of real path.
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", request.url.path)
|
||||
|
||||
request_counter.add(1, attrs)
|
||||
duration_histogram.record(elapsed_ms, attrs)
|
||||
attrs: Dict[str, str | int] = {
|
||||
"http.method": request.method,
|
||||
"http.route": route_path,
|
||||
"http.status_code": status_code,
|
||||
}
|
||||
|
||||
return response
|
||||
request_counter.add(1, attrs)
|
||||
duration_histogram.record(elapsed_ms, attrs)
|
||||
|
||||
@@ -17,6 +17,7 @@ from open_webui.env import (
|
||||
OTEL_SERVICE_NAME,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
OTEL_EXPORTER_OTLP_INSECURE,
|
||||
ENABLE_OTEL_TRACES,
|
||||
ENABLE_OTEL_METRICS,
|
||||
OTEL_BASIC_AUTH_USERNAME,
|
||||
OTEL_BASIC_AUTH_PASSWORD,
|
||||
@@ -27,29 +28,30 @@ from open_webui.env import (
|
||||
def setup(app: FastAPI, db_engine: Engine):
|
||||
# set up trace
|
||||
resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
|
||||
trace.set_tracer_provider(TracerProvider(resource=resource))
|
||||
if ENABLE_OTEL_TRACES:
|
||||
trace.set_tracer_provider(TracerProvider(resource=resource))
|
||||
|
||||
# Add basic auth header only if both username and password are not empty
|
||||
headers = []
|
||||
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
|
||||
auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
|
||||
auth_header = b64encode(auth_string.encode()).decode()
|
||||
headers = [("authorization", f"Basic {auth_header}")]
|
||||
# Add basic auth header only if both username and password are not empty
|
||||
headers = []
|
||||
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
|
||||
auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
|
||||
auth_header = b64encode(auth_string.encode()).decode()
|
||||
headers = [("authorization", f"Basic {auth_header}")]
|
||||
|
||||
# otlp export
|
||||
if OTEL_OTLP_SPAN_EXPORTER == "http":
|
||||
exporter = HttpOTLPSpanExporter(
|
||||
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
exporter = OTLPSpanExporter(
|
||||
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
insecure=OTEL_EXPORTER_OTLP_INSECURE,
|
||||
headers=headers,
|
||||
)
|
||||
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter))
|
||||
Instrumentor(app=app, db_engine=db_engine).instrument()
|
||||
# otlp export
|
||||
if OTEL_OTLP_SPAN_EXPORTER == "http":
|
||||
exporter = HttpOTLPSpanExporter(
|
||||
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
exporter = OTLPSpanExporter(
|
||||
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
insecure=OTEL_EXPORTER_OTLP_INSECURE,
|
||||
headers=headers,
|
||||
)
|
||||
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter))
|
||||
Instrumentor(app=app, db_engine=db_engine).instrument()
|
||||
|
||||
# set up metrics only if enabled
|
||||
if ENABLE_OTEL_METRICS:
|
||||
|
||||
@@ -5,6 +5,7 @@ import inspect
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import yaml
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
@@ -38,6 +39,7 @@ from open_webui.models.users import UserModel
|
||||
from open_webui.utils.plugin import load_tool_module_by_id
|
||||
from open_webui.env import (
|
||||
SRC_LOG_LEVELS,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
)
|
||||
@@ -55,19 +57,38 @@ def get_async_tool_function_and_apply_extra_params(
|
||||
extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters}
|
||||
partial_func = partial(function, **extra_params)
|
||||
|
||||
# Remove the 'frozen' keyword arguments from the signature
|
||||
# python-genai uses the signature to infer the tool properties for native function calling
|
||||
parameters = []
|
||||
for name, parameter in sig.parameters.items():
|
||||
# Exclude keyword arguments that are frozen
|
||||
if name in extra_params:
|
||||
continue
|
||||
# Keep remaining parameters
|
||||
parameters.append(parameter)
|
||||
|
||||
new_sig = inspect.Signature(
|
||||
parameters=parameters, return_annotation=sig.return_annotation
|
||||
)
|
||||
|
||||
if inspect.iscoroutinefunction(function):
|
||||
update_wrapper(partial_func, function)
|
||||
return partial_func
|
||||
# wrap the functools.partial as python-genai has trouble with it
|
||||
# https://github.com/googleapis/python-genai/issues/907
|
||||
async def new_function(*args, **kwargs):
|
||||
return await partial_func(*args, **kwargs)
|
||||
|
||||
else:
|
||||
# Make it a coroutine function
|
||||
# Make it a coroutine function when it is not already
|
||||
async def new_function(*args, **kwargs):
|
||||
return partial_func(*args, **kwargs)
|
||||
|
||||
update_wrapper(new_function, function)
|
||||
return new_function
|
||||
update_wrapper(new_function, function)
|
||||
new_function.__signature__ = new_sig
|
||||
|
||||
return new_function
|
||||
|
||||
|
||||
def get_tools(
|
||||
async def get_tools(
|
||||
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
|
||||
) -> dict[str, dict]:
|
||||
tools_dict = {}
|
||||
@@ -76,34 +97,60 @@ def get_tools(
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
if tool_id.startswith("server:"):
|
||||
server_idx = int(tool_id.split(":")[1])
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[server_idx]
|
||||
)
|
||||
server_id = tool_id.split(":")[1]
|
||||
|
||||
tool_server_data = None
|
||||
for server in request.app.state.TOOL_SERVERS:
|
||||
if server["idx"] == server_idx:
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
assert tool_server_data is not None
|
||||
specs = tool_server_data.get("specs", [])
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
token = None
|
||||
|
||||
cookies = {}
|
||||
headers = {}
|
||||
|
||||
if auth_type == "bearer":
|
||||
token = tool_server_connection.get("key", "")
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {tool_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
|
||||
def make_tool_function(function_name, token, tool_server_data):
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
def make_tool_function(function_name, tool_server_data, headers):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
token=token,
|
||||
url=tool_server_data["url"],
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
@@ -112,7 +159,7 @@ def get_tools(
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, token, tool_server_data
|
||||
function_name, tool_server_data, headers
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
@@ -126,14 +173,15 @@ def get_tools(
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
# TODO: if collision, prepend toolkit name
|
||||
if function_name in tools_dict:
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
log.warning(f"Discarding {tool_id}.{function_name}")
|
||||
else:
|
||||
tools_dict[function_name] = tool_dict
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
@@ -193,14 +241,15 @@ def get_tools(
|
||||
},
|
||||
}
|
||||
|
||||
# TODO: if collision, prepend toolkit name
|
||||
if function_name in tools_dict:
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
log.warning(f"Discarding {tool_id}.{function_name}")
|
||||
else:
|
||||
tools_dict[function_name] = tool_dict
|
||||
# Prepend tool ID to function name
|
||||
function_name = f"{tool_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
|
||||
return tools_dict
|
||||
|
||||
@@ -283,15 +332,15 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
|
||||
|
||||
field_defs = {}
|
||||
for name, param in parameters.items():
|
||||
|
||||
type_hint = type_hints.get(name, Any)
|
||||
default_value = param.default if param.default is not param.empty else ...
|
||||
|
||||
param_description = function_param_descriptions.get(name, None)
|
||||
|
||||
if param_description:
|
||||
field_defs[name] = type_hint, Field(
|
||||
default_value, description=param_description
|
||||
field_defs[name] = (
|
||||
type_hint,
|
||||
Field(default_value, description=param_description),
|
||||
)
|
||||
else:
|
||||
field_defs[name] = type_hint, default_value
|
||||
@@ -442,6 +491,34 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
return tool_payload
|
||||
|
||||
|
||||
async def set_tool_servers(request: Request):
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
)
|
||||
|
||||
if request.app.state.redis is not None:
|
||||
await request.app.state.redis.set(
|
||||
"tool_servers", json.dumps(request.app.state.TOOL_SERVERS)
|
||||
)
|
||||
|
||||
return request.app.state.TOOL_SERVERS
|
||||
|
||||
|
||||
async def get_tool_servers(request: Request):
|
||||
tool_servers = []
|
||||
if request.app.state.redis is not None:
|
||||
try:
|
||||
tool_servers = json.loads(await request.app.state.redis.get("tool_servers"))
|
||||
request.app.state.TOOL_SERVERS = tool_servers
|
||||
except Exception as e:
|
||||
log.error(f"Error fetching tool_servers from Redis: {e}")
|
||||
|
||||
if not tool_servers:
|
||||
tool_servers = await set_tool_servers(request)
|
||||
|
||||
return tool_servers
|
||||
|
||||
|
||||
async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
@@ -461,12 +538,23 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
||||
error_body = await response.json()
|
||||
raise Exception(error_body)
|
||||
|
||||
text_content = None
|
||||
|
||||
# Check if URL ends with .yaml or .yml to determine format
|
||||
if url.lower().endswith((".yaml", ".yml")):
|
||||
text_content = await response.text()
|
||||
res = yaml.safe_load(text_content)
|
||||
else:
|
||||
res = await response.json()
|
||||
text_content = await response.text()
|
||||
|
||||
try:
|
||||
res = json.loads(text_content)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
res = yaml.safe_load(text_content)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
except Exception as err:
|
||||
log.exception(f"Could not fetch tool server spec from {url}")
|
||||
if isinstance(err, dict) and "detail" in err:
|
||||
@@ -485,9 +573,7 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
async def get_tool_servers_data(
|
||||
servers: List[Dict[str, Any]], session_token: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
# Prepare list of enabled servers along with their original index
|
||||
server_entries = []
|
||||
for idx, server in enumerate(servers):
|
||||
@@ -503,13 +589,19 @@ async def get_tool_servers_data(
|
||||
|
||||
if auth_type == "bearer":
|
||||
token = server.get("key", "")
|
||||
elif auth_type == "session":
|
||||
token = session_token
|
||||
server_entries.append((idx, server, full_url, info, token))
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
|
||||
id = info.get("id")
|
||||
if not id:
|
||||
id = str(idx)
|
||||
|
||||
server_entries.append((id, idx, server, full_url, info, token))
|
||||
|
||||
# Create async tasks to fetch data
|
||||
tasks = [
|
||||
get_tool_server_data(token, url) for (_, _, url, _, token) in server_entries
|
||||
get_tool_server_data(token, url) for (_, _, _, url, _, token) in server_entries
|
||||
]
|
||||
|
||||
# Execute tasks concurrently
|
||||
@@ -517,7 +609,7 @@ async def get_tool_servers_data(
|
||||
|
||||
# Build final results with index and server metadata
|
||||
results = []
|
||||
for (idx, server, url, info, _), response in zip(server_entries, responses):
|
||||
for (id, idx, server, url, info, _), response in zip(server_entries, responses):
|
||||
if isinstance(response, Exception):
|
||||
log.error(f"Failed to connect to {url} OpenAPI tool server")
|
||||
continue
|
||||
@@ -535,6 +627,7 @@ async def get_tool_servers_data(
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": str(id),
|
||||
"idx": idx,
|
||||
"url": server.get("url"),
|
||||
"openapi": openapi_data,
|
||||
@@ -547,7 +640,12 @@ async def get_tool_servers_data(
|
||||
|
||||
|
||||
async def execute_tool_server(
|
||||
token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any]
|
||||
url: str,
|
||||
headers: Dict[str, str],
|
||||
cookies: Dict[str, str],
|
||||
name: str,
|
||||
params: Dict[str, Any],
|
||||
server_data: Dict[str, Any],
|
||||
) -> Any:
|
||||
error = None
|
||||
try:
|
||||
@@ -608,12 +706,9 @@ async def execute_tool_server(
|
||||
f"Request body expected for operation '{name}' but none found."
|
||||
)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
request_method = getattr(session, http_method.lower())
|
||||
|
||||
if http_method in ["post", "put", "patch"]:
|
||||
@@ -621,22 +716,36 @@ async def execute_tool_server(
|
||||
final_url,
|
||||
json=body_params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
raise Exception(f"HTTP error {response.status}: {text}")
|
||||
return await response.json()
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
else:
|
||||
async with request_method(
|
||||
final_url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
raise Exception(f"HTTP error {response.status}: {text}")
|
||||
return await response.json()
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as err:
|
||||
error = str(err)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import aiohttp
|
||||
|
||||
import requests
|
||||
from open_webui.config import WEBUI_FAVICON_URL
|
||||
from open_webui.env import SRC_LOG_LEVELS, VERSION
|
||||
|
||||
@@ -9,7 +9,7 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["WEBHOOK"])
|
||||
|
||||
|
||||
def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
|
||||
async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
|
||||
try:
|
||||
log.debug(f"post_webhook: {url}, {message}, {event_data}")
|
||||
payload = {}
|
||||
@@ -51,9 +51,12 @@ def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
|
||||
payload = {**event_data}
|
||||
|
||||
log.debug(f"payload: {payload}")
|
||||
r = requests.post(url, json=payload)
|
||||
r.raise_for_status()
|
||||
log.debug(f"r.text: {r.text}")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload) as r:
|
||||
r_text = await r.text()
|
||||
r.raise_for_status()
|
||||
log.debug(f"r.text: {r_text}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
+14
-16
@@ -20,8 +20,8 @@ sqlalchemy==2.0.38
|
||||
alembic==1.14.0
|
||||
peewee==3.18.1
|
||||
peewee-migrate==1.12.2
|
||||
psycopg2-binary==2.9.9
|
||||
pgvector==0.4.0
|
||||
psycopg2-binary==2.9.10
|
||||
pgvector==0.4.1
|
||||
PyMySQL==1.1.1
|
||||
bcrypt==4.3.0
|
||||
|
||||
@@ -29,7 +29,7 @@ pymongo
|
||||
redis
|
||||
boto3==1.40.5
|
||||
|
||||
argon2-cffi==23.1.0
|
||||
argon2-cffi==25.1.0
|
||||
APScheduler==3.10.4
|
||||
|
||||
pycrdt==0.12.25
|
||||
@@ -42,21 +42,20 @@ asgiref==3.8.1
|
||||
# AI libraries
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.28.0
|
||||
google-genai==1.32.0
|
||||
google-generativeai==0.8.5
|
||||
tiktoken
|
||||
|
||||
langchain==0.3.26
|
||||
langchain-community==0.3.26
|
||||
langchain-community==0.3.27
|
||||
|
||||
fake-useragent==2.2.0
|
||||
chromadb==0.6.3
|
||||
posthog==5.4.0
|
||||
chromadb==1.0.20
|
||||
pymilvus==2.5.0
|
||||
qdrant-client==1.14.3
|
||||
opensearch-py==2.8.0
|
||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||
elasticsearch==9.0.1
|
||||
elasticsearch==9.1.0
|
||||
pinecone==6.0.2
|
||||
oracledb==3.2.0
|
||||
|
||||
@@ -65,12 +64,12 @@ transformers
|
||||
sentence-transformers==4.1.0
|
||||
accelerate
|
||||
colbert-ai==0.2.21
|
||||
pyarrow==20.0.0
|
||||
einops==0.8.1
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.1
|
||||
|
||||
|
||||
ftfy==6.2.3
|
||||
pypdf==4.3.1
|
||||
pypdf==6.0.0
|
||||
fpdf2==2.8.2
|
||||
pymdown-extensions==10.14.2
|
||||
docx2txt==0.8
|
||||
@@ -99,11 +98,10 @@ onnxruntime==1.20.1
|
||||
faster-whisper==1.1.1
|
||||
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.1
|
||||
authlib==1.6.3
|
||||
|
||||
black==25.1.0
|
||||
langfuse==2.44.0
|
||||
youtube-transcript-api==1.1.0
|
||||
youtube-transcript-api==1.2.2
|
||||
pytube==15.0.0
|
||||
|
||||
pydub
|
||||
@@ -116,10 +114,10 @@ google-auth-oauthlib
|
||||
|
||||
## Tests
|
||||
docker~=7.1.0
|
||||
pytest~=8.3.5
|
||||
pytest~=8.4.1
|
||||
pytest-docker~=3.1.1
|
||||
|
||||
googleapis-common-protos==1.63.2
|
||||
googleapis-common-protos==1.70.0
|
||||
google-cloud-storage==2.19.0
|
||||
|
||||
azure-identity==1.23.0
|
||||
|
||||
+2
-2
@@ -53,12 +53,12 @@ if [ -n "$SPACE_ID" ]; then
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' &
|
||||
webui_pid=$!
|
||||
echo "Waiting for webui to start..."
|
||||
while ! curl -s http://localhost:8080/health > /dev/null; do
|
||||
while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
echo "Creating admin user..."
|
||||
curl \
|
||||
-X POST "http://localhost:8080/api/v1/auths/signup" \
|
||||
-X POST "http://localhost:${PORT}/api/v1/auths/signup" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }"
|
||||
|
||||
Generated
+84
-84
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.30",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
@@ -26,7 +26,7 @@
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.0.7",
|
||||
"@tiptap/extension-highlight": "^3.3.0",
|
||||
"@tiptap/extension-image": "^3.0.7",
|
||||
"@tiptap/extension-link": "^3.0.7",
|
||||
"@tiptap/extension-list": "^3.0.7",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@tiptap/extensions": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
@@ -46,7 +47,7 @@
|
||||
"codemirror-lang-hcl": "^0.1.0",
|
||||
"crc-32": "^1.2.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.2.5",
|
||||
"dompurify": "^3.2.6",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"focus-trap": "^7.6.4",
|
||||
@@ -66,10 +67,10 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lowlight": "^3.3.0",
|
||||
"marked": "^9.1.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mermaid": "^11.10.1",
|
||||
"paneforge": "^0.0.6",
|
||||
"panzoom": "^9.4.3",
|
||||
"pdfjs-dist": "^5.3.93",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-example-setup": "^1.2.3",
|
||||
@@ -82,7 +83,7 @@
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"pyodide": "^0.27.3",
|
||||
"pyodide": "^0.28.2",
|
||||
"socket.io-client": "^4.2.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"svelte-sonner": "^0.3.19",
|
||||
@@ -2224,9 +2225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz",
|
||||
"integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==",
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz",
|
||||
"integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"langium": "3.3.1"
|
||||
@@ -2238,9 +2239,9 @@
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz",
|
||||
"integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.78.tgz",
|
||||
"integrity": "sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
@@ -2250,22 +2251,22 @@
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.73",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.73",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.73",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.73",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.73",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.73",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.73",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.73",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.73"
|
||||
"@napi-rs/canvas-android-arm64": "0.1.78",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.78",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.78",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.78",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.78",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.78",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.78",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.78",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.78",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.78"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz",
|
||||
"integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.78.tgz",
|
||||
"integrity": "sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2279,9 +2280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz",
|
||||
"integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.78.tgz",
|
||||
"integrity": "sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2295,9 +2296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz",
|
||||
"integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.78.tgz",
|
||||
"integrity": "sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2311,9 +2312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz",
|
||||
"integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.78.tgz",
|
||||
"integrity": "sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2327,9 +2328,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz",
|
||||
"integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.78.tgz",
|
||||
"integrity": "sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2343,9 +2344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz",
|
||||
"integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.78.tgz",
|
||||
"integrity": "sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2359,9 +2360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz",
|
||||
"integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.78.tgz",
|
||||
"integrity": "sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2375,9 +2376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz",
|
||||
"integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.78.tgz",
|
||||
"integrity": "sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2391,9 +2392,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz",
|
||||
"integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.78.tgz",
|
||||
"integrity": "sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2407,9 +2408,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.73",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz",
|
||||
"integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==",
|
||||
"version": "0.1.78",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.78.tgz",
|
||||
"integrity": "sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3516,16 +3517,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-highlight": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.0.7.tgz",
|
||||
"integrity": "sha512-3oIRuXAg7l9+VPIMwHycXcqtZ7XJcC5vnLhPAQXIesYun6L9EoXmQox0225z8jpPG70N8zfl+YSd4qjsTMPaAg==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.3.0.tgz",
|
||||
"integrity": "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7"
|
||||
"@tiptap/core": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
@@ -3855,18 +3856,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/suggestion": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz",
|
||||
"integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz",
|
||||
"integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.9",
|
||||
"@tiptap/pm": "^3.0.9"
|
||||
"@tiptap/core": "^3.4.2",
|
||||
"@tiptap/pm": "^3.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/y-tiptap": {
|
||||
@@ -6767,9 +6767,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
|
||||
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -9577,14 +9577,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz",
|
||||
"integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==",
|
||||
"version": "11.10.1",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz",
|
||||
"integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.0.4",
|
||||
"@iconify/utils": "^2.1.33",
|
||||
"@mermaid-js/parser": "^0.4.0",
|
||||
"@mermaid-js/parser": "^0.6.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"cytoscape": "^3.29.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
@@ -9593,11 +9593,11 @@
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.11",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.2.4",
|
||||
"katex": "^0.16.9",
|
||||
"dompurify": "^3.2.5",
|
||||
"katex": "^0.16.22",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.7",
|
||||
"marked": "^16.0.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
@@ -9605,15 +9605,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/marked": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz",
|
||||
"integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==",
|
||||
"version": "16.2.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz",
|
||||
"integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/uuid": {
|
||||
@@ -10252,15 +10252,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.3.93",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.3.93.tgz",
|
||||
"integrity": "sha512-w3fQKVL1oGn8FRyx5JUG5tnbblggDqyx2XzA5brsJ5hSuS+I0NdnJANhmeWKLjotdbPQucLBug5t0MeWr0AAdg==",
|
||||
"version": "5.4.149",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz",
|
||||
"integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.71"
|
||||
"@napi-rs/canvas": "^0.1.77"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
@@ -10905,9 +10905,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pyodide": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.7.tgz",
|
||||
"integrity": "sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==",
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.2.tgz",
|
||||
"integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.5.0"
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.30",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.0.7",
|
||||
"@tiptap/extension-highlight": "^3.3.0",
|
||||
"@tiptap/extension-image": "^3.0.7",
|
||||
"@tiptap/extension-link": "^3.0.7",
|
||||
"@tiptap/extension-list": "^3.0.7",
|
||||
@@ -81,6 +81,7 @@
|
||||
"@tiptap/extensions": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
@@ -90,7 +91,7 @@
|
||||
"codemirror-lang-hcl": "^0.1.0",
|
||||
"crc-32": "^1.2.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.2.5",
|
||||
"dompurify": "^3.2.6",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"focus-trap": "^7.6.4",
|
||||
@@ -110,10 +111,10 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lowlight": "^3.3.0",
|
||||
"marked": "^9.1.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mermaid": "^11.10.1",
|
||||
"paneforge": "^0.0.6",
|
||||
"panzoom": "^9.4.3",
|
||||
"pdfjs-dist": "^5.3.93",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-example-setup": "^1.2.3",
|
||||
@@ -126,7 +127,7 @@
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"pyodide": "^0.27.3",
|
||||
"pyodide": "^0.28.2",
|
||||
"socket.io-client": "^4.2.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"svelte-sonner": "^0.3.19",
|
||||
|
||||
+44
-40
@@ -7,7 +7,7 @@ authors = [
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastapi==0.115.7",
|
||||
"uvicorn[standard]==0.34.2",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"pydantic==2.11.7",
|
||||
"python-multipart==0.0.20",
|
||||
|
||||
@@ -15,6 +15,10 @@ dependencies = [
|
||||
"python-jose==3.4.0",
|
||||
"passlib[bcrypt]==1.7.4",
|
||||
"cryptography",
|
||||
"bcrypt==4.3.0",
|
||||
"argon2-cffi==23.1.0",
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.6.3",
|
||||
|
||||
"requests==2.32.4",
|
||||
"aiohttp==3.12.15",
|
||||
@@ -28,54 +32,40 @@ dependencies = [
|
||||
"alembic==1.14.0",
|
||||
"peewee==3.18.1",
|
||||
"peewee-migrate==1.12.2",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pgvector==0.4.0",
|
||||
"PyMySQL==1.1.1",
|
||||
"bcrypt==4.3.0",
|
||||
|
||||
"pymongo",
|
||||
"redis",
|
||||
"boto3==1.40.5",
|
||||
|
||||
"argon2-cffi==23.1.0",
|
||||
"APScheduler==3.10.4",
|
||||
|
||||
"pycrdt==0.12.25",
|
||||
"redis",
|
||||
|
||||
"PyMySQL==1.1.1",
|
||||
"boto3==1.40.5",
|
||||
|
||||
"APScheduler==3.10.4",
|
||||
"RestrictedPython==8.0",
|
||||
|
||||
"loguru==0.7.3",
|
||||
"asgiref==3.8.1",
|
||||
|
||||
"tiktoken",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.28.0",
|
||||
"google-genai==1.32.0",
|
||||
"google-generativeai==0.8.5",
|
||||
"tiktoken",
|
||||
|
||||
"langchain==0.3.26",
|
||||
"langchain-community==0.3.26",
|
||||
"langchain-community==0.3.27",
|
||||
|
||||
"fake-useragent==2.2.0",
|
||||
"chromadb==0.6.3",
|
||||
"pymilvus==2.5.0",
|
||||
"qdrant-client==1.14.3",
|
||||
"chromadb==1.0.20",
|
||||
"opensearch-py==2.8.0",
|
||||
"playwright==1.49.1",
|
||||
"elasticsearch==9.0.1",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.2.0",
|
||||
|
||||
|
||||
"transformers",
|
||||
"sentence-transformers==4.1.0",
|
||||
"accelerate",
|
||||
"colbert-ai==0.2.21",
|
||||
"pyarrow==20.0.0",
|
||||
"einops==0.8.1",
|
||||
|
||||
"ftfy==6.2.3",
|
||||
"pypdf==4.3.1",
|
||||
"pypdf==6.0.0",
|
||||
"fpdf2==2.8.2",
|
||||
"pymdown-extensions==10.14.2",
|
||||
"docx2txt==0.8",
|
||||
@@ -100,14 +90,9 @@ dependencies = [
|
||||
"rank-bm25==0.2.2",
|
||||
|
||||
"onnxruntime==1.20.1",
|
||||
|
||||
"faster-whisper==1.1.1",
|
||||
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.6.1",
|
||||
|
||||
"black==25.1.0",
|
||||
"langfuse==2.44.0",
|
||||
"youtube-transcript-api==1.1.0",
|
||||
"pytube==15.0.0",
|
||||
|
||||
@@ -118,11 +103,9 @@ dependencies = [
|
||||
"google-auth-httplib2",
|
||||
"google-auth-oauthlib",
|
||||
|
||||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.1.1",
|
||||
|
||||
|
||||
"googleapis-common-protos==1.63.2",
|
||||
"googleapis-common-protos==1.70.0",
|
||||
"google-cloud-storage==2.19.0",
|
||||
|
||||
"azure-identity==1.20.0",
|
||||
@@ -131,14 +114,9 @@ dependencies = [
|
||||
"ldap3==2.9.1",
|
||||
|
||||
"firecrawl-py==1.12.0",
|
||||
|
||||
"tencentcloud-sdk-python==3.0.1336",
|
||||
|
||||
"gcp-storage-emulator>=2024.8.3",
|
||||
|
||||
"moto[s3]>=5.0.26",
|
||||
|
||||
"oracledb>=3.2.0",
|
||||
"posthog==5.4.0",
|
||||
|
||||
]
|
||||
readme = "README.md"
|
||||
@@ -154,6 +132,32 @@ classifiers = [
|
||||
"Topic :: Multimedia",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
postgres = [
|
||||
"psycopg2-binary==2.9.10",
|
||||
"pgvector==0.4.1",
|
||||
]
|
||||
|
||||
all = [
|
||||
"pymongo",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pgvector==0.4.0",
|
||||
"moto[s3]>=5.0.26",
|
||||
"gcp-storage-emulator>=2024.8.3",
|
||||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.1.1",
|
||||
"playwright==1.49.1",
|
||||
"elasticsearch==9.1.0",
|
||||
|
||||
"qdrant-client==1.14.3",
|
||||
"pymilvus==2.5.0",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.2.0",
|
||||
|
||||
"colbert-ai==0.2.21",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
open-webui = "open_webui:app"
|
||||
|
||||
|
||||
+32
-8
@@ -70,23 +70,23 @@ textarea::placeholder {
|
||||
}
|
||||
|
||||
.input-prose {
|
||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.input-prose-sm {
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
}
|
||||
|
||||
.markdown-prose {
|
||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-sm {
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-xs {
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
@@ -282,6 +282,14 @@ input[type='number'] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
@apply !bg-white dark:!bg-black !border-none;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply bg-white dark:bg-black;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='dark'] {
|
||||
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
|
||||
}
|
||||
@@ -401,17 +409,33 @@ input[type='number'] {
|
||||
}
|
||||
}
|
||||
|
||||
.tiptap .mention {
|
||||
.mention {
|
||||
border-radius: 0.4rem;
|
||||
box-decoration-break: clone;
|
||||
padding: 0.1rem 0.3rem;
|
||||
@apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20;
|
||||
@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
|
||||
}
|
||||
|
||||
.tiptap .mention::after {
|
||||
.mention::after {
|
||||
content: '\200B';
|
||||
}
|
||||
|
||||
.tiptap .suggestion {
|
||||
border-radius: 0.4rem;
|
||||
box-decoration-break: clone;
|
||||
padding: 0.1rem 0.3rem;
|
||||
@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
|
||||
}
|
||||
|
||||
.tiptap .suggestion::after {
|
||||
content: '\200B';
|
||||
}
|
||||
|
||||
.tiptap .suggestion.is-empty::after {
|
||||
content: '\00A0';
|
||||
border-bottom: 1px dotted rgba(31, 41, 55, 0.12);
|
||||
}
|
||||
|
||||
.input-prose .tiptap ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
|
||||
+35
-17
@@ -2,29 +2,42 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" crossorigin="use-credentials" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/static/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/static/favicon.svg"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<link rel="shortcut icon" href="/static/favicon.ico" crossorigin="use-credentials" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/static/apple-touch-icon.png"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/manifest.json"
|
||||
crossorigin="use-credentials"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="description" content="Open WebUI" />
|
||||
<link
|
||||
rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
title="Open WebUI"
|
||||
href="/opensearch.xml"
|
||||
/>
|
||||
<script src="/static/loader.js" defer></script>
|
||||
<link rel="stylesheet" href="/static/custom.css" />
|
||||
|
||||
<script src="/static/loader.js" defer crossorigin="use-credentials"></script>
|
||||
<link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />
|
||||
|
||||
<script>
|
||||
function resizeIframe(obj) {
|
||||
@@ -86,6 +99,10 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
if (document.documentElement.classList.contains('her')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (splash) splash.prepend(logo);
|
||||
});
|
||||
})();
|
||||
@@ -167,6 +184,7 @@
|
||||
<style type="text/css" nonce="">
|
||||
html {
|
||||
overflow-y: hidden !important;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
#splash-screen {
|
||||
|
||||
@@ -393,7 +393,7 @@ export const addUser = async (
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateUserProfile = async (token: string, name: string, profileImageUrl: string) => {
|
||||
export const updateUserProfile = async (token: string, profile: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/update/profile`, {
|
||||
@@ -403,8 +403,7 @@ export const updateUserProfile = async (token: string, name: string, profileImag
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
profile_image_url: profileImageUrl
|
||||
...profile
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { splitStream } from '$lib/utils';
|
||||
|
||||
export const uploadFile = async (token: string, file: File, metadata?: object | null) => {
|
||||
const data = new FormData();
|
||||
@@ -31,6 +32,75 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
const status = await getFileProcessStatus(token, res.id);
|
||||
|
||||
if (status && status.ok) {
|
||||
const reader = status.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(splitStream('\n'))
|
||||
.getReader();
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
let lines = value.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line !== '') {
|
||||
console.log(line);
|
||||
if (line === 'data: [DONE]') {
|
||||
console.log(line);
|
||||
} else {
|
||||
let data = JSON.parse(line.replace(/^data: /, ''));
|
||||
console.log(data);
|
||||
|
||||
if (data?.error) {
|
||||
console.error(data.error);
|
||||
res.error = data.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFileProcessStatus = async (token: string, id: string) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('stream', 'true');
|
||||
|
||||
let error = null;
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/${id}/process/status?${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}).catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
type FolderForm = {
|
||||
name: string;
|
||||
name?: string;
|
||||
data?: Record<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
export const createNewFolder = async (token: string, folderForm: FolderForm) => {
|
||||
|
||||
+30
-10
@@ -347,25 +347,31 @@ export const getToolServerData = async (token: string, url: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getToolServersData = async (i18n, servers: object[]) => {
|
||||
export const getToolServersData = async (servers: object[]) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
servers
|
||||
.filter((server) => server?.config?.enable)
|
||||
.map(async (server) => {
|
||||
let error = null;
|
||||
|
||||
let toolServerToken = null;
|
||||
const auth_type = server?.auth_type ?? 'bearer';
|
||||
if (auth_type === 'bearer') {
|
||||
toolServerToken = server?.key;
|
||||
} else if (auth_type === 'none') {
|
||||
// No authentication
|
||||
} else if (auth_type === 'session') {
|
||||
toolServerToken = localStorage.token;
|
||||
}
|
||||
|
||||
const data = await getToolServerData(
|
||||
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
|
||||
toolServerToken,
|
||||
(server?.path ?? '').includes('://')
|
||||
? server?.path
|
||||
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
|
||||
).catch((err) => {
|
||||
toast.error(
|
||||
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
|
||||
URL: (server?.path ?? '').includes('://')
|
||||
? server?.path
|
||||
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
|
||||
})
|
||||
);
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -377,6 +383,13 @@ export const getToolServersData = async (i18n, servers: object[]) => {
|
||||
info: info,
|
||||
specs: specs
|
||||
};
|
||||
} else if (error) {
|
||||
return {
|
||||
error,
|
||||
url: server?.url
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -480,7 +493,14 @@ export const executeToolServer = async (
|
||||
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
let responseData;
|
||||
try {
|
||||
responseData = await res.json();
|
||||
} catch (err) {
|
||||
responseData = await res.text();
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
console.error('API Request Error:', error);
|
||||
|
||||
@@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export const getNoteList = async (token: string = '') => {
|
||||
export const getNoteList = async (token: string = '', page: number | null = null) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
|
||||
if (page !== null) {
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
||||
@@ -234,7 +234,7 @@ export const getOllamaModels = async (token: string = '', urlIdx: null | number
|
||||
return (res?.models ?? [])
|
||||
.map((model) => ({ id: model.model, name: model.name ?? model.model, ...model }))
|
||||
.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
return (a?.name ?? a?.id ?? '').localeCompare(b?.name ?? b?.id ?? '');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -372,6 +372,7 @@ export const generateOpenAIChatCompletion = async (
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
||||
@@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const searchUsers = async (token: string, query: string) => {
|
||||
let error = null;
|
||||
let res = null;
|
||||
|
||||
res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getUserSettings = async (token: string) => {
|
||||
let error = null;
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
let url = '';
|
||||
let key = '';
|
||||
let auth_type = 'bearer';
|
||||
|
||||
let connectionType = 'external';
|
||||
let azure = false;
|
||||
@@ -74,6 +75,7 @@
|
||||
url,
|
||||
key,
|
||||
config: {
|
||||
auth_type,
|
||||
azure: azure,
|
||||
api_version: apiVersion
|
||||
}
|
||||
@@ -108,7 +110,7 @@
|
||||
|
||||
if (!ollama && !url) {
|
||||
loading = false;
|
||||
toast.error('URL is required');
|
||||
toast.error($i18n.t('URL is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,20 +118,20 @@
|
||||
if (!apiVersion) {
|
||||
loading = false;
|
||||
|
||||
toast.error('API Version is required');
|
||||
toast.error($i18n.t('API Version is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
|
||||
loading = false;
|
||||
|
||||
toast.error('Key is required');
|
||||
toast.error($i18n.t('Key is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
loading = false;
|
||||
toast.error('Deployment names are required');
|
||||
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -146,6 +148,7 @@
|
||||
prefix_id: prefixId,
|
||||
model_ids: modelIds,
|
||||
connection_type: connectionType,
|
||||
auth_type,
|
||||
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
|
||||
}
|
||||
};
|
||||
@@ -157,6 +160,7 @@
|
||||
|
||||
url = '';
|
||||
key = '';
|
||||
auth_type = 'bearer';
|
||||
prefixId = '';
|
||||
tags = [];
|
||||
modelIds = [];
|
||||
@@ -167,6 +171,8 @@
|
||||
url = connection.url;
|
||||
key = connection.key;
|
||||
|
||||
auth_type = connection.config.auth_type ?? 'bearer';
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
tags = connection.config?.tags ?? [];
|
||||
prefixId = connection.config?.prefix_id ?? '';
|
||||
@@ -305,23 +311,72 @@
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
<label
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Auth')}</label
|
||||
>
|
||||
{$i18n.t('Key')}
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<SensitiveInput
|
||||
inputClassName={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={key}
|
||||
placeholder={$i18n.t('API Key')}
|
||||
required={false}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
<option value="bearer">{$i18n.t('Bearer')}</option>
|
||||
|
||||
{#if !ollama}
|
||||
<option value="session">{$i18n.t('Session')}</option>
|
||||
{#if !direct}
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{#if azure}
|
||||
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if auth_type === 'bearer'}
|
||||
<SensitiveInput
|
||||
bind:value={key}
|
||||
placeholder={$i18n.t('API Key')}
|
||||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
@@ -349,6 +404,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Provider Type')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<button
|
||||
on:click={() => {
|
||||
azure = !azure;
|
||||
}}
|
||||
type="button"
|
||||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{azure ? $i18n.t('Azure OpenAI') : $i18n.t('OpenAI')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if azure}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
@@ -374,37 +452,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>
|
||||
{$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">
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="mb-1 flex justify-between">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
@@ -460,8 +508,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-1.5 w-full" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
||||
<input
|
||||
@@ -489,6 +535,34 @@
|
||||
</div>
|
||||
</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
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>
|
||||
{$i18n.t('Tags')}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-0.5">
|
||||
<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>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user