Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d1c85c99 | ||
|
|
b6a485371d | ||
|
|
da236d5b38 | ||
|
|
e7fa86aa26 | ||
|
|
51a138aec3 | ||
|
|
aee7aaae68 | ||
|
|
2d94b8e905 | ||
|
|
8656bfc5f4 | ||
|
|
d37f108ccd | ||
|
|
88aca2e4d8 | ||
|
|
234aa69753 | ||
|
|
f58fc753e3 | ||
|
|
118549caf3 | ||
|
|
1c418a7f83 | ||
|
|
fe28097817 | ||
|
|
7bae9053ac | ||
|
|
231d182c35 | ||
|
|
fe54fb61aa | ||
|
|
7e8ee46d2c | ||
|
|
da6748a68f | ||
|
|
91b6483aa9 | ||
|
|
cf20f04bdc | ||
|
|
67b3929954 | ||
|
|
ad9cae32dc | ||
|
|
a4e0c10f34 | ||
|
|
b1e63639cd | ||
|
|
0e99c43495 | ||
|
|
4f06f29348 | ||
|
|
4c2c58214c | ||
|
|
9a8c9ecb4d | ||
|
|
95462a394a | ||
|
|
11fd0735d1 | ||
|
|
2cb35e7142 | ||
|
|
e71ed76165 | ||
|
|
742e2ff193 | ||
|
|
3aad157006 | ||
|
|
a572cf4842 | ||
|
|
97dae432ab | ||
|
|
db9d3d386d | ||
|
|
4aa41aa139 | ||
|
|
5568a312cd | ||
|
|
2915bcf28c | ||
|
|
54ed62f7b0 | ||
|
|
baf913c832 | ||
|
|
57c02b2523 | ||
|
|
670dfca6a8 | ||
|
|
fb3eeaa126 | ||
|
|
30550d9190 | ||
|
|
cafe748b1b | ||
|
|
1773a4d4b8 | ||
|
|
27af4506df | ||
|
|
01d4a8ab7a | ||
|
|
8dc43f9e3a | ||
|
|
50a2204046 | ||
|
|
baed3035ac | ||
|
|
88bfe777cd | ||
|
|
f912b06b42 | ||
|
|
2b5dca2929 | ||
|
|
2c80f60f3e | ||
|
|
1283a8ce79 | ||
|
|
68a4fdf0e9 | ||
|
|
27cd87e9ad | ||
|
|
272c6f5ec5 | ||
|
|
86ef57f6c3 | ||
|
|
b4eea78aff | ||
|
|
1a18928c94 | ||
|
|
d7c54d92b5 | ||
|
|
e62f2b3c75 | ||
|
|
45e1c72613 | ||
|
|
23f62a7312 | ||
|
|
d1a6c6b209 | ||
|
|
bad7d69a58 | ||
|
|
a05dab6298 | ||
|
|
c80bb31968 | ||
|
|
54beeeaf72 | ||
|
|
1de5827eb3 | ||
|
|
b77848244b | ||
|
|
16cf973ce5 | ||
|
|
807a8be299 | ||
|
|
5765a87720 | ||
|
|
a1829f6a3e | ||
|
|
b8c3e5ed3e | ||
|
|
d10b518f42 | ||
|
|
4997ef2662 | ||
|
|
cda4c95c86 | ||
|
|
41e4e7395c | ||
|
|
680783266c | ||
|
|
99d7773230 | ||
|
|
ac08529380 | ||
|
|
a80d5240f6 | ||
|
|
9b86932714 | ||
|
|
0431ad9cc4 | ||
|
|
f8a3ed2d18 | ||
|
|
dcb0933149 | ||
|
|
b516431569 | ||
|
|
af6a25eed7 | ||
|
|
acdafcd18d | ||
|
|
a97cdc0da0 | ||
|
|
ac6292b812 | ||
|
|
d126c23ef8 | ||
|
|
36da2b06a5 | ||
|
|
90a5b3befb | ||
|
|
42faa63227 | ||
|
|
9e3d5407ae | ||
|
|
b550d78905 | ||
|
|
123dbf152e | ||
|
|
e7ccaf6e78 | ||
|
|
15bd5040f1 | ||
|
|
598282cf75 | ||
|
|
f7ea60b500 | ||
|
|
2fa222d00a | ||
|
|
6920fed97e | ||
|
|
528e58155b | ||
|
|
503fc354cc | ||
|
|
4eec423950 | ||
|
|
52bc5306b0 | ||
|
|
90e4b49b88 | ||
|
|
750a659a9f | ||
|
|
6fde0f2960 | ||
|
|
a0e323b89b | ||
|
|
a68ac4efe6 | ||
|
|
393bdd7ead | ||
|
|
7f411dd5cc | ||
|
|
c790ec7625 | ||
|
|
c46f3d05a8 | ||
|
|
b38d59dee6 | ||
|
|
da661756fa | ||
|
|
46967ffd04 | ||
|
|
5b1f9e3e21 | ||
|
|
776d4773a9 | ||
|
|
eeed0df743 | ||
|
|
b1006a2b57 | ||
|
|
53cd660de7 | ||
|
|
a7061383e8 | ||
|
|
97529e8ae4 | ||
|
|
c579e1fe44 | ||
|
|
17080a1c00 | ||
|
|
e6b3c91343 | ||
|
|
deb64fa6a6 | ||
|
|
9bc8784e3e | ||
|
|
d6f93753c1 | ||
|
|
0f12d512c4 | ||
|
|
b76d234f97 | ||
|
|
4aa5574ba7 | ||
|
|
ead1c1881c | ||
|
|
3df5826d93 | ||
|
|
cd417ca0ba | ||
|
|
4f8f8e0e0b | ||
|
|
05d7d1e562 | ||
|
|
08f4a2dff8 | ||
|
|
ff44fa242d | ||
|
|
e4f27ab75f | ||
|
|
86f024b9d7 | ||
|
|
3c7d01163d | ||
|
|
4a7e1b93e5 | ||
|
|
98883a68f2 | ||
|
|
b8a51de977 | ||
|
|
cd7bd0aa20 | ||
|
|
4d16cf6bf3 | ||
|
|
422d38fd11 | ||
|
|
879abd7fee | ||
|
|
77e971dd9f | ||
|
|
4f22a1c5e4 | ||
|
|
e201dc995c | ||
|
|
972be4eda5 | ||
|
|
34d16791a8 | ||
|
|
d3b09c6a02 | ||
|
|
27d61307cd | ||
|
|
685cca5bd5 | ||
|
|
bd02a456ab | ||
|
|
e5daec32ba | ||
|
|
b03529b006 | ||
|
|
612a52d7bb | ||
|
|
aa6f63a335 | ||
|
|
9f0010e234 | ||
|
|
e7061b70e9 | ||
|
|
fe65fe0b97 | ||
|
|
3d2f7e3143 | ||
|
|
1df5f1f9f6 | ||
|
|
05c46008da | ||
|
|
1493a5fd32 | ||
|
|
4b6d34438b | ||
|
|
e06489d92b | ||
|
|
c32b407f8a | ||
|
|
ab30b105e2 | ||
|
|
f27ab14bd8 | ||
|
|
23a51f2d01 | ||
|
|
05732de898 | ||
|
|
316f1d6ff0 | ||
|
|
b4d8287946 | ||
|
|
0dee15ba97 | ||
|
|
0e3b6b3b8f | ||
|
|
32086b6ecd | ||
|
|
2a95cbcef7 | ||
|
|
6614c73b6c | ||
|
|
2904a78222 | ||
|
|
ac879513e5 | ||
|
|
6d69ea3ac7 | ||
|
|
f096e99059 | ||
|
|
72d19e44dc | ||
|
|
b25c7da154 | ||
|
|
b8086c5edf | ||
|
|
91a9f32904 | ||
|
|
199f371f58 | ||
|
|
6cdadc73f2 | ||
|
|
c5a967e05f | ||
|
|
651f385ba5 | ||
|
|
f25a144e09 | ||
|
|
5eaee44daa | ||
|
|
9c76fb267a | ||
|
|
cf49b823b0 | ||
|
|
5be58f2601 | ||
|
|
1c64269387 | ||
|
|
f33d6eabdf | ||
|
|
7ae42f5ae3 | ||
|
|
9710060c27 | ||
|
|
06e5c00f24 | ||
|
|
f7b0fdde2a | ||
|
|
7aa3903b6b | ||
|
|
6bf4ba523d | ||
|
|
c42c0436f2 | ||
|
|
61f20acf61 | ||
|
|
c55afc4255 | ||
|
|
f1bbf3a91e | ||
|
|
de7f7b3d85 | ||
|
|
777e81f7a8 | ||
|
|
aeb5288a3c | ||
|
|
2771c26729 | ||
|
|
e4e97e727e | ||
|
|
1afa366dcb | ||
|
|
7c1b6f9157 | ||
|
|
651dea3864 | ||
|
|
96e71cf181 | ||
|
|
ac5cc4a050 | ||
|
|
48dc333a3d | ||
|
|
628b753a35 | ||
|
|
c28fbcb232 | ||
|
|
2edfde1990 | ||
|
|
01fcd057b7 | ||
|
|
fb3b736a35 | ||
|
|
064d9d6141 | ||
|
|
fd7385c392 | ||
|
|
834824ce7b | ||
|
|
466d5bb696 | ||
|
|
6e4a2f18e1 | ||
|
|
5e6e25f8c8 | ||
|
|
428f9a22c4 | ||
|
|
92d79150bd | ||
|
|
6bc5d331a2 | ||
|
|
a7ae280a4f | ||
|
|
2a1c1e34dc | ||
|
|
4b4fda46cf | ||
|
|
4b029048db | ||
|
|
34feaeeb96 | ||
|
|
a6c53303dd | ||
|
|
204484d8e7 | ||
|
|
41e4f33c1d | ||
|
|
94770f6059 | ||
|
|
c4fed37af9 | ||
|
|
b0279c5090 | ||
|
|
73f8237d43 | ||
|
|
4ad7430986 | ||
|
|
27a91cc80a | ||
|
|
820321ad55 | ||
|
|
7042318c34 | ||
|
|
6ae6cc9741 | ||
|
|
dbd7b90123 | ||
|
|
afad34fd09 | ||
|
|
167f4ba8ad | ||
|
|
7f6b260c35 | ||
|
|
62517f01e5 | ||
|
|
293531549c | ||
|
|
60db9ec8ef | ||
|
|
7528f24b61 | ||
|
|
e4c4ba0979 | ||
|
|
a89ffccd7e | ||
|
|
7be5b7f50f | ||
|
|
a5d8882bba | ||
|
|
07c5b25bc8 | ||
|
|
10c9a7d75e | ||
|
|
78e7f14360 | ||
|
|
700894a13d | ||
|
|
1c78594379 | ||
|
|
48cd72fab0 | ||
|
|
04251b7bff | ||
|
|
e150d8763b | ||
|
|
0159a33306 | ||
|
|
c07086401e | ||
|
|
2de11f8daf | ||
|
|
0e75e8529e | ||
|
|
40da51d37f | ||
|
|
bfc8f74477 | ||
|
|
d4f21c7e84 | ||
|
|
b9e200f104 | ||
|
|
da7610f6cb | ||
|
|
f188d570f7 | ||
|
|
52e8c6c998 | ||
|
|
7735a04783 | ||
|
|
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 | ||
|
|
b0720ec2aa | ||
|
|
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 | ||
|
|
f704d5b0b3 | ||
|
|
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 | ||
|
|
4ca43004ed | ||
|
|
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 | ||
|
|
7c0bf59c7c | ||
|
|
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 | ||
|
|
b50ae5bf69 | ||
|
|
39ae9167ee | ||
|
|
20b6902b9f | ||
|
|
08b958cfc9 | ||
|
|
c62f30e22c | ||
|
|
562710fe33 | ||
|
|
647e38f701 | ||
|
|
292cb62d4a | ||
|
|
05223f720d | ||
|
|
32a303f392 | ||
|
|
1d1a83b754 | ||
|
|
1ca5ad47b1 | ||
|
|
3111d1bf61 | ||
|
|
d735b036fe | ||
|
|
9d80cc3b2d | ||
|
|
0ebe4f8f84 | ||
|
|
be373e9fd4 | ||
|
|
898826dc22 | ||
|
|
0bca4e230e | ||
|
|
ef5374a34e | ||
|
|
ceaafbbfd2 | ||
|
|
7b0ce47eb8 | ||
|
|
a64586f9c2 | ||
|
|
e214d59d10 |
@@ -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
|
||||
@@ -449,7 +449,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
|
||||
@@ -535,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
|
||||
@@ -589,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
|
||||
@@ -645,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
|
||||
@@ -701,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
|
||||
@@ -757,7 +757,7 @@ jobs:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-slim-*
|
||||
path: /tmp/digests
|
||||
|
||||
@@ -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
|
||||
|
||||
+216
-1
@@ -5,13 +5,228 @@ 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.32] - 2025-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- 🗝️ Permission toggle for public sharing of notes was added, allowing note owners to quickly enable or disable public access from the note settings interface.
|
||||
- ⚠️ A warning is now displayed in the user edit modal if conflicting group permissions are detected, helping administrators resolve access control ambiguities before saving changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🧰 Fixed regression where External Tool servers (OpenAPI) were nonfunctional after the 0.6.31 update; external tools integration is now restored and reliable.
|
||||
- 🚑 Resolved a critical bug causing Streamable HTTP OAuth 2.1 (MCP server) integrations to throw a 500 error on first invocation due to missing 'SessionMiddleware'. OAuth 2.1 registration now succeeds and works on subsequent requests as expected.
|
||||
- 🐛 The "Set as default" option is now reliably clickable in model and filter selection menus, fixing cases where the interface appeared unresponsive.
|
||||
- 🛠️ Embed UI now works seamlessly with both default and native function calling flows, ensuring the tool embedding experience is consistent regardless of invocation method.
|
||||
- 🧹 Addressed various minor UI bugs and inconsistencies for a cleaner user experience.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧬 MCP tool result handling code was refactored for improved parsing and robustness of tool outputs.
|
||||
- 🧩 The user edit modal was overhauled for clarity and usability, improving the organization of group, permission, and public sharing controls.
|
||||
|
||||
## [0.6.31] - 2025-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
|
||||
- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
|
||||
- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
|
||||
- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
|
||||
- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
|
||||
- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
|
||||
- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
|
||||
- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
|
||||
- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
|
||||
- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
|
||||
- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
|
||||
- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
|
||||
- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
|
||||
- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
|
||||
- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
|
||||
- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
|
||||
- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
|
||||
- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
|
||||
- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
|
||||
- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
|
||||
- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
|
||||
- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
|
||||
- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
|
||||
- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
|
||||
- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
|
||||
- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
|
||||
- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
|
||||
- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
|
||||
- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
|
||||
- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
|
||||
- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
|
||||
- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
|
||||
- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
|
||||
- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
|
||||
- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
|
||||
- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
|
||||
- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
|
||||
- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
|
||||
|
||||
### Changed
|
||||
|
||||
- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
|
||||
- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
|
||||
|
||||
## [0.6.30] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🔑 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 SupportA**: 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.
|
||||
- 📱 **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.
|
||||
|
||||
+13
-7
@@ -4,6 +4,7 @@
|
||||
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
|
||||
@@ -25,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
|
||||
@@ -45,6 +49,7 @@ 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
|
||||
@@ -123,7 +128,6 @@ RUN apt-get update && \
|
||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
|
||||
RUN pip3 install --no-cache-dir uv && \
|
||||
if [ "$USE_SLIM" != "true" ]; then \
|
||||
if [ "$USE_CUDA" = "true" ]; then \
|
||||
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
|
||||
@@ -134,17 +138,17 @@ 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; \
|
||||
else \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir; \
|
||||
fi; \
|
||||
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/
|
||||
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" ] && [ "$USE_SLIM" != "true" ]; then \
|
||||
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 && \
|
||||
@@ -170,11 +174,13 @@ HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq
|
||||
# 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 set -eux; \
|
||||
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
|
||||
find /root -type d -exec chmod g+s {} + || true; \
|
||||
fi
|
||||
|
||||
USER $UID:$GID
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Open WebUI Multi-License Notice
|
||||
|
||||
This repository contains code governed by multiple licenses based on the date and origin of contribution:
|
||||
|
||||
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
|
||||
|
||||
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
|
||||
|
||||
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
|
||||
|
||||
For details on which commits are covered by which license, refer to LICENSE_HISTORY.
|
||||
@@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
|
||||
|
||||
## License 📜
|
||||
|
||||
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
|
||||
This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
|
||||
|
||||
## Support 💬
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+134
-11
@@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
|
||||
|
||||
|
||||
class AppConfig:
|
||||
_state: dict[str, PersistentConfig]
|
||||
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
|
||||
_redis_key_prefix: str
|
||||
|
||||
_state: dict[str, PersistentConfig]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: Optional[str] = None,
|
||||
@@ -233,9 +234,8 @@ class AppConfig:
|
||||
redis_cluster: Optional[bool] = False,
|
||||
redis_key_prefix: str = "open-webui",
|
||||
):
|
||||
super().__setattr__("_state", {})
|
||||
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||
if redis_url:
|
||||
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||
super().__setattr__(
|
||||
"_redis",
|
||||
get_redis_connection(
|
||||
@@ -246,6 +246,8 @@ class AppConfig:
|
||||
),
|
||||
)
|
||||
|
||||
super().__setattr__("_state", {})
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if isinstance(value, PersistentConfig):
|
||||
self._state[key] = value
|
||||
@@ -313,7 +315,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(
|
||||
@@ -513,6 +515,30 @@ OAUTH_GROUPS_CLAIM = PersistentConfig(
|
||||
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(
|
||||
"ENABLE_OAUTH_ROLE_MANAGEMENT",
|
||||
"oauth.enable_role_mapping",
|
||||
@@ -660,7 +686,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 +731,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 +765,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)
|
||||
@@ -1162,6 +1217,11 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get(
|
||||
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
|
||||
@@ -1299,6 +1359,7 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
|
||||
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
|
||||
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
|
||||
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
"chat": {
|
||||
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
@@ -1944,16 +2005,23 @@ if VECTOR_DB == "chroma":
|
||||
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
|
||||
|
||||
# Milvus
|
||||
|
||||
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
|
||||
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
|
||||
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
|
||||
|
||||
MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW")
|
||||
MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
|
||||
MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16"))
|
||||
MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100"))
|
||||
MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
|
||||
MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
|
||||
MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
|
||||
os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
|
||||
)
|
||||
ENABLE_MILVUS_MULTITENANCY_MODE = (
|
||||
os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "true").lower() == "true"
|
||||
)
|
||||
# Hyphens not allowed, need to use underscores in collection names
|
||||
MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
|
||||
|
||||
# Qdrant
|
||||
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
||||
@@ -1998,6 +2066,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:
|
||||
@@ -2113,10 +2184,20 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
|
||||
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
|
||||
)
|
||||
|
||||
ONEDRIVE_CLIENT_ID = PersistentConfig(
|
||||
"ONEDRIVE_CLIENT_ID",
|
||||
"onedrive.client_id",
|
||||
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
|
||||
|
||||
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 = os.environ.get("ONEDRIVE_CLIENT_ID", "")
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
|
||||
"ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
|
||||
)
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
|
||||
"ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
|
||||
)
|
||||
|
||||
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
|
||||
@@ -2229,6 +2310,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",
|
||||
@@ -2241,6 +2334,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",
|
||||
@@ -2669,6 +2780,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
|
||||
)
|
||||
|
||||
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.ollama_cloud_api_key",
|
||||
os.getenv("OLLAMA_CLOUD_API_KEY", ""),
|
||||
)
|
||||
|
||||
SEARXNG_QUERY_URL = PersistentConfig(
|
||||
"SEARXNG_QUERY_URL",
|
||||
"rag.web.search.searxng_query_url",
|
||||
@@ -3097,6 +3214,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",
|
||||
|
||||
@@ -465,6 +465,23 @@ 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_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# SCIM Configuration
|
||||
@@ -534,16 +551,16 @@ else:
|
||||
|
||||
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
|
||||
)
|
||||
|
||||
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||
else:
|
||||
try:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
|
||||
except Exception:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||
|
||||
|
||||
####################################
|
||||
|
||||
@@ -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,
|
||||
@@ -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,75 @@ 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 = []
|
||||
has_user_valves = False
|
||||
if hasattr(function_module, "UserValves"):
|
||||
has_user_valves = True
|
||||
|
||||
# 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,
|
||||
"has_user_valves": has_user_valves,
|
||||
}
|
||||
)
|
||||
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,
|
||||
"has_user_valves": has_user_valves,
|
||||
}
|
||||
)
|
||||
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 +242,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 = await 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,6 +263,7 @@ 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__"] = await get_tools(
|
||||
|
||||
+146
-52
@@ -50,6 +50,11 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from starsessions import (
|
||||
SessionMiddleware as StarSessionsMiddleware,
|
||||
SessionAutoloadMiddleware,
|
||||
)
|
||||
from starsessions.stores.redis import RedisStore
|
||||
|
||||
from open_webui.utils import logger
|
||||
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
|
||||
@@ -110,9 +115,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,
|
||||
@@ -157,6 +159,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,
|
||||
@@ -243,8 +246,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,
|
||||
@@ -266,6 +274,7 @@ from open_webui.config import (
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
WEB_SEARCH_TRUST_ENV,
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
JINA_API_KEY,
|
||||
SEARCHAPI_API_KEY,
|
||||
SEARCHAPI_ENGINE,
|
||||
@@ -297,14 +306,17 @@ from open_webui.config import (
|
||||
GOOGLE_PSE_ENGINE_ID,
|
||||
GOOGLE_DRIVE_CLIENT_ID,
|
||||
GOOGLE_DRIVE_API_KEY,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL,
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS,
|
||||
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,
|
||||
@@ -442,6 +454,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,
|
||||
@@ -460,7 +473,12 @@ from open_webui.utils.auth import (
|
||||
get_verified_user,
|
||||
)
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.oauth import OAuthManager
|
||||
from open_webui.utils.oauth import (
|
||||
OAuthManager,
|
||||
OAuthClientManager,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
@@ -590,7 +608,13 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# For Open WebUI OIDC/OAuth2
|
||||
oauth_manager = OAuthManager(app)
|
||||
app.state.oauth_manager = oauth_manager
|
||||
|
||||
# For Integrations
|
||||
oauth_client_manager = OAuthClientManager(app)
|
||||
app.state.oauth_client_manager = oauth_client_manager
|
||||
|
||||
app.state.instance_id = None
|
||||
app.state.config = AppConfig(
|
||||
@@ -810,8 +834,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
|
||||
@@ -870,6 +899,8 @@ app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
|
||||
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
|
||||
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
|
||||
|
||||
app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
|
||||
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
|
||||
app.state.config.YACY_USERNAME = YACY_USERNAME
|
||||
@@ -1019,6 +1050,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
|
||||
@@ -1278,33 +1310,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.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
|
||||
|
||||
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||
|
||||
models = []
|
||||
@@ -1340,12 +1345,7 @@ async def get_models(
|
||||
)
|
||||
)
|
||||
|
||||
# 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:
|
||||
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])}"
|
||||
@@ -1519,7 +1519,7 @@ async def chat_completion(
|
||||
try:
|
||||
event_emitter = get_event_emitter(metadata)
|
||||
await event_emitter(
|
||||
{"type": "task-cancelled"},
|
||||
{"type": "chat:tasks:cancel"},
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
@@ -1535,13 +1535,28 @@ async def chat_completion(
|
||||
"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
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
if mcp_clients := metadata.get("mcp_clients"):
|
||||
for client in mcp_clients.values():
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
log.debug(f"Error cleaning up: {e}")
|
||||
pass
|
||||
|
||||
if (
|
||||
metadata.get("session_id")
|
||||
@@ -1642,8 +1657,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:
|
||||
@@ -1700,6 +1725,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 {}
|
||||
@@ -1737,7 +1770,8 @@ async def get_app_config(request: Request):
|
||||
"api_key": GOOGLE_DRIVE_API_KEY.value,
|
||||
},
|
||||
"onedrive": {
|
||||
"client_id": ONEDRIVE_CLIENT_ID.value,
|
||||
"client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
|
||||
"client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
|
||||
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
|
||||
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
|
||||
},
|
||||
@@ -1857,17 +1891,76 @@ async def get_current_usage(user=Depends(get_verified_user)):
|
||||
# OAuth Login & Callback
|
||||
############################
|
||||
|
||||
# SessionMiddleware is used by authlib for oauth
|
||||
if len(OAUTH_PROVIDERS) > 0:
|
||||
|
||||
# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
|
||||
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
||||
for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if tool_server_connection.get("type", "openapi") == "mcp":
|
||||
server_id = tool_server_connection.get("info", {}).get("id")
|
||||
auth_type = tool_server_connection.get("auth_type", "none")
|
||||
if server_id and auth_type == "oauth_2.1":
|
||||
oauth_client_info = tool_server_connection.get("info", {}).get(
|
||||
"oauth_client_info", ""
|
||||
)
|
||||
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
app.state.oauth_client_manager.add_client(
|
||||
f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info)
|
||||
)
|
||||
|
||||
try:
|
||||
if REDIS_URL:
|
||||
redis_session_store = RedisStore(
|
||||
url=REDIS_URL,
|
||||
prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
|
||||
)
|
||||
|
||||
app.add_middleware(SessionAutoloadMiddleware)
|
||||
app.add_middleware(
|
||||
StarSessionsMiddleware,
|
||||
store=redis_session_store,
|
||||
cookie_name="owui-session",
|
||||
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
log.info("Using Redis for session")
|
||||
else:
|
||||
raise ValueError("No Redis URL provided")
|
||||
except Exception as e:
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=WEBUI_SECRET_KEY,
|
||||
session_cookie="oui-session",
|
||||
session_cookie="owui-session",
|
||||
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/oauth/clients/{client_id}/authorize")
|
||||
async def oauth_client_authorize(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_authorize(request, client_id=client_id)
|
||||
|
||||
|
||||
@app.get("/oauth/clients/{client_id}/callback")
|
||||
async def oauth_client_callback(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_callback(
|
||||
request,
|
||||
client_id=client_id,
|
||||
user_id=user.id if user else None,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/oauth/{provider}/login")
|
||||
async def oauth_login(provider: str, request: Request):
|
||||
return await oauth_manager.handle_login(request, provider)
|
||||
@@ -1879,8 +1972,9 @@ async def oauth_login(provider: str, request: Request):
|
||||
# - This is considered insecure in general, as OAuth providers do not always verify email addresses
|
||||
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
|
||||
# - Email addresses are considered unique, so we fail registration if the email address is already taken
|
||||
@app.get("/oauth/{provider}/callback")
|
||||
async def oauth_callback(provider: str, request: Request, response: Response):
|
||||
@app.get("/oauth/{provider}/login/callback")
|
||||
@app.get("/oauth/{provider}/callback") # Legacy endpoint
|
||||
async def oauth_login_callback(provider: str, request: Request, response: Response):
|
||||
return await oauth_manager.handle_callback(request, provider, response)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""Add reply_to_id column to message
|
||||
|
||||
Revision ID: a5c220713937
|
||||
Revises: 38d63c18f30f
|
||||
Create Date: 2025-09-27 02:24:18.058455
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a5c220713937"
|
||||
down_revision: Union[str, None] = "38d63c18f30f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add 'reply_to_id' column to the 'message' table for replying to messages
|
||||
op.add_column(
|
||||
"message",
|
||||
sa.Column("reply_to_id", sa.Text(), nullable=True),
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove 'reply_to_id' column from the 'message' table
|
||||
op.drop_column("message", "reply_to_id")
|
||||
|
||||
pass
|
||||
@@ -57,6 +57,10 @@ class ChannelModel(BaseModel):
|
||||
####################
|
||||
|
||||
|
||||
class ChannelResponse(ChannelModel):
|
||||
write_access: bool = False
|
||||
|
||||
|
||||
class ChannelForm(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
@@ -236,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
|
||||
@@ -366,6 +366,15 @@ class ChatTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
|
||||
try:
|
||||
with get_db() as db:
|
||||
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def update_chat_share_id_by_id(
|
||||
self, id: str, share_id: Optional[str]
|
||||
) -> Optional[ChatModel]:
|
||||
@@ -492,11 +501,16 @@ class ChatTable:
|
||||
self,
|
||||
user_id: str,
|
||||
include_archived: bool = False,
|
||||
include_folders: bool = False,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
|
||||
if not include_folders:
|
||||
query = query.filter_by(folder_id=None)
|
||||
|
||||
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
|
||||
if not include_archived:
|
||||
@@ -805,7 +819,7 @@ class ChatTable:
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_chats_by_folder_id_and_user_id(
|
||||
self, folder_id: str, user_id: str
|
||||
self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
|
||||
) -> list[ChatModel]:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
|
||||
@@ -814,6 +828,11 @@ class ChatTable:
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
@@ -943,6 +962,16 @@ class ChatTable:
|
||||
|
||||
return count
|
||||
|
||||
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
|
||||
query = query.filter_by(folder_id=folder_id)
|
||||
count = query.count()
|
||||
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
return count
|
||||
|
||||
def delete_tag_by_id_and_user_id_and_tag_name(
|
||||
self, id: str, user_id: str, tag_name: str
|
||||
) -> bool:
|
||||
|
||||
@@ -130,6 +130,17 @@ class FilesTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
file = db.query(File).filter_by(id=id, user_id=user_id).first()
|
||||
if file:
|
||||
return FileModel.model_validate(file)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
@@ -147,6 +158,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 [
|
||||
|
||||
@@ -50,6 +50,20 @@ class FolderModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FolderMetadataResponse(BaseModel):
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class FolderNameIdResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
meta: Optional[FolderMetadataResponse] = None
|
||||
parent_id: Optional[str] = None
|
||||
is_expanded: bool = False
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
@@ -58,6 +72,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 +213,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 +244,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)
|
||||
|
||||
@@ -37,6 +37,7 @@ class Function(Base):
|
||||
class FunctionMeta(BaseModel):
|
||||
description: Optional[str] = None
|
||||
manifest: Optional[dict] = {}
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class FunctionModel(BaseModel):
|
||||
@@ -54,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
|
||||
####################
|
||||
@@ -111,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:
|
||||
@@ -166,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(
|
||||
@@ -237,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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.tags import TagModel, Tag, Tags
|
||||
from open_webui.models.users import Users, UserNameResponse
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -43,6 +44,7 @@ class Message(Base):
|
||||
user_id = Column(Text)
|
||||
channel_id = Column(Text, nullable=True)
|
||||
|
||||
reply_to_id = Column(Text, nullable=True)
|
||||
parent_id = Column(Text, nullable=True)
|
||||
|
||||
content = Column(Text)
|
||||
@@ -60,6 +62,7 @@ class MessageModel(BaseModel):
|
||||
user_id: str
|
||||
channel_id: Optional[str] = None
|
||||
|
||||
reply_to_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
content: str
|
||||
@@ -77,6 +80,7 @@ class MessageModel(BaseModel):
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str
|
||||
reply_to_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
@@ -88,7 +92,15 @@ class Reactions(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class MessageResponse(MessageModel):
|
||||
class MessageUserResponse(MessageModel):
|
||||
user: Optional[UserNameResponse] = None
|
||||
|
||||
|
||||
class MessageReplyToResponse(MessageUserResponse):
|
||||
reply_to_message: Optional[MessageUserResponse] = None
|
||||
|
||||
|
||||
class MessageResponse(MessageReplyToResponse):
|
||||
latest_reply_at: Optional[int]
|
||||
reply_count: int
|
||||
reactions: list[Reactions]
|
||||
@@ -107,6 +119,7 @@ class MessageTable:
|
||||
"id": id,
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
"reply_to_id": form_data.reply_to_id,
|
||||
"parent_id": form_data.parent_id,
|
||||
"content": form_data.content,
|
||||
"data": form_data.data,
|
||||
@@ -128,19 +141,32 @@ class MessageTable:
|
||||
if not message:
|
||||
return None
|
||||
|
||||
reactions = self.get_reactions_by_message_id(id)
|
||||
replies = self.get_replies_by_message_id(id)
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
**{
|
||||
reactions = self.get_reactions_by_message_id(id)
|
||||
thread_replies = self.get_thread_replies_by_message_id(id)
|
||||
|
||||
user = Users.get_user_by_id(message.user_id)
|
||||
return MessageResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"latest_reply_at": replies[0].created_at if replies else None,
|
||||
"reply_count": len(replies),
|
||||
"user": user.model_dump() if user else None,
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump() if reply_to_message else None
|
||||
),
|
||||
"latest_reply_at": (
|
||||
thread_replies[0].created_at if thread_replies else None
|
||||
),
|
||||
"reply_count": len(thread_replies),
|
||||
"reactions": reactions,
|
||||
}
|
||||
)
|
||||
|
||||
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
|
||||
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
@@ -148,7 +174,27 @@ class MessageTable:
|
||||
.order_by(Message.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
messages = []
|
||||
for message in all_messages:
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
messages.append(
|
||||
MessageReplyToResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump()
|
||||
if reply_to_message
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
|
||||
with get_db() as db:
|
||||
@@ -159,7 +205,7 @@ class MessageTable:
|
||||
|
||||
def get_messages_by_channel_id(
|
||||
self, channel_id: str, skip: int = 0, limit: int = 50
|
||||
) -> list[MessageModel]:
|
||||
) -> list[MessageReplyToResponse]:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
@@ -169,11 +215,31 @@ class MessageTable:
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
messages = []
|
||||
for message in all_messages:
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
messages.append(
|
||||
MessageReplyToResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump()
|
||||
if reply_to_message
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
def get_messages_by_parent_id(
|
||||
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
|
||||
) -> list[MessageModel]:
|
||||
) -> list[MessageReplyToResponse]:
|
||||
with get_db() as db:
|
||||
message = db.get(Message, parent_id)
|
||||
|
||||
@@ -193,7 +259,26 @@ class MessageTable:
|
||||
if len(all_messages) < limit:
|
||||
all_messages.append(message)
|
||||
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
messages = []
|
||||
for message in all_messages:
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
messages.append(
|
||||
MessageReplyToResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump()
|
||||
if reply_to_message
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
def update_message_by_id(
|
||||
self, id: str, form_data: MessageForm
|
||||
@@ -201,8 +286,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]:
|
||||
|
||||
@@ -2,8 +2,10 @@ import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
@@ -96,21 +98,85 @@ 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,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
notes = self.get_notes()
|
||||
return [
|
||||
note
|
||||
for note in notes
|
||||
if note.user_id == user_id
|
||||
or has_access(user_id, permission, note.access_control)
|
||||
]
|
||||
with get_db() as db:
|
||||
query = db.query(Note).filter(Note.user_id == user_id)
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_permission(
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = "write",
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
# Order newest-first. We stream to keep memory usage low.
|
||||
query = (
|
||||
db.query(Note)
|
||||
.order_by(Note.updated_at.desc())
|
||||
.execution_options(stream_results=True)
|
||||
.yield_per(256)
|
||||
)
|
||||
|
||||
results: list[NoteModel] = []
|
||||
n_skipped = 0
|
||||
|
||||
for note in query:
|
||||
# Fast-pass #1: owner
|
||||
if note.user_id == user_id:
|
||||
permitted = True
|
||||
# Fast-pass #2: public/open
|
||||
elif note.access_control is None:
|
||||
# Technically this should mean public access for both read and write, but we'll only do read for now
|
||||
# We might want to change this behavior later
|
||||
permitted = permission == "read"
|
||||
else:
|
||||
permitted = has_access(
|
||||
user_id, permission, note.access_control, user_group_ids
|
||||
)
|
||||
|
||||
if not permitted:
|
||||
continue
|
||||
|
||||
# Apply skip AFTER permission filtering so it counts only accessible notes
|
||||
if skip and n_skipped < skip:
|
||||
n_skipped += 1
|
||||
continue
|
||||
|
||||
results.append(NoteModel.model_validate(note))
|
||||
if limit is not None and len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
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_session_by_provider_and_user_id(
|
||||
self, provider: str, user_id: str
|
||||
) -> Optional[OAuthSessionModel]:
|
||||
"""Get OAuth session by provider and user ID"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
session = (
|
||||
db.query(OAuthSession)
|
||||
.filter_by(provider=provider, user_id=user_id)
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth session by provider and user ID: {e}")
|
||||
return None
|
||||
|
||||
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
|
||||
"""Get all OAuth sessions for a user"""
|
||||
try:
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
@@ -93,6 +95,8 @@ class ToolResponse(BaseModel):
|
||||
class ToolUserResponse(ToolResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolForm(BaseModel):
|
||||
id: str
|
||||
@@ -144,9 +148,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 +172,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]:
|
||||
|
||||
@@ -107,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
|
||||
@@ -210,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -148,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"):
|
||||
@@ -174,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()
|
||||
@@ -182,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)
|
||||
|
||||
|
||||
@@ -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,18 +127,23 @@ def query_doc_with_hybrid_search(
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
try:
|
||||
if not collection_result.documents[0]:
|
||||
if (
|
||||
not collection_result
|
||||
or not hasattr(collection_result, "documents")
|
||||
or not collection_result.documents
|
||||
or len(collection_result.documents) == 0
|
||||
or not collection_result.documents[0]
|
||||
):
|
||||
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
|
||||
return {"documents": [], "metadatas": [], "distances": []}
|
||||
|
||||
# BM_25 required only if weight is greater than 0
|
||||
if hybrid_bm25_weight > 0:
|
||||
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],
|
||||
)
|
||||
bm25_retriever.k = k
|
||||
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],
|
||||
)
|
||||
bm25_retriever.k = k
|
||||
|
||||
vector_search_retriever = VectorSearchRetriever(
|
||||
collection_name=collection_name,
|
||||
@@ -343,22 +351,18 @@ def query_collection_with_hybrid_search(
|
||||
# Fetch collection data once per collection sequentially
|
||||
# Avoid fetching the same data multiple times later
|
||||
collection_results = {}
|
||||
# Only retrieve entire collection if bm_25 calculation is required
|
||||
if hybrid_bm25_weight > 0:
|
||||
for collection_name in collection_names:
|
||||
try:
|
||||
log.debug(
|
||||
f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}"
|
||||
)
|
||||
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
|
||||
collection_name=collection_name
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch collection {collection_name}: {e}")
|
||||
collection_results[collection_name] = None
|
||||
else:
|
||||
for collection_name in collection_names:
|
||||
collection_results[collection_name] = []
|
||||
for collection_name in collection_names:
|
||||
try:
|
||||
log.debug(
|
||||
f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}"
|
||||
)
|
||||
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
|
||||
collection_name=collection_name
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch collection {collection_name}: {e}")
|
||||
collection_results[collection_name] = None
|
||||
|
||||
log.info(
|
||||
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
|
||||
)
|
||||
@@ -437,13 +441,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)
|
||||
@@ -493,26 +498,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
|
||||
@@ -529,6 +547,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"
|
||||
@@ -585,6 +627,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)
|
||||
):
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from open_webui.retrieval.vector.main import (
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
|
||||
from open_webui.config import (
|
||||
CHROMA_DATA_PATH,
|
||||
@@ -146,7 +146,7 @@ class ChromaClient(VectorDBBase):
|
||||
ids = [item["id"] for item in items]
|
||||
documents = [item["text"] for item in items]
|
||||
embeddings = [item["vector"] for item in items]
|
||||
metadatas = [stringify_metadata(item["metadata"]) for item in items]
|
||||
metadatas = [process_metadata(item["metadata"]) for item in items]
|
||||
|
||||
for batch in create_batches(
|
||||
api=self.client,
|
||||
@@ -166,7 +166,7 @@ class ChromaClient(VectorDBBase):
|
||||
ids = [item["id"] for item in items]
|
||||
documents = [item["text"] for item in items]
|
||||
embeddings = [item["vector"] for item in items]
|
||||
metadatas = [stringify_metadata(item["metadata"]) for item in items]
|
||||
metadatas = [process_metadata(item["metadata"]) for item in items]
|
||||
|
||||
collection.upsert(
|
||||
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
import ssl
|
||||
from elasticsearch.helpers import bulk, scan
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -245,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
@@ -266,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -22,6 +22,8 @@ from open_webui.config import (
|
||||
MILVUS_HNSW_M,
|
||||
MILVUS_HNSW_EFCONSTRUCTION,
|
||||
MILVUS_IVF_FLAT_NLIST,
|
||||
MILVUS_DISKANN_MAX_DEGREE,
|
||||
MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
@@ -131,12 +133,18 @@ class MilvusClient(VectorDBBase):
|
||||
elif index_type == "IVF_FLAT":
|
||||
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
||||
log.info(f"IVF_FLAT params: {index_creation_params}")
|
||||
elif index_type == "DISKANN":
|
||||
index_creation_params = {
|
||||
"max_degree": MILVUS_DISKANN_MAX_DEGREE,
|
||||
"search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||
}
|
||||
log.info(f"DISKANN params: {index_creation_params}")
|
||||
elif index_type in ["FLAT", "AUTOINDEX"]:
|
||||
log.info(f"Using {index_type} index with no specific build-time params.")
|
||||
else:
|
||||
log.warning(
|
||||
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
|
||||
f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. "
|
||||
f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
|
||||
f"Milvus will use its default for the collection if this type is not directly supported for index creation."
|
||||
)
|
||||
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
|
||||
@@ -189,7 +197,7 @@ class MilvusClient(VectorDBBase):
|
||||
)
|
||||
return self._result_to_search_result(result)
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
||||
def query(self, collection_name: str, filter: dict, limit: int = -1):
|
||||
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
# Construct the filter string for querying
|
||||
@@ -222,7 +230,7 @@ class MilvusClient(VectorDBBase):
|
||||
"data",
|
||||
"metadata",
|
||||
],
|
||||
limit=limit, # Pass the limit directly; None means no limit.
|
||||
limit=limit, # Pass the limit directly; -1 means no limit.
|
||||
)
|
||||
|
||||
while True:
|
||||
@@ -249,7 +257,7 @@ class MilvusClient(VectorDBBase):
|
||||
)
|
||||
# Using query with a trivial filter to get all items.
|
||||
# This will use the paginated query logic.
|
||||
return self.query(collection_name=collection_name, filter={}, limit=None)
|
||||
return self.query(collection_name=collection_name, filter={}, limit=-1)
|
||||
|
||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
||||
# Insert the items into the collection, if the collection does not exist, it will be created.
|
||||
@@ -281,7 +289,7 @@ class MilvusClient(VectorDBBase):
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"data": {"text": item["text"]},
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
@@ -317,7 +325,7 @@ class MilvusClient(VectorDBBase):
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"data": {"text": item["text"]},
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
|
||||
from open_webui.config import (
|
||||
MILVUS_URI,
|
||||
MILVUS_TOKEN,
|
||||
MILVUS_DB,
|
||||
MILVUS_COLLECTION_PREFIX,
|
||||
MILVUS_INDEX_TYPE,
|
||||
MILVUS_METRIC_TYPE,
|
||||
MILVUS_HNSW_M,
|
||||
MILVUS_HNSW_EFCONSTRUCTION,
|
||||
MILVUS_IVF_FLAT_NLIST,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from pymilvus import (
|
||||
connections,
|
||||
utility,
|
||||
Collection,
|
||||
CollectionSchema,
|
||||
FieldSchema,
|
||||
DataType,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
RESOURCE_ID_FIELD = "resource_id"
|
||||
|
||||
|
||||
class MilvusClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
# Milvus collection names can only contain numbers, letters, and underscores.
|
||||
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
|
||||
connections.connect(
|
||||
alias="default",
|
||||
uri=MILVUS_URI,
|
||||
token=MILVUS_TOKEN,
|
||||
db_name=MILVUS_DB,
|
||||
)
|
||||
|
||||
# Main collection types for multi-tenancy
|
||||
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
||||
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
|
||||
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
|
||||
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
|
||||
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
|
||||
self.shared_collections = [
|
||||
self.MEMORY_COLLECTION,
|
||||
self.KNOWLEDGE_COLLECTION,
|
||||
self.FILE_COLLECTION,
|
||||
self.WEB_SEARCH_COLLECTION,
|
||||
self.HASH_BASED_COLLECTION,
|
||||
]
|
||||
|
||||
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Maps the traditional collection name to multi-tenant collection and resource ID.
|
||||
|
||||
WARNING: This mapping relies on current Open WebUI naming conventions for
|
||||
collection names. If Open WebUI changes how it generates collection names
|
||||
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
|
||||
formats), this mapping will break and route data to incorrect collections.
|
||||
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
|
||||
DATA MAPPING INSIDE THE DATABASE.
|
||||
"""
|
||||
resource_id = collection_name
|
||||
|
||||
if collection_name.startswith("user-memory-"):
|
||||
return self.MEMORY_COLLECTION, resource_id
|
||||
elif collection_name.startswith("file-"):
|
||||
return self.FILE_COLLECTION, resource_id
|
||||
elif collection_name.startswith("web-search-"):
|
||||
return self.WEB_SEARCH_COLLECTION, resource_id
|
||||
elif len(collection_name) == 63 and all(
|
||||
c in "0123456789abcdef" for c in collection_name
|
||||
):
|
||||
return self.HASH_BASED_COLLECTION, resource_id
|
||||
else:
|
||||
return self.KNOWLEDGE_COLLECTION, resource_id
|
||||
|
||||
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
|
||||
fields = [
|
||||
FieldSchema(
|
||||
name="id",
|
||||
dtype=DataType.VARCHAR,
|
||||
is_primary=True,
|
||||
auto_id=False,
|
||||
max_length=36,
|
||||
),
|
||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="metadata", dtype=DataType.JSON),
|
||||
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
|
||||
]
|
||||
schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
|
||||
collection = Collection(mt_collection_name, schema)
|
||||
|
||||
index_params = {
|
||||
"metric_type": MILVUS_METRIC_TYPE,
|
||||
"index_type": MILVUS_INDEX_TYPE,
|
||||
"params": {},
|
||||
}
|
||||
if MILVUS_INDEX_TYPE == "HNSW":
|
||||
index_params["params"] = {
|
||||
"M": MILVUS_HNSW_M,
|
||||
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
|
||||
}
|
||||
elif MILVUS_INDEX_TYPE == "IVF_FLAT":
|
||||
index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
||||
|
||||
collection.create_index("vector", index_params)
|
||||
collection.create_index(RESOURCE_ID_FIELD)
|
||||
log.info(f"Created shared collection: {mt_collection_name}")
|
||||
return collection
|
||||
|
||||
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||
if not utility.has_collection(mt_collection_name):
|
||||
self._create_shared_collection(mt_collection_name, dimension)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return False
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
|
||||
return len(res) > 0
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||
if not items:
|
||||
return
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
dimension = len(items[0]["vector"])
|
||||
self._ensure_collection(mt_collection, dimension)
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
entities = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
RESOURCE_ID_FIELD: resource_id,
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
collection.insert(entities)
|
||||
collection.flush()
|
||||
|
||||
def search(
|
||||
self, collection_name: str, vectors: List[List[float]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
if not vectors:
|
||||
return None
|
||||
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
|
||||
search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
|
||||
results = collection.search(
|
||||
data=vectors,
|
||||
anns_field="vector",
|
||||
param=search_params,
|
||||
limit=limit,
|
||||
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
output_fields=["id", "text", "metadata"],
|
||||
)
|
||||
|
||||
ids, documents, metadatas, distances = [], [], [], []
|
||||
for hits in results:
|
||||
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
|
||||
for hit in hits:
|
||||
batch_ids.append(hit.entity.get("id"))
|
||||
batch_docs.append(hit.entity.get("text"))
|
||||
batch_metadatas.append(hit.entity.get("metadata"))
|
||||
batch_dists.append(hit.distance)
|
||||
ids.append(batch_ids)
|
||||
documents.append(batch_docs)
|
||||
metadatas.append(batch_metadatas)
|
||||
distances.append(batch_dists)
|
||||
|
||||
return SearchResult(
|
||||
ids=ids, documents=documents, metadatas=metadatas, distances=distances
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
# Build expression
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if ids:
|
||||
# Milvus expects a string list for 'in' operator
|
||||
id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
|
||||
expr.append(f"id in [{id_list_str}]")
|
||||
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
expr.append(f"metadata['{key}'] == '{value}'")
|
||||
|
||||
collection.delete(" and ".join(expr))
|
||||
|
||||
def reset(self):
|
||||
for collection_name in self.shared_collections:
|
||||
if utility.has_collection(collection_name):
|
||||
utility.drop_collection(collection_name)
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
|
||||
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
if isinstance(value, str):
|
||||
expr.append(f"metadata['{key}'] == '{value}'")
|
||||
else:
|
||||
expr.append(f"metadata['{key}'] == {value}")
|
||||
|
||||
results = collection.query(
|
||||
expr=" and ".join(expr),
|
||||
output_fields=["id", "text", "metadata"],
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
ids = [res["id"] for res in results]
|
||||
documents = [res["text"] for res in results]
|
||||
metadatas = [res["metadata"] for res in results]
|
||||
|
||||
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
return self.query(collection_name, filter={}, limit=None)
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]):
|
||||
return self.upsert(collection_name, items)
|
||||
@@ -2,7 +2,7 @@ from opensearchpy import OpenSearch
|
||||
from opensearchpy.helpers import bulk
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -201,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
"_source": {
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
@@ -223,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
"doc": {
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ from sqlalchemy.ext.mutable import MutableDict
|
||||
from sqlalchemy.exc import NoSuchTableError
|
||||
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -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,
|
||||
@@ -112,18 +113,19 @@ class PgvectorClient(VectorDBBase):
|
||||
try:
|
||||
# Ensure the pgvector extension is available
|
||||
# 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 = 'vector') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
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
|
||||
@@ -263,7 +265,7 @@ class PgvectorClient(VectorDBBase):
|
||||
vector=vector,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=stringify_metadata(item["metadata"]),
|
||||
vmetadata=process_metadata(item["metadata"]),
|
||||
)
|
||||
new_items.append(new_chunk)
|
||||
self.session.bulk_save_objects(new_items)
|
||||
@@ -321,7 +323,7 @@ class PgvectorClient(VectorDBBase):
|
||||
if existing:
|
||||
existing.vector = vector
|
||||
existing.text = item["text"]
|
||||
existing.vmetadata = stringify_metadata(item["metadata"])
|
||||
existing.vmetadata = process_metadata(item["metadata"])
|
||||
existing.collection_name = (
|
||||
collection_name # Update collection_name if necessary
|
||||
)
|
||||
@@ -331,7 +333,7 @@ class PgvectorClient(VectorDBBase):
|
||||
vector=vector,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=stringify_metadata(item["metadata"]),
|
||||
vmetadata=process_metadata(item["metadata"]),
|
||||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
|
||||
@@ -32,7 +32,7 @@ from open_webui.config import (
|
||||
PINECONE_CLOUD,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
|
||||
|
||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||
@@ -185,7 +185,7 @@ class PineconeClient(VectorDBBase):
|
||||
point = {
|
||||
"id": item["id"],
|
||||
"values": item["vector"],
|
||||
"metadata": stringify_metadata(metadata),
|
||||
"metadata": process_metadata(metadata),
|
||||
}
|
||||
points.append(point)
|
||||
return points
|
||||
|
||||
@@ -105,6 +105,13 @@ class QdrantClient(VectorDBBase):
|
||||
|
||||
Returns:
|
||||
tuple: (collection_name, tenant_id)
|
||||
|
||||
WARNING: This mapping relies on current Open WebUI naming conventions for
|
||||
collection names. If Open WebUI changes how it generates collection names
|
||||
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
|
||||
formats), this mapping will break and route data to incorrect collections.
|
||||
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
|
||||
DATA MAPPING INSIDE THE DATABASE.
|
||||
"""
|
||||
# Check for user memory collections
|
||||
tenant_id = collection_name
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -185,7 +185,7 @@ class S3VectorClient(VectorDBBase):
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Convert metadata to string format for consistency
|
||||
metadata = stringify_metadata(metadata)
|
||||
metadata = process_metadata(metadata)
|
||||
|
||||
# Filter metadata to comply with S3 Vector API limit of 10 keys
|
||||
metadata = self._filter_metadata(metadata, item["id"])
|
||||
@@ -256,7 +256,7 @@ class S3VectorClient(VectorDBBase):
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Convert metadata to string format for consistency
|
||||
metadata = stringify_metadata(metadata)
|
||||
metadata = process_metadata(metadata)
|
||||
|
||||
# Filter metadata to comply with S3 Vector API limit of 10 keys
|
||||
metadata = self._filter_metadata(metadata, item["id"])
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from open_webui.retrieval.vector.main import VectorDBBase
|
||||
from open_webui.retrieval.vector.type import VectorType
|
||||
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
|
||||
from open_webui.config import (
|
||||
VECTOR_DB,
|
||||
ENABLE_QDRANT_MULTITENANCY_MODE,
|
||||
ENABLE_MILVUS_MULTITENANCY_MODE,
|
||||
)
|
||||
|
||||
|
||||
class Vector:
|
||||
@@ -12,9 +16,16 @@ class Vector:
|
||||
"""
|
||||
match vector_type:
|
||||
case VectorType.MILVUS:
|
||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||
if ENABLE_MILVUS_MULTITENANCY_MODE:
|
||||
from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
|
||||
MilvusClient,
|
||||
)
|
||||
|
||||
return MilvusClient()
|
||||
return MilvusClient()
|
||||
else:
|
||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||
|
||||
return MilvusClient()
|
||||
case VectorType.QDRANT:
|
||||
if ENABLE_QDRANT_MULTITENANCY_MODE:
|
||||
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
|
||||
|
||||
def stringify_metadata(
|
||||
|
||||
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def process_metadata(
|
||||
metadata: dict[str, any],
|
||||
) -> dict[str, any]:
|
||||
for key, value in metadata.items():
|
||||
# Remove large fields
|
||||
if key in KEYS_TO_EXCLUDE:
|
||||
del metadata[key]
|
||||
|
||||
# Convert non-serializable fields to strings
|
||||
if (
|
||||
isinstance(value, datetime)
|
||||
or isinstance(value, list)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_ollama_cloud(
|
||||
url: str,
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Ollama Search API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Ollama Search API key
|
||||
query (str): The query to search for
|
||||
count (int): Number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results by
|
||||
"""
|
||||
log.info(f"Searching with Ollama for query: {query}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"query": query, "max_results": count}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
log.info(f"Found {len(results)} results")
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result.get("url", ""),
|
||||
title=result.get("title", ""),
|
||||
snippet=result.get("content", ""),
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
except Exception as e:
|
||||
log.error(f"Error searching Ollama: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
from typing import Optional, Literal
|
||||
import requests
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_perplexity_search(
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Perplexity API key
|
||||
query (str): The query to search for
|
||||
count (int): Maximum number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results
|
||||
|
||||
"""
|
||||
|
||||
# Handle PersistentConfig object
|
||||
if hasattr(api_key, "__str__"):
|
||||
api_key = str(api_key)
|
||||
|
||||
try:
|
||||
url = "https://api.perplexity.ai/search"
|
||||
|
||||
# Create payload for the API call
|
||||
payload = {
|
||||
"query": query,
|
||||
"max_results": count,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Make the API request
|
||||
response = requests.request("POST", url, json=payload, headers=headers)
|
||||
# Parse the JSON response
|
||||
json_response = response.json()
|
||||
|
||||
# Extract citations from the response
|
||||
results = json_response.get("results", [])
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["url"], title=result["title"], snippet=result["snippet"]
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error searching with Perplexity Search API: {e}")
|
||||
return []
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
@@ -551,7 +550,7 @@ def transcription_handler(request, file_path, metadata):
|
||||
metadata = metadata or {}
|
||||
|
||||
languages = [
|
||||
metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE,
|
||||
metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
|
||||
None, # Always fallback to None in case transcription fails
|
||||
]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from open_webui.models.auths import (
|
||||
)
|
||||
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 (
|
||||
@@ -676,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={
|
||||
@@ -703,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:
|
||||
|
||||
@@ -10,7 +10,13 @@ from pydantic import BaseModel
|
||||
from open_webui.socket.main import sio, get_user_ids_from_room
|
||||
from open_webui.models.users import Users, UserNameResponse
|
||||
|
||||
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.channels import (
|
||||
Channels,
|
||||
ChannelModel,
|
||||
ChannelForm,
|
||||
ChannelResponse,
|
||||
)
|
||||
from open_webui.models.messages import (
|
||||
Messages,
|
||||
MessageModel,
|
||||
@@ -24,9 +30,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"])
|
||||
@@ -72,7 +86,7 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[ChannelModel])
|
||||
@router.get("/{id}", response_model=Optional[ChannelResponse])
|
||||
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
|
||||
channel = Channels.get_channel_by_id(id)
|
||||
if not channel:
|
||||
@@ -87,7 +101,16 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
return ChannelModel(**channel.model_dump())
|
||||
write_access = has_access(
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
)
|
||||
|
||||
return ChannelResponse(
|
||||
**{
|
||||
**channel.model_dump(),
|
||||
"write_access": write_access or user.role == "admin",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -144,7 +167,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
|
||||
|
||||
|
||||
class MessageUserResponse(MessageResponse):
|
||||
user: UserNameResponse
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
|
||||
@@ -173,15 +196,17 @@ async def get_channel_messages(
|
||||
user = Users.get_user_by_id(message.user_id)
|
||||
users[message.user_id] = user
|
||||
|
||||
replies = Messages.get_replies_by_message_id(message.id)
|
||||
latest_reply_at = replies[0].created_at if replies else None
|
||||
thread_replies = Messages.get_thread_replies_by_message_id(message.id)
|
||||
latest_thread_reply_at = (
|
||||
thread_replies[0].created_at if thread_replies else None
|
||||
)
|
||||
|
||||
messages.append(
|
||||
MessageUserResponse(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"reply_count": len(replies),
|
||||
"latest_reply_at": latest_reply_at,
|
||||
"reply_count": len(thread_replies),
|
||||
"latest_reply_at": latest_thread_reply_at,
|
||||
"reactions": Messages.get_reactions_by_message_id(message.id),
|
||||
"user": UserNameResponse(**users[message.user_id].model_dump()),
|
||||
}
|
||||
@@ -200,14 +225,11 @@ 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:
|
||||
await post_webhook(
|
||||
name,
|
||||
@@ -221,14 +243,169 @@ 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)
|
||||
|
||||
model_mentions = {}
|
||||
|
||||
# check if the message is a reply to a message sent by a model
|
||||
if (
|
||||
message.reply_to_message
|
||||
and message.reply_to_message.meta
|
||||
and message.reply_to_message.meta.get("model_id", None)
|
||||
):
|
||||
model_id = message.reply_to_message.meta.get("model_id", None)
|
||||
model_mentions[model_id] = {"id": model_id, "id_type": "M"}
|
||||
|
||||
# check if any of the mentions are models
|
||||
for mention in mentions:
|
||||
if mention["id_type"] == "M" and mention["id"] not in model_mentions:
|
||||
model_mentions[mention["id"]] = mention
|
||||
|
||||
if not model_mentions:
|
||||
return False
|
||||
|
||||
for mention in model_mentions.values():
|
||||
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 = []
|
||||
images = []
|
||||
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)}"
|
||||
)
|
||||
|
||||
thread_message_files = thread_message.data.get("files", [])
|
||||
for file in thread_message_files:
|
||||
if file.get("type", "") == "image":
|
||||
images.append(file.get("url", ""))
|
||||
|
||||
system_message = {
|
||||
"role": "system",
|
||||
"content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational."
|
||||
+ (
|
||||
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context."
|
||||
if thread_history
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
content = f"{user.name if user else 'User'}: {message_content}"
|
||||
if images:
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
},
|
||||
*[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image,
|
||||
},
|
||||
}
|
||||
for image in images
|
||||
],
|
||||
]
|
||||
|
||||
form_data = {
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
system_message,
|
||||
{"role": "user", "content": 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:
|
||||
@@ -237,7 +414,7 @@ async def post_new_message(
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -245,24 +422,14 @@ async def post_new_message(
|
||||
|
||||
try:
|
||||
message = Messages.insert_new_message(form_data, channel.id, user.id)
|
||||
|
||||
if message:
|
||||
message = Messages.get_message_by_id(message.id)
|
||||
event_data = {
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"reply_count": 0,
|
||||
"latest_reply_at": None,
|
||||
"reactions": Messages.get_reactions_by_message_id(
|
||||
message.id
|
||||
),
|
||||
"user": UserNameResponse(**user.model_dump()),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
@@ -286,27 +453,39 @@ async def post_new_message(
|
||||
"message_id": parent_message.id,
|
||||
"data": {
|
||||
"type": "message:reply",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**parent_message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(
|
||||
parent_message.user_id
|
||||
).model_dump()
|
||||
),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": parent_message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
return message, channel
|
||||
else:
|
||||
raise Exception("Error creating message")
|
||||
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 +493,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(
|
||||
@@ -466,14 +650,7 @@ async def update_message_by_id(
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message:update",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**user.model_dump()
|
||||
).model_dump(),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
@@ -509,7 +686,7 @@ async def add_reaction_to_message(
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -539,9 +716,6 @@ async def add_reaction_to_message(
|
||||
"type": "message:reaction:add",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(message.user_id).model_dump()
|
||||
).model_dump(),
|
||||
"name": form_data.name,
|
||||
},
|
||||
},
|
||||
@@ -575,7 +749,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -608,9 +782,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||
"type": "message:reaction:remove",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(message.user_id).model_dump()
|
||||
).model_dump(),
|
||||
"name": form_data.name,
|
||||
},
|
||||
},
|
||||
@@ -657,7 +828,9 @@ async def delete_message_by_id(
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(user.id, type="read", access_control=channel.access_control)
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -695,16 +868,7 @@ async def delete_message_by_id(
|
||||
"message_id": parent_message.id,
|
||||
"data": {
|
||||
"type": "message:reply",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**parent_message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(
|
||||
parent_message.user_id
|
||||
).model_dump()
|
||||
),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": parent_message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
|
||||
@@ -37,7 +37,9 @@ router = APIRouter()
|
||||
@router.get("/", response_model=list[ChatTitleIdResponse])
|
||||
@router.get("/list", response_model=list[ChatTitleIdResponse])
|
||||
def get_session_user_chat_list(
|
||||
user=Depends(get_verified_user), page: Optional[int] = None
|
||||
user=Depends(get_verified_user),
|
||||
page: Optional[int] = None,
|
||||
include_folders: Optional[bool] = False,
|
||||
):
|
||||
try:
|
||||
if page is not None:
|
||||
@@ -45,10 +47,12 @@ def get_session_user_chat_list(
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, skip=skip, limit=limit
|
||||
user.id, include_folders=include_folders, skip=skip, limit=limit
|
||||
)
|
||||
else:
|
||||
return Chats.get_chat_title_id_list_by_user_id(user.id)
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, include_folders=include_folders
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
@@ -166,7 +170,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:
|
||||
@@ -214,6 +218,28 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/folder/{folder_id}/list")
|
||||
async def get_chat_list_by_folder_id(
|
||||
folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user)
|
||||
):
|
||||
try:
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return [
|
||||
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
|
||||
for chat in Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder_id, user.id, skip=skip, limit=limit
|
||||
)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetPinnedChats
|
||||
############################
|
||||
@@ -335,6 +361,16 @@ async def archive_all_chats(user=Depends(get_verified_user)):
|
||||
return Chats.archive_all_chats_by_user_id(user.id)
|
||||
|
||||
|
||||
############################
|
||||
# UnarchiveAllChats
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/unarchive/all", response_model=bool)
|
||||
async def unarchive_all_chats(user=Depends(get_verified_user)):
|
||||
return Chats.unarchive_all_chats_by_user_id(user.id)
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChatById
|
||||
############################
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
import aiohttp
|
||||
|
||||
from typing import Optional
|
||||
|
||||
@@ -12,10 +14,24 @@ from open_webui.utils.tools import (
|
||||
get_tool_server_url,
|
||||
set_tool_servers,
|
||||
)
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.utils.oauth import (
|
||||
get_discovery_urls,
|
||||
get_oauth_client_info_with_dynamic_client_registration,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
############################
|
||||
# ImportConfig
|
||||
@@ -79,6 +95,43 @@ async def set_connections_config(
|
||||
}
|
||||
|
||||
|
||||
class OAuthClientRegistrationForm(BaseModel):
|
||||
url: str
|
||||
client_id: str
|
||||
client_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/oauth/clients/register")
|
||||
async def register_oauth_client(
|
||||
request: Request,
|
||||
form_data: OAuthClientRegistrationForm,
|
||||
type: Optional[str] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
try:
|
||||
oauth_client_id = form_data.client_id
|
||||
if type:
|
||||
oauth_client_id = f"{type}:{form_data.client_id}"
|
||||
|
||||
oauth_client_info = (
|
||||
await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request, oauth_client_id, form_data.url
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": True,
|
||||
"oauth_client_info": encrypt_data(
|
||||
oauth_client_info.model_dump(mode="json")
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to register OAuth client: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to register OAuth client",
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ToolServers Config
|
||||
############################
|
||||
@@ -87,6 +140,7 @@ async def set_connections_config(
|
||||
class ToolServerConnection(BaseModel):
|
||||
url: str
|
||||
path: str
|
||||
type: Optional[str] = "openapi" # openapi, mcp
|
||||
auth_type: Optional[str]
|
||||
key: Optional[str]
|
||||
config: Optional[dict]
|
||||
@@ -114,8 +168,29 @@ async def set_tool_servers_config(
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
|
||||
]
|
||||
|
||||
await set_tool_servers(request)
|
||||
|
||||
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
server_type = connection.get("type", "openapi")
|
||||
if server_type == "mcp":
|
||||
server_id = connection.get("info", {}).get("id")
|
||||
auth_type = connection.get("auth_type", "none")
|
||||
if auth_type == "oauth_2.1" and server_id:
|
||||
try:
|
||||
oauth_client_info = connection.get("info", {}).get(
|
||||
"oauth_client_info", ""
|
||||
)
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
|
||||
await request.app.state.oauth_client_manager.add_client(
|
||||
f"{server_type}:{server_id}",
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to add OAuth client for MCP tool server: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
@@ -129,19 +204,106 @@ async def verify_tool_servers_config(
|
||||
Verify the connection to the tool server.
|
||||
"""
|
||||
try:
|
||||
if form_data.type == "mcp":
|
||||
if form_data.auth_type == "oauth_2.1":
|
||||
discovery_urls = get_discovery_urls(form_data.url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(
|
||||
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
|
||||
)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
discovery_urls[0]
|
||||
) as oauth_server_metadata_response:
|
||||
if oauth_server_metadata_response.status == 200:
|
||||
try:
|
||||
oauth_server_metadata = (
|
||||
OAuthMetadata.model_validate(
|
||||
await oauth_server_metadata_response.json()
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": True,
|
||||
"oauth_server_metadata": oauth_server_metadata.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
log.info(
|
||||
f"Failed to parse OAuth 2.1 discovery document: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_urls[0]}",
|
||||
)
|
||||
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
client = MCPClient()
|
||||
headers = None
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
elif form_data.auth_type == "system_oauth":
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if token:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
await client.connect(form_data.url, headers=headers)
|
||||
specs = await client.list_tool_specs()
|
||||
return {
|
||||
"status": True,
|
||||
"specs": specs,
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to create MCP client: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to create MCP client",
|
||||
)
|
||||
finally:
|
||||
if client:
|
||||
await client.disconnect()
|
||||
else: # openapi
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
elif form_data.auth_type == "system_oauth":
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to connect to the tool server: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to connect to the tool server: {str(e)}",
|
||||
detail=f"Failed to connect to the tool server",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -120,11 +120,6 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
|
||||
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)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file_item.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error processing file: {file_item.id}")
|
||||
Files.update_file_data_by_id(
|
||||
@@ -411,25 +406,28 @@ async def get_file_process_status(
|
||||
MAX_FILE_PROCESSING_DURATION = 3600 * 2
|
||||
|
||||
async def event_stream(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 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")
|
||||
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"):
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if status in ("completed", "failed"):
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(file),
|
||||
|
||||
@@ -10,10 +10,15 @@ import mimetypes
|
||||
|
||||
from open_webui.models.folders import (
|
||||
FolderForm,
|
||||
FolderUpdateForm,
|
||||
FolderModel,
|
||||
FolderNameIdResponse,
|
||||
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
|
||||
@@ -40,21 +45,38 @@ router = APIRouter()
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FolderModel])
|
||||
@router.get("/", response_model=list[FolderNameIdResponse])
|
||||
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(),
|
||||
"items": {
|
||||
"chats": [
|
||||
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
|
||||
for chat in Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder.id, user.id
|
||||
)
|
||||
]
|
||||
},
|
||||
}
|
||||
for folder in folders
|
||||
]
|
||||
@@ -113,22 +135,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)
|
||||
@@ -231,15 +255,15 @@ async def update_folder_is_expanded_by_id(
|
||||
async def delete_folder_by_id(
|
||||
request: Request, id: str, user=Depends(get_verified_user)
|
||||
):
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
|
||||
if folder:
|
||||
|
||||
@@ -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:
|
||||
@@ -412,8 +431,10 @@ async def update_function_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
valves = Valves(**form_data)
|
||||
Functions.update_function_valves_by_id(id, valves.model_dump())
|
||||
return valves.model_dump()
|
||||
|
||||
valves_dict = valves.model_dump(exclude_unset=True)
|
||||
Functions.update_function_valves_by_id(id, valves_dict)
|
||||
return valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function values by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
@@ -495,10 +516,11 @@ async def update_function_user_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Functions.update_user_valves_by_id_and_user_id(
|
||||
id, user.id, user_valves.model_dump()
|
||||
id, user.id, user_valves_dict
|
||||
)
|
||||
return user_valves.model_dump()
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function user valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -48,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": {
|
||||
@@ -72,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
|
||||
|
||||
|
||||
@@ -119,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 = (
|
||||
@@ -165,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": {
|
||||
@@ -508,6 +514,7 @@ async def image_generations(
|
||||
size = form_data.size
|
||||
|
||||
width, height = tuple(map(int, size.split("x")))
|
||||
model = get_image_model(request)
|
||||
|
||||
r = None
|
||||
try:
|
||||
@@ -525,11 +532,7 @@ async def image_generations(
|
||||
headers["X-OpenWebUI-User-Role"] = user.role
|
||||
|
||||
data = {
|
||||
"model": (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
if request.app.state.config.IMAGE_GENERATION_MODEL != ""
|
||||
else "dall-e-2"
|
||||
),
|
||||
"model": model,
|
||||
"prompt": form_data.prompt,
|
||||
"n": form_data.n,
|
||||
"size": (
|
||||
@@ -544,10 +547,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,
|
||||
)
|
||||
@@ -572,7 +581,6 @@ async def image_generations(
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
|
||||
|
||||
model = get_image_model(request)
|
||||
data = {
|
||||
"instances": {"prompt": form_data.prompt},
|
||||
"parameters": {
|
||||
@@ -628,7 +636,7 @@ async def image_generations(
|
||||
}
|
||||
)
|
||||
res = await comfyui_generate_image(
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
model,
|
||||
form_data,
|
||||
user.id,
|
||||
request.app.state.config.COMFYUI_BASE_URL,
|
||||
|
||||
@@ -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 (
|
||||
@@ -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,9 @@
|
||||
from typing import Optional
|
||||
import io
|
||||
import base64
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from open_webui.models.models import (
|
||||
ModelForm,
|
||||
@@ -10,12 +15,22 @@ 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 BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -90,6 +105,50 @@ async def export_models(user=Depends(get_admin_user)):
|
||||
return Models.get_models()
|
||||
|
||||
|
||||
############################
|
||||
# ImportModels
|
||||
############################
|
||||
|
||||
|
||||
class ModelsImportForm(BaseModel):
|
||||
models: list[dict]
|
||||
|
||||
|
||||
@router.post("/import", response_model=bool)
|
||||
async def import_models(
|
||||
user: str = Depends(get_admin_user), form_data: ModelsImportForm = (...)
|
||||
):
|
||||
try:
|
||||
data = form_data.models
|
||||
if isinstance(data, list):
|
||||
for model_data in data:
|
||||
# Here, you can add logic to validate model_data if needed
|
||||
model_id = model_data.get("id")
|
||||
if model_id:
|
||||
existing_model = Models.get_model_by_id(model_id)
|
||||
if existing_model:
|
||||
# Update existing model
|
||||
model_data["meta"] = model_data.get("meta", {})
|
||||
model_data["params"] = model_data.get("params", {})
|
||||
|
||||
updated_model = ModelForm(
|
||||
**{**existing_model.model_dump(), **model_data}
|
||||
)
|
||||
Models.update_model_by_id(model_id, updated_model)
|
||||
else:
|
||||
# Insert new model
|
||||
model_data["meta"] = model_data.get("meta", {})
|
||||
model_data["params"] = model_data.get("params", {})
|
||||
new_model = ModelForm(**model_data)
|
||||
Models.insert_new_model(user_id=user.id, form_data=new_model)
|
||||
return True
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON format")
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
############################
|
||||
# SyncModels
|
||||
############################
|
||||
@@ -129,6 +188,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
|
||||
############################
|
||||
|
||||
@@ -48,7 +48,7 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
|
||||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
||||
}
|
||||
)
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
for note in Notes.get_notes_by_permission(user.id, "write")
|
||||
]
|
||||
|
||||
return notes
|
||||
@@ -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,17 @@ 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_permission(
|
||||
user.id, "write", skip=skip, limit=limit
|
||||
)
|
||||
]
|
||||
|
||||
return notes
|
||||
@@ -171,6 +180,18 @@ async def update_note_by_id(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
|
||||
try:
|
||||
note = Notes.update_note_by_id(id, form_data)
|
||||
await sio.emit(
|
||||
|
||||
@@ -340,7 +340,10 @@ def merge_ollama_models_lists(model_lists):
|
||||
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:
|
||||
@@ -1691,25 +1694,27 @@ async def download_file_stream(
|
||||
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
|
||||
|
||||
if done:
|
||||
file.seek(0)
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
file.seek(0)
|
||||
file.close()
|
||||
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
response = requests.post(url, data=file)
|
||||
with open(file_path, "rb") as file:
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
|
||||
if response.ok:
|
||||
res = {
|
||||
"done": done,
|
||||
"blob": f"sha256:{hashed}",
|
||||
"name": file_name,
|
||||
}
|
||||
os.remove(file_path)
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
with requests.Session() as session:
|
||||
response = session.post(url, data=file, timeout=30)
|
||||
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
else:
|
||||
raise "Ollama: Could not create blob, Please try again."
|
||||
if response.ok:
|
||||
res = {
|
||||
"done": done,
|
||||
"blob": f"sha256:{hashed}",
|
||||
"name": file_name,
|
||||
}
|
||||
os.remove(file_path)
|
||||
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
else:
|
||||
raise "Ollama: Could not create blob, Please try again."
|
||||
|
||||
|
||||
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
|
||||
|
||||
@@ -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,
|
||||
@@ -119,6 +121,93 @@ def openai_reasoning_model_handler(payload):
|
||||
return payload
|
||||
|
||||
|
||||
async 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 = await 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 = await 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,
|
||||
)
|
||||
|
||||
@@ -401,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()")
|
||||
|
||||
@@ -489,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 = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
models = {
|
||||
@@ -509,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:
|
||||
@@ -572,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
|
||||
@@ -584,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 = await 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:
|
||||
@@ -624,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:
|
||||
@@ -836,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 = await 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)
|
||||
|
||||
@@ -890,6 +936,7 @@ async def generate_chat_completion(
|
||||
url=request_url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
@@ -951,31 +998,29 @@ 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 = await 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", ""):
|
||||
@@ -1037,23 +1082,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 = await 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)
|
||||
@@ -1062,7 +1102,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)
|
||||
@@ -1071,6 +1110,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
|
||||
# Web search engines
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.retrieval.web.ollama import search_ollama_cloud
|
||||
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
|
||||
from open_webui.retrieval.web.brave import search_brave
|
||||
from open_webui.retrieval.web.kagi import search_kagi
|
||||
from open_webui.retrieval.web.mojeek import search_mojeek
|
||||
@@ -76,6 +78,7 @@ from open_webui.retrieval.utils import (
|
||||
query_doc,
|
||||
query_doc_with_hybrid_search,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import filter_metadata
|
||||
from open_webui.utils.misc import (
|
||||
calculate_sha256_string,
|
||||
)
|
||||
@@ -426,8 +429,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,
|
||||
@@ -464,6 +472,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
@@ -520,6 +529,7 @@ class WebConfig(BaseModel):
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
|
||||
SEARXNG_QUERY_URL: Optional[str] = None
|
||||
YACY_QUERY_URL: Optional[str] = None
|
||||
YACY_USERNAME: Optional[str] = None
|
||||
@@ -596,8 +606,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
|
||||
@@ -767,6 +782,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
|
||||
@@ -777,7 +802,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
|
||||
@@ -954,6 +993,9 @@ async def update_rag_config(
|
||||
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
|
||||
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
)
|
||||
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = (
|
||||
form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
)
|
||||
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
|
||||
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
|
||||
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
|
||||
@@ -1062,8 +1104,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,
|
||||
@@ -1100,6 +1147,7 @@ async def update_rag_config(
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
@@ -1295,7 +1343,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,
|
||||
@@ -1331,6 +1379,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 = [
|
||||
{
|
||||
@@ -1342,11 +1391,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)
|
||||
@@ -1365,198 +1416,228 @@ def process_file(
|
||||
form_data: ProcessFileForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
try:
|
||||
if user.role == "admin":
|
||||
file = Files.get_file_by_id(form_data.file_id)
|
||||
else:
|
||||
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
|
||||
|
||||
collection_name = form_data.collection_name
|
||||
if file:
|
||||
try:
|
||||
|
||||
if collection_name is None:
|
||||
collection_name = f"file-{file.id}"
|
||||
collection_name = form_data.collection_name
|
||||
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
|
||||
if collection_name is None:
|
||||
collection_name = f"file-{file.id}"
|
||||
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
|
||||
except:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=form_data.content.replace("<br/>", "\n"),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(
|
||||
collection_name=f"file-{file.id}"
|
||||
)
|
||||
except:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=form_data.content.replace("<br/>", "\n"),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
text_content = form_data.content
|
||||
elif form_data.collection_name:
|
||||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=f"file-{file.id}", filter={"file_id": file.id}
|
||||
)
|
||||
]
|
||||
|
||||
text_content = form_data.content
|
||||
elif form_data.collection_name:
|
||||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.documents[0][idx],
|
||||
metadata=result.metadatas[0][idx],
|
||||
)
|
||||
for idx, id in enumerate(result.ids[0])
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=f"file-{file.id}", filter={"file_id": file.id}
|
||||
text_content = file.data.get("content", "")
|
||||
else:
|
||||
# Process the file and save the content
|
||||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
|
||||
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"pipeline": request.app.state.config.DOCLING_PIPELINE,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
},
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
)
|
||||
docs = loader.load(
|
||||
file.filename, file.meta.get("content_type"), file_path
|
||||
)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=doc.page_content,
|
||||
metadata={
|
||||
**filter_metadata(doc.metadata),
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
text_content = " ".join([doc.page_content for doc in docs])
|
||||
|
||||
log.debug(f"text_content: {text_content}")
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"content": text_content},
|
||||
)
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.documents[0][idx],
|
||||
metadata=result.metadatas[0][idx],
|
||||
)
|
||||
for idx, id in enumerate(result.ids[0])
|
||||
]
|
||||
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:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
text_content = file.data.get("content", "")
|
||||
else:
|
||||
# Process the file and save the content
|
||||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
},
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
)
|
||||
docs = loader.load(
|
||||
file.filename, file.meta.get("content_type"), file_path
|
||||
)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=doc.page_content,
|
||||
metadata={
|
||||
**doc.metadata,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
"hash": hash,
|
||||
},
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
text_content = " ".join([doc.page_content for doc in docs])
|
||||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
log.debug(f"text_content: {text_content}")
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed", "content": text_content},
|
||||
)
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
|
||||
if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
metadata={
|
||||
"file_id": file.id,
|
||||
"name": file.filename,
|
||||
"hash": hash,
|
||||
},
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
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
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
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)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class ProcessTextForm(BaseModel):
|
||||
@@ -1714,7 +1795,25 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
"""
|
||||
|
||||
# TODO: add playwright to search the web
|
||||
if engine == "searxng":
|
||||
if engine == "ollama_cloud":
|
||||
return search_ollama_cloud(
|
||||
"https://ollama.com",
|
||||
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
elif engine == "perplexity_search":
|
||||
if request.app.state.config.PERPLEXITY_API_KEY:
|
||||
return search_perplexity_search(
|
||||
request.app.state.config.PERPLEXITY_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception("No PERPLEXITY_API_KEY found in environment variables")
|
||||
elif engine == "searxng":
|
||||
if request.app.state.config.SEARXNG_QUERY_URL:
|
||||
return search_searxng(
|
||||
request.app.state.config.SEARXNG_QUERY_URL,
|
||||
@@ -1945,6 +2044,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}"
|
||||
@@ -1966,6 +2067,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))
|
||||
@@ -2010,12 +2112,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,
|
||||
@@ -2048,6 +2154,7 @@ async def process_web_search(
|
||||
return {
|
||||
"status": True,
|
||||
"collection_names": [collection_name],
|
||||
"items": result_items,
|
||||
"filenames": urls,
|
||||
"loaded_count": len(docs),
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ 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
|
||||
|
||||
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.tools import (
|
||||
ToolForm,
|
||||
ToolModel,
|
||||
@@ -15,7 +17,11 @@ from open_webui.models.tools import (
|
||||
ToolUserResponse,
|
||||
Tools,
|
||||
)
|
||||
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
|
||||
from open_webui.utils.plugin import (
|
||||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
get_tool_module_from_cache,
|
||||
)
|
||||
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
|
||||
@@ -33,6 +39,14 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tool_module(request, tool_id, load_from_db=True):
|
||||
"""
|
||||
Get the tool module by its ID.
|
||||
"""
|
||||
tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
|
||||
return tool_module
|
||||
|
||||
|
||||
############################
|
||||
# GetTools
|
||||
############################
|
||||
@@ -40,8 +54,21 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ToolUserResponse])
|
||||
async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
tools = Tools.get_tools()
|
||||
tools = []
|
||||
|
||||
# Local Tools
|
||||
for tool in Tools.get_tools():
|
||||
tool_module = get_tool_module(request, tool.id)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
"has_user_valves": hasattr(tool_module, "UserValves"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
for server in await get_tool_servers(request):
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
@@ -67,15 +94,60 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
)
|
||||
)
|
||||
|
||||
# MCP Tool Servers
|
||||
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if server.get("type", "openapi") == "mcp":
|
||||
server_id = server.get("info", {}).get("id")
|
||||
auth_type = server.get("auth_type", "none")
|
||||
|
||||
session_token = None
|
||||
if auth_type == "oauth_2.1":
|
||||
splits = server_id.split(":")
|
||||
server_id = splits[-1] if len(splits) > 1 else server_id
|
||||
|
||||
session_token = (
|
||||
await request.app.state.oauth_client_manager.get_oauth_token(
|
||||
user.id, f"mcp:{server_id}"
|
||||
)
|
||||
)
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"name": server.get("info", {}).get("name", "MCP Tool Server"),
|
||||
"meta": {
|
||||
"description": server.get("info", {}).get(
|
||||
"description", ""
|
||||
),
|
||||
},
|
||||
"access_control": server.get("config", {}).get(
|
||||
"access_control", None
|
||||
),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
**(
|
||||
{
|
||||
"authenticated": session_token is not None,
|
||||
}
|
||||
if auth_type == "oauth_2.1"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
# Admin can see all tools
|
||||
return tools
|
||||
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
|
||||
|
||||
@@ -460,8 +532,9 @@ async def update_tools_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
valves = Valves(**form_data)
|
||||
Tools.update_tool_valves_by_id(id, valves.model_dump())
|
||||
return valves.model_dump()
|
||||
valves_dict = valves.model_dump(exclude_unset=True)
|
||||
Tools.update_tool_valves_by_id(id, valves_dict)
|
||||
return valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update tool valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
@@ -536,10 +609,11 @@ async def update_tools_user_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Tools.update_user_valves_by_id_and_user_id(
|
||||
id, user.id, user_valves.model_dump()
|
||||
id, user.id, user_valves_dict
|
||||
)
|
||||
return user_valves.model_dump()
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update user valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -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
|
||||
############################
|
||||
@@ -137,6 +157,7 @@ class SharingPermissions(BaseModel):
|
||||
public_knowledge: bool = True
|
||||
public_prompts: bool = True
|
||||
public_tools: bool = True
|
||||
public_notes: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
@@ -340,6 +361,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
|
||||
############################
|
||||
|
||||
@@ -705,6 +705,23 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "embeds":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
|
||||
embeds = event_data.get("data", {}).get("embeds", [])
|
||||
embeds.extend(message.get("embeds", []))
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
"embeds": embeds,
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "files":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,12 +109,19 @@ def has_access(
|
||||
user_id: str,
|
||||
type: str = "write",
|
||||
access_control: Optional[dict] = None,
|
||||
user_group_ids: Optional[Set[str]] = None,
|
||||
strict: bool = True,
|
||||
) -> bool:
|
||||
if access_control is None:
|
||||
return type == "read"
|
||||
if strict:
|
||||
return type == "read"
|
||||
else:
|
||||
return True
|
||||
|
||||
if user_group_ids is None:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
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", [])
|
||||
@@ -127,9 +134,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)
|
||||
@@ -0,0 +1,97 @@
|
||||
from open_webui.routers.images import (
|
||||
load_b64_image_data,
|
||||
upload_image,
|
||||
)
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
|
||||
from open_webui.routers.files import upload_file_handler
|
||||
|
||||
import mimetypes
|
||||
import base64
|
||||
import io
|
||||
|
||||
|
||||
def get_image_url_from_base64(request, base64_image_string, metadata, user):
|
||||
if "data:image/png;base64" in base64_image_string:
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = load_b64_image_data(base64_image_string)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
return image_url
|
||||
return None
|
||||
|
||||
|
||||
def load_b64_audio_data(b64_str):
|
||||
try:
|
||||
if "," in b64_str:
|
||||
header, b64_data = b64_str.split(",", 1)
|
||||
else:
|
||||
b64_data = b64_str
|
||||
header = "data:audio/wav;base64"
|
||||
audio_data = base64.b64decode(b64_data)
|
||||
content_type = (
|
||||
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
|
||||
)
|
||||
return audio_data, content_type
|
||||
except Exception as e:
|
||||
print(f"Error decoding base64 audio data: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def upload_audio(request, audio_data, content_type, metadata, user):
|
||||
audio_format = mimetypes.guess_extension(content_type)
|
||||
file = UploadFile(
|
||||
file=io.BytesIO(audio_data),
|
||||
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
|
||||
headers={
|
||||
"content-type": content_type,
|
||||
},
|
||||
)
|
||||
file_item = upload_file_handler(
|
||||
request,
|
||||
file=file,
|
||||
metadata=metadata,
|
||||
process=False,
|
||||
user=user,
|
||||
)
|
||||
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
|
||||
return url
|
||||
|
||||
|
||||
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
|
||||
if "data:audio/wav;base64" in base64_audio_string:
|
||||
audio_url = ""
|
||||
# Extract base64 audio data from the line
|
||||
audio_data, content_type = load_b64_audio_data(base64_audio_string)
|
||||
if audio_data is not None:
|
||||
audio_url = upload_audio(
|
||||
request,
|
||||
audio_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
return audio_url
|
||||
return None
|
||||
|
||||
|
||||
def get_file_url_from_base64(request, base64_file_string, metadata, user):
|
||||
if "data:image/png;base64" in base64_file_string:
|
||||
return get_image_url_from_base64(request, base64_file_string, metadata, user)
|
||||
elif "data:audio/wav;base64" in base64_file_string:
|
||||
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
||||
return None
|
||||
@@ -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, {}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self):
|
||||
self.session: Optional[ClientSession] = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
async def connect(self, url: str, headers: Optional[dict] = None):
|
||||
try:
|
||||
self._streams_context = streamablehttp_client(url, headers=headers)
|
||||
|
||||
transport = await self.exit_stack.enter_async_context(self._streams_context)
|
||||
read_stream, write_stream, _ = transport
|
||||
|
||||
self._session_context = ClientSession(
|
||||
read_stream, write_stream
|
||||
) # pylint: disable=W0201
|
||||
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
self._session_context
|
||||
)
|
||||
await self.session.initialize()
|
||||
except Exception as e:
|
||||
await self.disconnect()
|
||||
raise e
|
||||
|
||||
async def list_tool_specs(self) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.list_tools()
|
||||
tools = result.tools
|
||||
|
||||
tool_specs = []
|
||||
for tool in tools:
|
||||
name = tool.name
|
||||
description = tool.description
|
||||
|
||||
inputSchema = tool.inputSchema
|
||||
|
||||
# TODO: handle outputSchema if needed
|
||||
outputSchema = getattr(tool, "outputSchema", None)
|
||||
|
||||
tool_specs.append(
|
||||
{"name": name, "description": description, "parameters": inputSchema}
|
||||
)
|
||||
|
||||
return tool_specs
|
||||
|
||||
async def call_tool(
|
||||
self, function_name: str, function_args: dict
|
||||
) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.call_tool(function_name, function_args)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP tool call.")
|
||||
|
||||
result_dict = result.model_dump(mode="json")
|
||||
result_content = result_dict.get("content", {})
|
||||
|
||||
if result.isError:
|
||||
raise Exception(result_content)
|
||||
else:
|
||||
return result_content
|
||||
|
||||
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.list_resources(cursor=cursor)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP list_resources call.")
|
||||
|
||||
result_dict = result.model_dump()
|
||||
resources = result_dict.get("resources", [])
|
||||
|
||||
return resources
|
||||
|
||||
async def read_resource(self, uri: str) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.read_resource(uri)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP read_resource call.")
|
||||
result_dict = result.model_dump()
|
||||
|
||||
return result_dict
|
||||
|
||||
async def disconnect(self):
|
||||
# Clean up and close the session
|
||||
await self.exit_stack.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.exit_stack.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
|
||||
await self.disconnect()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,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.
|
||||
|
||||
@@ -36,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
|
||||
@@ -53,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
|
||||
|
||||
@@ -120,19 +120,20 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
|
||||
return get_system_message(messages), remove_system_message(messages)
|
||||
|
||||
|
||||
def prepend_to_first_user_message_content(
|
||||
content: str, messages: list[dict]
|
||||
) -> list[dict]:
|
||||
for message in messages:
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
item["text"] = f"{content}\n{item['text']}"
|
||||
else:
|
||||
message["content"] = f"{content}\n{message['content']}"
|
||||
break
|
||||
return messages
|
||||
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
|
||||
if isinstance(message["content"], list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
if append:
|
||||
item["text"] = f"{item['text']}\n{content}"
|
||||
else:
|
||||
item["text"] = f"{content}\n{item['text']}"
|
||||
else:
|
||||
if append:
|
||||
message["content"] = f"{message['content']}\n{content}"
|
||||
else:
|
||||
message["content"] = f"{content}\n{message['content']}"
|
||||
return message
|
||||
|
||||
|
||||
def add_or_update_system_message(
|
||||
@@ -148,10 +149,7 @@ def add_or_update_system_message(
|
||||
"""
|
||||
|
||||
if messages and messages[0].get("role") == "system":
|
||||
if append:
|
||||
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
|
||||
else:
|
||||
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
|
||||
messages[0] = update_message_content(messages[0], content, append)
|
||||
else:
|
||||
# Insert at the beginning
|
||||
messages.insert(0, {"role": "system", "content": content})
|
||||
@@ -159,7 +157,7 @@ def add_or_update_system_message(
|
||||
return messages
|
||||
|
||||
|
||||
def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
def add_or_update_user_message(content: str, messages: list[dict], append: bool = True):
|
||||
"""
|
||||
Adds a new user message at the end of the messages list
|
||||
or updates the existing user message at the end.
|
||||
@@ -170,7 +168,7 @@ def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
"""
|
||||
|
||||
if messages and messages[-1].get("role") == "user":
|
||||
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
|
||||
messages[-1] = update_message_content(messages[-1], content, append)
|
||||
else:
|
||||
# Insert at the end
|
||||
messages.append({"role": "user", "content": content})
|
||||
@@ -178,6 +176,16 @@ def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
return messages
|
||||
|
||||
|
||||
def prepend_to_first_user_message_content(
|
||||
content: str, messages: list[dict]
|
||||
) -> list[dict]:
|
||||
for message in messages:
|
||||
if message["role"] == "user":
|
||||
message = update_message_content(message, content, append=False)
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
def append_or_update_assistant_message(content: str, messages: list[dict]):
|
||||
"""
|
||||
Adds a new assistant message at the end of the messages list
|
||||
@@ -383,17 +391,10 @@ def parse_ollama_modelfile(model_text):
|
||||
"top_k": int,
|
||||
"top_p": float,
|
||||
"num_keep": int,
|
||||
"typical_p": float,
|
||||
"presence_penalty": float,
|
||||
"frequency_penalty": float,
|
||||
"penalize_newline": bool,
|
||||
"numa": bool,
|
||||
"num_batch": int,
|
||||
"num_gpu": int,
|
||||
"main_gpu": int,
|
||||
"low_vram": bool,
|
||||
"f16_kv": bool,
|
||||
"vocab_only": bool,
|
||||
"use_mmap": bool,
|
||||
"use_mlock": bool,
|
||||
"num_thread": int,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -262,6 +263,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
"icon": function.meta.manifest.get("icon_url", None)
|
||||
or getattr(module, "icon_url", None)
|
||||
or getattr(module, "icon", None),
|
||||
"has_user_valves": hasattr(module, "UserValves"),
|
||||
}
|
||||
]
|
||||
|
||||
@@ -332,3 +334,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
|
||||
|
||||
+947
-173
File diff suppressed because it is too large
Load Diff
@@ -153,17 +153,11 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
|
||||
"repeat_last_n": int,
|
||||
"top_k": int,
|
||||
"min_p": float,
|
||||
"typical_p": float,
|
||||
"repeat_penalty": float,
|
||||
"presence_penalty": float,
|
||||
"frequency_penalty": float,
|
||||
"penalize_newline": bool,
|
||||
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
|
||||
"numa": bool,
|
||||
"num_gpu": int,
|
||||
"main_gpu": int,
|
||||
"low_vram": bool,
|
||||
"vocab_only": bool,
|
||||
"use_mmap": bool,
|
||||
"use_mlock": bool,
|
||||
"num_thread": int,
|
||||
|
||||
@@ -166,6 +166,48 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
||||
if load_from_db:
|
||||
# Always load from the database by default
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
if not tool:
|
||||
raise Exception(f"Tool not found: {tool_id}")
|
||||
content = tool.content
|
||||
|
||||
new_content = replace_imports(content)
|
||||
if new_content != content:
|
||||
content = new_content
|
||||
# Update the tool content in the database
|
||||
Tools.update_tool_by_id(tool_id, {"content": content})
|
||||
|
||||
if (
|
||||
hasattr(request.app.state, "TOOL_CONTENTS")
|
||||
and tool_id in request.app.state.TOOL_CONTENTS
|
||||
) and (
|
||||
hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
|
||||
):
|
||||
if request.app.state.TOOL_CONTENTS[tool_id] == content:
|
||||
return request.app.state.TOOLS[tool_id], None
|
||||
|
||||
tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
|
||||
else:
|
||||
if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
|
||||
return request.app.state.TOOLS[tool_id], None
|
||||
|
||||
tool_module, frontmatter = load_tool_module_by_id(tool_id)
|
||||
|
||||
if not hasattr(request.app.state, "TOOLS"):
|
||||
request.app.state.TOOLS = {}
|
||||
|
||||
if not hasattr(request.app.state, "TOOL_CONTENTS"):
|
||||
request.app.state.TOOL_CONTENTS = {}
|
||||
|
||||
request.app.state.TOOLS[tool_id] = tool_module
|
||||
request.app.state.TOOL_CONTENTS[tool_id] = content
|
||||
|
||||
return tool_module, frontmatter
|
||||
|
||||
|
||||
def get_function_module_from_cache(request, function_id, load_from_db=True):
|
||||
if load_from_db:
|
||||
# Always load from the database by default
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -96,72 +96,117 @@ async def get_tools(
|
||||
for tool_id in tool_ids:
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
|
||||
if tool_id.startswith("server:"):
|
||||
server_id = tool_id.split(":")[1]
|
||||
splits = tool_id.split(":")
|
||||
|
||||
tool_server_data = None
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
if len(splits) == 2:
|
||||
type = "openapi"
|
||||
server_id = splits[1]
|
||||
elif len(splits) == 3:
|
||||
type = splits[1]
|
||||
server_id = splits[2]
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
server_id_splits = server_id.split("|")
|
||||
if len(server_id_splits) == 2:
|
||||
server_id = server_id_splits[0]
|
||||
function_names = server_id_splits[1].split(",")
|
||||
|
||||
if type == "openapi":
|
||||
|
||||
tool_server_data = None
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
tool_server_idx
|
||||
]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
|
||||
cookies = {}
|
||||
headers = {}
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {tool_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
def make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
url=tool_server_data["url"],
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
tool_function,
|
||||
{},
|
||||
)
|
||||
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
# Misc info
|
||||
"type": "external",
|
||||
}
|
||||
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
token = None
|
||||
|
||||
if auth_type == "bearer":
|
||||
token = tool_server_connection.get("key", "")
|
||||
elif auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
|
||||
def make_tool_function(function_name, token, tool_server_data):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
token=token,
|
||||
url=tool_server_data["url"],
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, token, tool_server_data
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
tool_function,
|
||||
{},
|
||||
)
|
||||
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
@@ -518,12 +563,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:
|
||||
@@ -532,27 +588,20 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
||||
error = str(err)
|
||||
raise Exception(error)
|
||||
|
||||
data = {
|
||||
"openapi": res,
|
||||
"info": res.get("info", {}),
|
||||
"specs": convert_openapi_to_tool_payload(res),
|
||||
}
|
||||
|
||||
log.info(f"Fetched data: {data}")
|
||||
return data
|
||||
log.debug(f"Fetched data: {res}")
|
||||
return res
|
||||
|
||||
|
||||
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
|
||||
|
||||
tasks = []
|
||||
server_entries = []
|
||||
for idx, server in enumerate(servers):
|
||||
if server.get("config", {}).get("enable"):
|
||||
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
|
||||
openapi_path = server.get("path", "openapi.json")
|
||||
full_url = get_tool_server_url(server.get("url"), openapi_path)
|
||||
|
||||
if (
|
||||
server.get("config", {}).get("enable")
|
||||
and server.get("type", "openapi") == "openapi"
|
||||
):
|
||||
info = server.get("info", {})
|
||||
|
||||
auth_type = server.get("auth_type", "bearer")
|
||||
@@ -560,19 +609,42 @@ async def get_tool_servers_data(
|
||||
|
||||
if auth_type == "bearer":
|
||||
token = server.get("key", "")
|
||||
elif auth_type == "session":
|
||||
token = session_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))
|
||||
server_url = server.get("url")
|
||||
spec_type = server.get("spec_type", "url")
|
||||
|
||||
# Create async tasks to fetch data
|
||||
tasks = [
|
||||
get_tool_server_data(token, url) for (_, _, _, url, _, token) in server_entries
|
||||
]
|
||||
# Create async tasks to fetch data
|
||||
task = None
|
||||
if spec_type == "url":
|
||||
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
|
||||
openapi_path = server.get("path", "openapi.json")
|
||||
spec_url = get_tool_server_url(server_url, openapi_path)
|
||||
# Fetch from URL
|
||||
task = get_tool_server_data(token, spec_url)
|
||||
elif spec_type == "json" and server.get("spec", ""):
|
||||
# Use provided JSON spec
|
||||
spec_json = None
|
||||
try:
|
||||
spec_json = json.loads(server.get("spec", ""))
|
||||
except Exception as e:
|
||||
log.error(f"Error parsing JSON spec for tool server {id}: {e}")
|
||||
|
||||
if spec_json:
|
||||
task = asyncio.sleep(
|
||||
0,
|
||||
result=spec_json,
|
||||
)
|
||||
|
||||
if task:
|
||||
tasks.append(task)
|
||||
server_entries.append((id, idx, server, server_url, info, token))
|
||||
|
||||
# Execute tasks concurrently
|
||||
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -584,8 +656,13 @@ async def get_tool_servers_data(
|
||||
log.error(f"Failed to connect to {url} OpenAPI tool server")
|
||||
continue
|
||||
|
||||
openapi_data = response.get("openapi", {})
|
||||
response = {
|
||||
"openapi": response,
|
||||
"info": response.get("info", {}),
|
||||
"specs": convert_openapi_to_tool_payload(response),
|
||||
}
|
||||
|
||||
openapi_data = response.get("openapi", {})
|
||||
if info and isinstance(openapi_data, dict):
|
||||
openapi_data["info"] = openapi_data.get("info", {})
|
||||
|
||||
@@ -610,8 +687,13 @@ 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]
|
||||
) -> Any:
|
||||
url: str,
|
||||
headers: Dict[str, str],
|
||||
cookies: Dict[str, str],
|
||||
name: str,
|
||||
params: Dict[str, Any],
|
||||
server_data: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||
error = None
|
||||
try:
|
||||
openapi = server_data.get("openapi", {})
|
||||
@@ -671,11 +753,6 @@ 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, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
@@ -686,7 +763,9 @@ async def execute_tool_server(
|
||||
final_url,
|
||||
json=body_params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
@@ -697,12 +776,15 @@ async def execute_tool_server(
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
else:
|
||||
async with request_method(
|
||||
final_url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
@@ -713,12 +795,13 @@ async def execute_tool_server(
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
|
||||
except Exception as err:
|
||||
error = str(err)
|
||||
log.exception(f"API Request Error: {error}")
|
||||
return {"error": error}
|
||||
return ({"error": error}, None)
|
||||
|
||||
|
||||
def get_tool_server_url(url: Optional[str], path: str) -> str:
|
||||
|
||||
+36
-32
@@ -2,75 +2,82 @@ fastapi==0.115.7
|
||||
uvicorn[standard]==0.35.0
|
||||
pydantic==2.11.7
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.13.0
|
||||
python-jose==3.4.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
cryptography
|
||||
bcrypt==4.3.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.3
|
||||
|
||||
requests==2.32.4
|
||||
requests==2.32.5
|
||||
aiohttp==3.12.15
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.6.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
sqlalchemy==2.0.38
|
||||
alembic==1.14.0
|
||||
peewee==3.18.1
|
||||
peewee-migrate==1.12.2
|
||||
psycopg2-binary==2.9.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
|
||||
|
||||
pymongo
|
||||
|
||||
psycopg2-binary==2.9.10
|
||||
pgvector==0.4.1
|
||||
|
||||
PyMySQL==1.1.1
|
||||
boto3==1.40.5
|
||||
|
||||
APScheduler==3.10.4
|
||||
RestrictedPython==8.0
|
||||
|
||||
loguru==0.7.3
|
||||
asgiref==3.8.1
|
||||
|
||||
# AI libraries
|
||||
tiktoken
|
||||
mcp==1.14.1
|
||||
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.32.0
|
||||
google-genai==1.38.0
|
||||
google-generativeai==0.8.5
|
||||
tiktoken
|
||||
|
||||
langchain==0.3.26
|
||||
langchain-community==0.3.26
|
||||
langchain==0.3.27
|
||||
langchain-community==0.3.29
|
||||
|
||||
fake-useragent==2.2.0
|
||||
chromadb==0.6.3
|
||||
posthog==5.4.0
|
||||
chromadb==1.0.20
|
||||
opensearch-py==2.8.0
|
||||
|
||||
pymilvus==2.5.0
|
||||
qdrant-client==1.14.3
|
||||
opensearch-py==2.8.0
|
||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||
elasticsearch==9.0.1
|
||||
elasticsearch==9.1.0
|
||||
pinecone==6.0.2
|
||||
oracledb==3.2.0
|
||||
|
||||
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
||||
transformers
|
||||
sentence-transformers==4.1.0
|
||||
sentence-transformers==5.1.1
|
||||
accelerate
|
||||
colbert-ai==0.2.21
|
||||
pyarrow==20.0.0
|
||||
einops==0.8.1
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.1
|
||||
|
||||
colbert-ai==0.2.21
|
||||
|
||||
ftfy==6.2.3
|
||||
pypdf==4.3.1
|
||||
pypdf==6.0.0
|
||||
fpdf2==2.8.2
|
||||
pymdown-extensions==10.14.2
|
||||
docx2txt==0.8
|
||||
@@ -95,14 +102,11 @@ rapidocr-onnxruntime==1.4.4
|
||||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.20.1
|
||||
|
||||
faster-whisper==1.1.1
|
||||
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.1
|
||||
|
||||
black==25.1.0
|
||||
youtube-transcript-api==1.1.0
|
||||
youtube-transcript-api==1.2.2
|
||||
pytube==15.0.0
|
||||
|
||||
pydub
|
||||
@@ -115,13 +119,13 @@ 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
|
||||
azure-identity==1.25.0
|
||||
azure-storage-blob==12.24.1
|
||||
|
||||
|
||||
|
||||
+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
+127
-102
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.26",
|
||||
"version": "0.6.32",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.6.26",
|
||||
"version": "0.6.32",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
@@ -23,10 +23,10 @@
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.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,7 +37,9 @@
|
||||
"@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",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -46,7 +48,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 +68,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 +84,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 +2226,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 +2240,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 +2252,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 +2281,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 +2297,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 +2313,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 +2329,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 +2345,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 +2361,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 +2377,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 +2393,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 +2409,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"
|
||||
],
|
||||
@@ -3382,9 +3384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-collaboration": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
|
||||
"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
|
||||
"integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -3392,8 +3394,8 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5",
|
||||
"@tiptap/y-tiptap": "^3.0.0-beta.3",
|
||||
"yjs": "^13"
|
||||
}
|
||||
@@ -3412,9 +3414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-drag-handle": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
|
||||
"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
|
||||
"integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13"
|
||||
@@ -3424,10 +3426,10 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-collaboration": "^3.0.7",
|
||||
"@tiptap/extension-node-range": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/extension-collaboration": "^3.4.5",
|
||||
"@tiptap/extension-node-range": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5",
|
||||
"@tiptap/y-tiptap": "^3.0.0-beta.3"
|
||||
}
|
||||
},
|
||||
@@ -3516,16 +3518,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": {
|
||||
@@ -3641,9 +3643,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-node-range": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
|
||||
"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
|
||||
"integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -3651,8 +3653,8 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7"
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
@@ -3855,18 +3857,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": {
|
||||
@@ -4569,6 +4570,21 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webreflection/fetch": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
|
||||
@@ -4672,6 +4688,15 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
|
||||
"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/amator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
|
||||
@@ -6767,9 +6792,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 +9602,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 +9618,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 +9630,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 +10277,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 +10930,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"
|
||||
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.26",
|
||||
"version": "0.6.32",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
@@ -67,10 +67,10 @@
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.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,7 +81,9 @@
|
||||
"@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",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -90,7 +92,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 +112,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 +128,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",
|
||||
|
||||
+27
-22
@@ -10,23 +10,25 @@ dependencies = [
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"pydantic==2.11.7",
|
||||
"python-multipart==0.0.20",
|
||||
"itsdangerous==2.2.0",
|
||||
|
||||
"python-socketio==5.13.0",
|
||||
"python-jose==3.4.0",
|
||||
"passlib[bcrypt]==1.7.4",
|
||||
"cryptography",
|
||||
"bcrypt==4.3.0",
|
||||
"argon2-cffi==23.1.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.6.1",
|
||||
"authlib==1.6.3",
|
||||
|
||||
"requests==2.32.4",
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.12.15",
|
||||
"async-timeout",
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
"starlette-compress==1.6.0",
|
||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||
"starsessions[redis]==2.2.1",
|
||||
|
||||
"sqlalchemy==2.0.38",
|
||||
"alembic==1.14.0",
|
||||
@@ -46,33 +48,28 @@ dependencies = [
|
||||
"asgiref==3.8.1",
|
||||
|
||||
"tiktoken",
|
||||
"mcp==1.14.1",
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.32.0",
|
||||
"google-genai==1.38.0",
|
||||
"google-generativeai==0.8.5",
|
||||
|
||||
"langchain==0.3.26",
|
||||
"langchain-community==0.3.26",
|
||||
"langchain==0.3.27",
|
||||
"langchain-community==0.3.29",
|
||||
|
||||
"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",
|
||||
"sentence-transformers==5.1.1",
|
||||
"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",
|
||||
@@ -112,10 +109,10 @@ dependencies = [
|
||||
|
||||
|
||||
|
||||
"googleapis-common-protos==1.63.2",
|
||||
"googleapis-common-protos==1.70.0",
|
||||
"google-cloud-storage==2.19.0",
|
||||
|
||||
"azure-identity==1.20.0",
|
||||
"azure-identity==1.25.0",
|
||||
"azure-storage-blob==12.24.1",
|
||||
|
||||
"ldap3==2.9.1",
|
||||
@@ -124,7 +121,6 @@ dependencies = [
|
||||
"tencentcloud-sdk-python==3.0.1336",
|
||||
|
||||
"oracledb>=3.2.0",
|
||||
"posthog==5.4.0",
|
||||
|
||||
]
|
||||
readme = "README.md"
|
||||
@@ -142,8 +138,8 @@ classifiers = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
postgres = [
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pgvector==0.4.0",
|
||||
"psycopg2-binary==2.9.10",
|
||||
"pgvector==0.4.1",
|
||||
]
|
||||
|
||||
all = [
|
||||
@@ -155,6 +151,15 @@ all = [
|
||||
"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]
|
||||
|
||||
+144
-11
@@ -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 {
|
||||
@@ -116,7 +116,7 @@ li p {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
background-color: rgba(215, 215, 215, 0.8);
|
||||
background-color: rgba(215, 215, 215, 0.6);
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
@@ -124,12 +124,12 @@ li p {
|
||||
|
||||
/* Dark theme scrollbar styles */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
|
||||
background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
|
||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.6rem;
|
||||
height: 0.4rem;
|
||||
width: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -637,3 +661,112 @@ body {
|
||||
background: #171717;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Position the handle relative to each LI */
|
||||
.pm-li--with-handle {
|
||||
position: relative;
|
||||
margin-left: 12px; /* make space for the handle */
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* The drag handle itself */
|
||||
.pm-list-drag-handle {
|
||||
position: absolute;
|
||||
left: -36px; /* pull into the left gutter */
|
||||
top: 1px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
|
||||
left: -16px; /* pull into the left gutter more to avoid the checkbox */
|
||||
}
|
||||
|
||||
.pm-list-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pm-li--with-handle:hover > .pm-list-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.pm-list-drag-handle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:root {
|
||||
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
|
||||
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
|
||||
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* BEFORE/AFTER lines */
|
||||
.pm-li-drop-before::before,
|
||||
.pm-li-drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--pm-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pm-li-drop-before::before {
|
||||
top: -2px;
|
||||
}
|
||||
.pm-li-drop-after::after {
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
background: var(--pm-fill-target);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-li-drop-outdent::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 3px;
|
||||
background: color-mix(in oklab, Highlight 35%, transparent);
|
||||
}
|
||||
|
||||
.pm-li--with-handle:has(.pm-li-drop-before),
|
||||
.pm-li--with-handle:has(.pm-li-drop-after),
|
||||
.pm-li--with-handle:has(.pm-li-drop-into),
|
||||
.pm-li--with-handle:has(.pm-li-drop-outdent) {
|
||||
background: var(--pm-fill-ancestor);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
+30
-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) {
|
||||
|
||||
@@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
|
||||
};
|
||||
|
||||
type MessageForm = {
|
||||
reply_to_id?: string;
|
||||
parent_id?: string;
|
||||
content: string;
|
||||
data?: object;
|
||||
|
||||
@@ -33,6 +33,38 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
|
||||
return res;
|
||||
};
|
||||
|
||||
export const unarchiveAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/unarchive/all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const importChat = async (
|
||||
token: string,
|
||||
chat: object,
|
||||
@@ -77,7 +109,11 @@ export const importChat = async (
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChatList = async (token: string = '', page: number | null = null) => {
|
||||
export const getChatList = async (
|
||||
token: string = '',
|
||||
page: number | null = null,
|
||||
include_folders: boolean = false
|
||||
) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -85,6 +121,10 @@ export const getChatList = async (token: string = '', page: number | null = null
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
if (include_folders) {
|
||||
searchParams.append('include_folders', 'true');
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -319,6 +359,45 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChatListByFolderId = async (token: string, folderId: string, page: number = 1) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (page !== null) {
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/chats/folder/${folderId}/list?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getAllArchivedChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import type { Banner } from '$lib/types';
|
||||
|
||||
export const importConfig = async (token: string, config) => {
|
||||
@@ -202,6 +202,52 @@ export const verifyToolServerConnection = async (token: string, connection: obje
|
||||
return res;
|
||||
};
|
||||
|
||||
type RegisterOAuthClientForm = {
|
||||
url: string;
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
token: string,
|
||||
formData: RegisterOAuthClientForm,
|
||||
type: null | string = null
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = type ? `?type=${type}` : '';
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register${searchParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getOAuthClientAuthorizationUrl = (clientId: string, type: null | string = null) => {
|
||||
const oauthClientId = type ? `${type}:${clientId}` : clientId;
|
||||
return `${WEBUI_BASE_URL}/oauth/clients/${oauthClientId}/authorize`;
|
||||
};
|
||||
|
||||
export const getCodeExecutionConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
error = err.detail || err.message;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+56
-27
@@ -337,14 +337,8 @@ export const getToolServerData = async (token: string, url: string) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = {
|
||||
openapi: res,
|
||||
info: res.info,
|
||||
specs: convertOpenApiToToolPayload(res)
|
||||
};
|
||||
|
||||
console.log(data);
|
||||
return data;
|
||||
console.log(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getToolServersData = async (servers: object[]) => {
|
||||
@@ -354,18 +348,46 @@ export const getToolServersData = async (servers: object[]) => {
|
||||
.filter((server) => server?.config?.enable)
|
||||
.map(async (server) => {
|
||||
let error = null;
|
||||
const data = await getToolServerData(
|
||||
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
|
||||
(server?.path ?? '').includes('://')
|
||||
? server?.path
|
||||
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
|
||||
).catch((err) => {
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { openapi, info, specs } = data;
|
||||
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;
|
||||
}
|
||||
|
||||
let res = null;
|
||||
const specType = server?.spec_type ?? 'url';
|
||||
|
||||
if (specType === 'url') {
|
||||
res = await getToolServerData(
|
||||
toolServerToken,
|
||||
(server?.path ?? '').includes('://')
|
||||
? server?.path
|
||||
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
|
||||
).catch((err) => {
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
} else if ((specType === 'json' && server?.spec) ?? null) {
|
||||
try {
|
||||
res = JSON.parse(server?.spec);
|
||||
} catch (e) {
|
||||
error = 'Failed to parse JSON spec';
|
||||
}
|
||||
}
|
||||
|
||||
if (res) {
|
||||
const { openapi, info, specs } = {
|
||||
openapi: res,
|
||||
info: res.info,
|
||||
specs: convertOpenApiToToolPayload(res)
|
||||
};
|
||||
|
||||
return {
|
||||
url: server?.url,
|
||||
openapi: openapi,
|
||||
@@ -482,18 +504,25 @@ export const executeToolServer = async (
|
||||
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
|
||||
}
|
||||
|
||||
let responseData;
|
||||
try {
|
||||
responseData = await res.json();
|
||||
} catch (err) {
|
||||
responseData = await res.text();
|
||||
}
|
||||
// make a clone of res and extract headers
|
||||
const responseHeaders = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
return responseData;
|
||||
const text = await res.text();
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = JSON.parse(text);
|
||||
} catch {
|
||||
responseData = text;
|
||||
}
|
||||
return [responseData, responseHeaders];
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
console.error('API Request Error:', error);
|
||||
return { error };
|
||||
return [{ error }, null];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,34 @@ export const getModels = async (token: string = '') => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const importModels = async (token: string, models: object[]) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/models/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ models: models })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getBaseModels = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -372,6 +372,7 @@ export const generateOpenAIChatCompletion = async (
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user