Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f4c85bb1 | ||
|
|
340d9820b8 | ||
|
|
597709c64c | ||
|
|
84fd41e6ef | ||
|
|
3917b04ed3 | ||
|
|
b6ea043d18 | ||
|
|
b082a7edbe | ||
|
|
72df23ed79 | ||
|
|
11476eb6ee | ||
|
|
7096baff1b | ||
|
|
2885ed9648 | ||
|
|
ab877e1d7e | ||
|
|
b13a824047 | ||
|
|
9cbe2644b1 | ||
|
|
6c54ca552a | ||
|
|
2949be4f27 | ||
|
|
8e139b04f0 | ||
|
|
76fe344eb8 | ||
|
|
f3cae94028 | ||
|
|
7a1afa9c66 | ||
|
|
437b7c5171 | ||
|
|
5211602c2f | ||
|
|
6a5aac43df | ||
|
|
d6664369a1 | ||
|
|
bc7defbdd0 | ||
|
|
99e18d49dc | ||
|
|
1af128fb24 | ||
|
|
b8103807cf | ||
|
|
d4f67bb266 | ||
|
|
f3deb8b8de | ||
|
|
7753f57d42 | ||
|
|
26d069080a | ||
|
|
d802c718a4 | ||
|
|
ace2345a69 | ||
|
|
59324a82e0 | ||
|
|
e46e87889e | ||
|
|
6fcdd11fb2 | ||
|
|
047d8cec5d | ||
|
|
61d8d2f2cb | ||
|
|
51e05988bd | ||
|
|
b37907671e | ||
|
|
0e245c0afc | ||
|
|
b748d5edee | ||
|
|
debe3c752f | ||
|
|
ea578af45f | ||
|
|
8258dfb5af | ||
|
|
1909505466 | ||
|
|
f98ea78964 | ||
|
|
f1e38fa376 | ||
|
|
090234735a | ||
|
|
afca606063 | ||
|
|
0d473a456a | ||
|
|
4a2428123b | ||
|
|
ba6fc385bb | ||
|
|
4db0da2b75 | ||
|
|
5ab95cbc3a | ||
|
|
3b0da53457 | ||
|
|
5d90e31ab6 | ||
|
|
dc3055873b | ||
|
|
f5d250820b | ||
|
|
8892c14984 | ||
|
|
75883dc835 | ||
|
|
d1677635a5 | ||
|
|
400c4c3d12 | ||
|
|
a47e3b429e | ||
|
|
805e980ae5 | ||
|
|
b50cfd0940 | ||
|
|
53d044f8a2 | ||
|
|
0e95af01ea | ||
|
|
c5b48ec551 | ||
|
|
e0ffe234ba | ||
|
|
e0917e9f38 | ||
|
|
177f2902db | ||
|
|
423a35782b | ||
|
|
deaa7133a2 | ||
|
|
37658e06a2 | ||
|
|
1f7a93b0fb | ||
|
|
866c61bac6 | ||
|
|
c2b4b76a90 | ||
|
|
5a4a293a93 | ||
|
|
c060e70089 | ||
|
|
e34f11f5cc | ||
|
|
3e55c7f333 | ||
|
|
b8a4824305 | ||
|
|
2251a50451 | ||
|
|
62bfe73964 | ||
|
|
2eeffc8c59 | ||
|
|
6e8c6e269e | ||
|
|
14cfd4599c | ||
|
|
4fd7bfe19b | ||
|
|
9ccb9a87e6 | ||
|
|
53060a44ae | ||
|
|
4c7c5dd860 | ||
|
|
69be2a1bc9 | ||
|
|
42b8463fcc | ||
|
|
35df44d07e | ||
|
|
652e1a6b19 | ||
|
|
78fcfbb328 | ||
|
|
33a3aed698 | ||
|
|
d0a114869e | ||
|
|
7d8fb116b6 | ||
|
|
6f73561f7a | ||
|
|
a710ea1f07 | ||
|
|
b95bc727df | ||
|
|
ba9e9e8095 | ||
|
|
47c65bac6c | ||
|
|
85018dd106 | ||
|
|
669a9f0777 | ||
|
|
1160a03644 | ||
|
|
843810cc9f | ||
|
|
8b5f5bb327 | ||
|
|
15efb07961 | ||
|
|
a1034a4ca3 | ||
|
|
4978dd9085 | ||
|
|
0b8f86d27b | ||
|
|
6918eb44ac | ||
|
|
91af2698be | ||
|
|
4681b06206 | ||
|
|
aecbc93c16 | ||
|
|
0313ac0166 | ||
|
|
87a837f850 | ||
|
|
4bb41d3ead | ||
|
|
edf927516e | ||
|
|
190400d1e8 | ||
|
|
380a31fbac | ||
|
|
fced3efd98 | ||
|
|
d92d555c03 | ||
|
|
880323a4e4 | ||
|
|
8a2723a7a6 | ||
|
|
2ff889dce0 | ||
|
|
6c677e4b28 | ||
|
|
a98661735f | ||
|
|
b446bea6d1 | ||
|
|
6faa9db4a7 | ||
|
|
2b276583c3 | ||
|
|
80a5aa7259 | ||
|
|
9d76f30f9b | ||
|
|
09bbcee391 | ||
|
|
c2aa5b7399 | ||
|
|
9b9d5d84f4 | ||
|
|
389dbd1983 | ||
|
|
2f8c0b6b26 | ||
|
|
464c67eb46 | ||
|
|
4bbc32efa6 | ||
|
|
c27d8a4a28 | ||
|
|
371bdd7afa | ||
|
|
0d00971dad | ||
|
|
80c359c24e | ||
|
|
6076afd5f5 | ||
|
|
8e60174e0d | ||
|
|
ba591d8c41 | ||
|
|
387c2377ba | ||
|
|
809d91a3df | ||
|
|
99ff1684d0 | ||
|
|
9a1d2a1844 | ||
|
|
5dd99a0ea7 | ||
|
|
aee60add1d | ||
|
|
6af91325e2 | ||
|
|
a3f3f58e54 | ||
|
|
bc5f7e31ac | ||
|
|
c70a037692 | ||
|
|
bebbc24d27 | ||
|
|
4e61bdd109 | ||
|
|
88c8ea883c | ||
|
|
9123c57c8a | ||
|
|
af285297bc | ||
|
|
5c156c854b | ||
|
|
70333c4dcf | ||
|
|
1ca70b886b | ||
|
|
210dc746f0 | ||
|
|
63256136ef | ||
|
|
fd8cdb383c | ||
|
|
ab42a192e6 | ||
|
|
adab4dbf21 | ||
|
|
c14160d73a | ||
|
|
a7330d97da | ||
|
|
b39d33cefb | ||
|
|
d2e7feea86 | ||
|
|
a018140c7d | ||
|
|
9931ccef1e | ||
|
|
56b012e3a6 | ||
|
|
c9820d65a6 | ||
|
|
9c1c11cad0 | ||
|
|
8b5ecfb2fe | ||
|
|
3e81705dd3 | ||
|
|
5168d612dc | ||
|
|
e67ab489aa | ||
|
|
c11cc58b00 | ||
|
|
3288b82175 | ||
|
|
f67f86f06f | ||
|
|
c4cd2b55f0 | ||
|
|
bacf39902b | ||
|
|
aeb81767a9 | ||
|
|
884282c4ed | ||
|
|
f55579ce25 | ||
|
|
02f5bfba73 | ||
|
|
f29c9d8dcf | ||
|
|
4c65728443 | ||
|
|
b36b480b43 | ||
|
|
b8450ecf01 | ||
|
|
464b086ec5 | ||
|
|
fc83842ab2 | ||
|
|
7b248fc1f4 | ||
|
|
a28bec865b | ||
|
|
b7a91b1963 | ||
|
|
2ccc441b41 | ||
|
|
106494aa79 | ||
|
|
0b336fb6f1 | ||
|
|
8f68b25510 | ||
|
|
7fd5304e76 | ||
|
|
af0f2a678e | ||
|
|
ad80643774 | ||
|
|
6e11bf73a7 | ||
|
|
ce45939463 | ||
|
|
d430fe9551 | ||
|
|
7f488b3754 | ||
|
|
6d4f449085 | ||
|
|
7953aa5763 | ||
|
|
95f54f5778 | ||
|
|
51fc98bed8 | ||
|
|
ea8dc333ee | ||
|
|
5447ac2de6 | ||
|
|
5fae7360fb | ||
|
|
db3c26ab7a | ||
|
|
8da1ce358f | ||
|
|
ce9455b9a2 | ||
|
|
36e5e51510 | ||
|
|
7f39097879 | ||
|
|
9409083d0b | ||
|
|
2573882b88 | ||
|
|
d1be1de013 | ||
|
|
7c03601425 | ||
|
|
053ab2838b | ||
|
|
602bb55434 | ||
|
|
19c09d216d | ||
|
|
7a11fd2a4d | ||
|
|
c04569fdd4 | ||
|
|
b0da2c998b | ||
|
|
86551de1f7 | ||
|
|
2f338b6ae7 | ||
|
|
a64667ca8d | ||
|
|
3448362e00 | ||
|
|
9cbe39718d | ||
|
|
7969d8e8f3 | ||
|
|
c59b4cf379 | ||
|
|
5e91c2c1fe | ||
|
|
b66d86cb74 | ||
|
|
ec9e3ef2c3 | ||
|
|
a3ed5045d3 | ||
|
|
7f75acff96 | ||
|
|
a944e1f526 | ||
|
|
3a0696e1b0 | ||
|
|
d8d8380a78 | ||
|
|
0c57980e72 | ||
|
|
51fe33395b | ||
|
|
4fe45d4430 | ||
|
|
87e5aee106 | ||
|
|
0cd400f5ee | ||
|
|
b9c64d0936 | ||
|
|
6cb519ca0e | ||
|
|
6bf393a480 | ||
|
|
ac4513a773 | ||
|
|
dae4a7088e | ||
|
|
50d9a2ac58 | ||
|
|
0b1c81791e | ||
|
|
99f05561f8 | ||
|
|
5e73fd569d | ||
|
|
7010393a41 | ||
|
|
41220b379f | ||
|
|
b0965a8184 | ||
|
|
76d1690a0c | ||
|
|
d269c4b8e6 | ||
|
|
2b9c466bed | ||
|
|
1facc4485a | ||
|
|
de3e474224 | ||
|
|
cc563d9763 | ||
|
|
ffe7bdd213 | ||
|
|
6860dec08f | ||
|
|
61f49ff580 | ||
|
|
41e28a9f6f | ||
|
|
168997a58e | ||
|
|
a6c7ddcb28 | ||
|
|
67e930e1d2 | ||
|
|
92c7549b06 | ||
|
|
e97394958e | ||
|
|
d600e60776 | ||
|
|
fd4c42b42e | ||
|
|
da5fbc0e39 | ||
|
|
1ad4f7a244 | ||
|
|
c0f65ec28a | ||
|
|
6fd91e58cf | ||
|
|
b433d30c26 | ||
|
|
1ff7cb7d2f | ||
|
|
9c38183689 | ||
|
|
fd97882fca | ||
|
|
2b4d4d2770 | ||
|
|
dc60bd2816 | ||
|
|
0b84b22b4e | ||
|
|
870c23f69d | ||
|
|
12cfb7b0f0 | ||
|
|
37f1d47a06 | ||
|
|
14a890a0a5 | ||
|
|
f155abc63c | ||
|
|
f6f3360ef7 | ||
|
|
99fe23ef66 | ||
|
|
92c5773682 | ||
|
|
3c5ae1f62e | ||
|
|
543ed432b1 | ||
|
|
a8b61204b4 | ||
|
|
ec0124b521 | ||
|
|
07b2ecf6aa | ||
|
|
e7b86cc852 | ||
|
|
eb3c080cce | ||
|
|
b3e328ed04 | ||
|
|
6c8202d87d | ||
|
|
936c3efe5e | ||
|
|
0fe82d119f | ||
|
|
5e35aab292 | ||
|
|
f17bfb3adb | ||
|
|
00e4391a6f | ||
|
|
93236cead0 | ||
|
|
ed1db62b12 | ||
|
|
ab36b8aeae | ||
|
|
b02a3da4da | ||
|
|
7c4f261aa2 | ||
|
|
14e158fde9 | ||
|
|
f50755ccd9 | ||
|
|
04da2b05f6 | ||
|
|
9772c18b20 | ||
|
|
cc12e9e1a3 | ||
|
|
3ddebefca2 | ||
|
|
8f6c3f46d6 | ||
|
|
e0769c6a1f | ||
|
|
415114504f | ||
|
|
94356dc3ca | ||
|
|
7f04045697 | ||
|
|
0b3719cc65 | ||
|
|
c71236ba07 | ||
|
|
c4278f4784 | ||
|
|
bbafeca495 | ||
|
|
dcb71c2292 | ||
|
|
91e826cc27 | ||
|
|
9964ad0a5b | ||
|
|
8644e81a1c | ||
|
|
7ef2345ba6 | ||
|
|
00b16cbaf2 | ||
|
|
2b926a373c | ||
|
|
5df4f98fc3 | ||
|
|
717d58116a | ||
|
|
3e02618278 | ||
|
|
5fb5daf07f | ||
|
|
059327cd19 | ||
|
|
fd53c737f6 | ||
|
|
dc05ccb73f | ||
|
|
aeafcc7903 | ||
|
|
86111ad44a | ||
|
|
ec81e98fa2 | ||
|
|
fe69189dc9 | ||
|
|
5b4f81ce11 | ||
|
|
6ebf522a27 | ||
|
|
2a6d2392a0 | ||
|
|
25157ca6ba | ||
|
|
c01e29c47f | ||
|
|
bb689b9480 | ||
|
|
db836c53f6 | ||
|
|
7ca35068a6 | ||
|
|
bbedc50f00 | ||
|
|
d2b5a1614a | ||
|
|
abae0a837f | ||
|
|
4eef6f432f | ||
|
|
0a1ff4a8fa | ||
|
|
931e5ffa6e | ||
|
|
185249623b | ||
|
|
ca894df2bd | ||
|
|
9e49fbc8bf | ||
|
|
f8b941fb96 | ||
|
|
2fee89dfa6 | ||
|
|
4d364e2967 | ||
|
|
8bdefa5481 | ||
|
|
34a8b95cc3 | ||
|
|
e353f15a3c | ||
|
|
77b357c73b | ||
|
|
96e9bfe0e5 | ||
|
|
3c32d2cada | ||
|
|
3da1802eec | ||
|
|
0ebe35c571 | ||
|
|
29d6eb642f | ||
|
|
7536a94585 | ||
|
|
89dcce0df2 | ||
|
|
6e2bffee69 | ||
|
|
84c06aa582 | ||
|
|
281b74f4b4 | ||
|
|
816e35bd79 | ||
|
|
0469120709 | ||
|
|
bf3897ec40 | ||
|
|
d649db4ef7 | ||
|
|
28db03e4f9 | ||
|
|
26549244fc | ||
|
|
3ccd2364ba | ||
|
|
1131a61b8e | ||
|
|
2f3d4622db | ||
|
|
96d8a52fe3 | ||
|
|
1ddc784af9 | ||
|
|
185224e4e1 | ||
|
|
2c15f8e676 | ||
|
|
4e82c44f3e | ||
|
|
e41e375aab | ||
|
|
343dac91c9 | ||
|
|
cf3635ba25 | ||
|
|
66bde32623 | ||
|
|
cbc514e681 | ||
|
|
4ecf2a8685 | ||
|
|
5555d889d5 | ||
|
|
7dde16d0ac | ||
|
|
53764fe648 | ||
|
|
235489cfc5 | ||
|
|
b2befd486f | ||
|
|
9306ae5972 | ||
|
|
036ce12dd9 | ||
|
|
d6c3f93cfd | ||
|
|
b12a493fe5 | ||
|
|
f4827f0c18 | ||
|
|
e1e2c096e2 | ||
|
|
ff353578db | ||
|
|
be989f3645 | ||
|
|
4c45d67677 | ||
|
|
59768e34f4 | ||
|
|
4371d2c5a5 | ||
|
|
9be22cb637 | ||
|
|
f95f51530b | ||
|
|
c3e8e1cbf1 | ||
|
|
2327c32a74 | ||
|
|
a214e63cab | ||
|
|
f1507f2458 | ||
|
|
255367934b | ||
|
|
bd4e010c76 | ||
|
|
155518e788 | ||
|
|
d43bbcae28 | ||
|
|
309380b098 | ||
|
|
2c723e1f28 | ||
|
|
01eedd36bc | ||
|
|
ba0088f39b | ||
|
|
661625f362 | ||
|
|
30b7ab3591 | ||
|
|
7dc7d5c028 | ||
|
|
2c31f5c725 | ||
|
|
a6864db8ec | ||
|
|
21f85e63bf | ||
|
|
9220afe7b3 | ||
|
|
bb4115fa0e | ||
|
|
551597b9cc | ||
|
|
cb4299eb98 | ||
|
|
042c37ea34 | ||
|
|
85a384fab5 | ||
|
|
4461122a0e | ||
|
|
24be96a6d4 | ||
|
|
c9f983f644 | ||
|
|
cc6cbf53e8 | ||
|
|
4279762ea4 | ||
|
|
efedb7ab1f | ||
|
|
32135a29bb | ||
|
|
8a74bdce37 | ||
|
|
3d9a430927 | ||
|
|
a27a095884 | ||
|
|
596298fa46 | ||
|
|
bf7a18a0f8 | ||
|
|
d81886e315 | ||
|
|
7effb04782 | ||
|
|
f5fefb49d5 | ||
|
|
e4a53e0a3c | ||
|
|
e861ba5520 | ||
|
|
100a764293 | ||
|
|
1d216b82ba | ||
|
|
9eccce2444 | ||
|
|
27de981246 | ||
|
|
40bea00e3d | ||
|
|
8609471eeb | ||
|
|
cb135c370a | ||
|
|
8cf578d9fd | ||
|
|
1aac41d6c3 | ||
|
|
20403c9df7 | ||
|
|
b944acd3ff | ||
|
|
1ec9e42c1e | ||
|
|
5989e9bf75 | ||
|
|
75b2e4a659 | ||
|
|
14c3d0c2d1 | ||
|
|
737dc7797c | ||
|
|
eb14bfbcdf | ||
|
|
4995bfe571 | ||
|
|
098ec43413 | ||
|
|
fcb03df9ce | ||
|
|
19bb3589ee | ||
|
|
e12a79c0e2 | ||
|
|
a9405cc101 | ||
|
|
7988e0c6e2 | ||
|
|
14bf579e9c | ||
|
|
fc2202ad76 | ||
|
|
a2afd6f645 | ||
|
|
b8e16211b9 | ||
|
|
efb54aa2e4 | ||
|
|
9faa4c6a4c | ||
|
|
5c74e56bd0 | ||
|
|
8609fe7ab4 | ||
|
|
1cb8fa0f03 | ||
|
|
a681d5c60e | ||
|
|
256034e285 | ||
|
|
d414662d23 | ||
|
|
940a437631 | ||
|
|
aaff204e7b | ||
|
|
e68a3d28b5 | ||
|
|
090619c27c | ||
|
|
9c120affb6 | ||
|
|
2c7ccc69fe | ||
|
|
a38e44e870 | ||
|
|
20f54616e7 | ||
|
|
b4caad928e | ||
|
|
7d756982c2 | ||
|
|
6062174602 | ||
|
|
9a5febc2d1 | ||
|
|
2d5b82df8c | ||
|
|
ffa51ece0c | ||
|
|
a0d034fa7c | ||
|
|
fc5dfd3536 | ||
|
|
5d7c89964c | ||
|
|
caa5ad44d4 | ||
|
|
040f29d058 | ||
|
|
bb62bd9c1f | ||
|
|
b9f74a66b2 | ||
|
|
254dd4246e | ||
|
|
4da75a9e78 | ||
|
|
4483fa95b8 | ||
|
|
c157e74f0c | ||
|
|
ada9655f33 | ||
|
|
9de92687f6 | ||
|
|
31dbafd62e | ||
|
|
0dc29a220f | ||
|
|
7db9f7b8b6 | ||
|
|
dc50fa3744 | ||
|
|
8ef7938c96 | ||
|
|
332043c38b | ||
|
|
49fe137553 | ||
|
|
6a93b54e8e | ||
|
|
8598fc9608 | ||
|
|
5885d8e275 | ||
|
|
f9d374810d | ||
|
|
358ad8ef20 | ||
|
|
33f2fdb91e | ||
|
|
e65ac97c45 | ||
|
|
b2b1a21299 | ||
|
|
75208935d7 | ||
|
|
44e7e09784 | ||
|
|
1d53754ea7 | ||
|
|
6e8ca96799 | ||
|
|
5db7d8acd1 | ||
|
|
0fe00ab2c3 | ||
|
|
9b000d82ec | ||
|
|
7e6f1f8848 | ||
|
|
31e2686ae6 | ||
|
|
cce5f024bd | ||
|
|
17263a294f | ||
|
|
6dbab870e5 | ||
|
|
9598532270 | ||
|
|
e31a7bcc4c | ||
|
|
261328077d | ||
|
|
aebbc2593b | ||
|
|
8261f0e17e | ||
|
|
37d2aed26e | ||
|
|
c377ff80c9 | ||
|
|
9bd031dae3 | ||
|
|
bc7853d6a4 | ||
|
|
adefc811c7 | ||
|
|
3c828f7828 | ||
|
|
da75d0ca1e | ||
|
|
51ab02f3af | ||
|
|
29d08c2a16 | ||
|
|
abcf59ab2b | ||
|
|
ffa5db4fec | ||
|
|
f95e52a6a6 | ||
|
|
17aca908c5 | ||
|
|
e663b90a9f | ||
|
|
bf97dbe052 | ||
|
|
80eaa24ca8 | ||
|
|
8b5e89eada | ||
|
|
a46765fd0e | ||
|
|
baaa285534 | ||
|
|
a90d3f3268 | ||
|
|
e70dd33233 | ||
|
|
9946dc7b5f | ||
|
|
88a296b989 | ||
|
|
bf12946ac9 | ||
|
|
99f88082ca | ||
|
|
6636207e0c | ||
|
|
c8f1bdf928 | ||
|
|
3f2025dc6e | ||
|
|
549237fb05 | ||
|
|
797c7d4e56 | ||
|
|
bf193dfb5d | ||
|
|
30d15c1b4b | ||
|
|
b9afef85c4 | ||
|
|
ed0bebc7ef | ||
|
|
f75ac5cc01 | ||
|
|
1cf21d3fa2 | ||
|
|
19b69fcb66 | ||
|
|
e15ff8d9f6 | ||
|
|
0e6f09a0a9 | ||
|
|
a50a8e2ef9 | ||
|
|
65d997a6c5 | ||
|
|
62fbd6c91c | ||
|
|
03288d9fca | ||
|
|
06aa59e2a5 | ||
|
|
02000b1a6c | ||
|
|
dd8e742c2d | ||
|
|
55a6fae9db | ||
|
|
bdcd04a78c | ||
|
|
e10d6ad79c | ||
|
|
9c4ce0273f | ||
|
|
3d2a26fe0b | ||
|
|
aa6648ba8a | ||
|
|
ec97fdf5d6 | ||
|
|
4025c96d0f | ||
|
|
ed9a2c295c | ||
|
|
28e44452dd | ||
|
|
1d382b83c8 | ||
|
|
a2f12db8d9 | ||
|
|
7071a03419 | ||
|
|
7a593b63b2 | ||
|
|
4c55ad71a7 | ||
|
|
1f632d3570 | ||
|
|
2eca6f6414 | ||
|
|
0b7f927983 | ||
|
|
a68ec8822a | ||
|
|
aac25eac9e | ||
|
|
f2d2cae8fb | ||
|
|
71288ea3f2 | ||
|
|
cbe84d7139 | ||
|
|
d4c3cfbaba | ||
|
|
336d0b45ad | ||
|
|
da4aa5f08b | ||
|
|
7489bc6126 | ||
|
|
4519e4eed1 | ||
|
|
787987d2f0 | ||
|
|
17ed331a0a | ||
|
|
b339fd6fed | ||
|
|
0eda03bd3c | ||
|
|
a935e375f3 | ||
|
|
dd6124a84f | ||
|
|
558b2b1016 | ||
|
|
86e24bb4aa | ||
|
|
ff46fa5b33 | ||
|
|
bd40cceb2d | ||
|
|
9201fd4652 | ||
|
|
65a44a3900 | ||
|
|
d2e426990b | ||
|
|
ed0538e643 | ||
|
|
e3e7eb96f6 | ||
|
|
a45f2cd0a9 | ||
|
|
6121d4696a | ||
|
|
b78354100f | ||
|
|
512345f40e | ||
|
|
12896fb728 | ||
|
|
8b80555adf | ||
|
|
4e4b5ab83b | ||
|
|
03eeb75bfe | ||
|
|
74ace200fe | ||
|
|
2b5c015bcb | ||
|
|
82716f3789 | ||
|
|
a8a9b35153 | ||
|
|
d3c7628092 | ||
|
|
86f1aee20b | ||
|
|
f9a66afe76 | ||
|
|
fac5884d8c | ||
|
|
6035664048 | ||
|
|
75a31e116a | ||
|
|
78befd5a2f | ||
|
|
b735042ecc | ||
|
|
308d8ac04a | ||
|
|
b5ddaf6417 | ||
|
|
8d94a59f27 | ||
|
|
479b5ad820 | ||
|
|
f408b08965 | ||
|
|
42be1f956a | ||
|
|
ab9486603c | ||
|
|
58b309149a | ||
|
|
83ed2d0661 | ||
|
|
4b538047dd | ||
|
|
fd2ff5e777 | ||
|
|
897727c7eb | ||
|
|
d6ad96affb | ||
|
|
5613cda5cf | ||
|
|
ed0659aca6 | ||
|
|
fd91b277a4 | ||
|
|
d2758c7fa2 | ||
|
|
ef3232a24f | ||
|
|
438eb90671 | ||
|
|
c9bbfaee9d | ||
|
|
1d0921de0d | ||
|
|
c1066d7e08 | ||
|
|
23991c32c5 | ||
|
|
e6afa69f59 | ||
|
|
2b1c3b5208 | ||
|
|
b905b73935 | ||
|
|
fa5cd59ef8 | ||
|
|
030c20feff | ||
|
|
8fb7b980a2 | ||
|
|
eafb8e014a | ||
|
|
2ab5aa4d34 | ||
|
|
5cb1475e29 | ||
|
|
2f2a7ee0b0 | ||
|
|
2e56b1f13d | ||
|
|
caeb822cdc | ||
|
|
47f8b3500b | ||
|
|
73e64fe7fb | ||
|
|
aee9d05916 | ||
|
|
3af289d196 | ||
|
|
9f9fb3403d | ||
|
|
c6580a2a02 | ||
|
|
417ba4980a | ||
|
|
0c6f22b20b | ||
|
|
96c7a0ac3c | ||
|
|
e895c33baa | ||
|
|
fbfd3ff134 | ||
|
|
5a40feea1c | ||
|
|
1f5860c4ab | ||
|
|
d069d1264d | ||
|
|
4f4811adb1 | ||
|
|
05b79ce221 | ||
|
|
18d6311951 | ||
|
|
8f4104fb7a | ||
|
|
349b0ca0b7 | ||
|
|
2dde4102ad | ||
|
|
eabdd4a140 | ||
|
|
4667328d40 | ||
|
|
56740ab8d6 | ||
|
|
95badda1c2 | ||
|
|
ebf2e4141e | ||
|
|
a8813c1a69 | ||
|
|
fbcc80485b | ||
|
|
765acc4cad | ||
|
|
9801a2d23f | ||
|
|
424f6f71cf | ||
|
|
b280f828b0 | ||
|
|
d54e588ec3 | ||
|
|
08e4c163ea | ||
|
|
000906cd99 | ||
|
|
c3acb30bb1 | ||
|
|
df853246f3 | ||
|
|
6692fb2181 | ||
|
|
b14398481d | ||
|
|
7df6d7f325 | ||
|
|
fcb93f3155 | ||
|
|
02e5bbc903 | ||
|
|
359bcb837d | ||
|
|
ecae944fd0 | ||
|
|
05f284d4a6 | ||
|
|
ea723374c7 | ||
|
|
1f38350128 | ||
|
|
e37433f2b1 | ||
|
|
b61efcf54a | ||
|
|
32b386e8d0 | ||
|
|
7bbeaec93b | ||
|
|
34ef249a10 | ||
|
|
363ac35c09 | ||
|
|
50bc6d9b12 | ||
|
|
27bb5f5369 | ||
|
|
54dc24986f | ||
|
|
07b5e84221 | ||
|
|
2bd7db12a2 | ||
|
|
528a2cf96a | ||
|
|
2aa5e73728 | ||
|
|
dec9dd1ac0 | ||
|
|
bb06df80a2 | ||
|
|
0b761776c4 | ||
|
|
2450f2c5d8 | ||
|
|
62180b15e1 | ||
|
|
c201b4d689 | ||
|
|
bc66755fb0 | ||
|
|
e74c06d2f5 | ||
|
|
53dcaa63d4 | ||
|
|
186a89bcba | ||
|
|
351f0186b9 | ||
|
|
21e96247a9 | ||
|
|
84e0605835 | ||
|
|
20c1c3f66b | ||
|
|
2a4fe1335e | ||
|
|
e24e22debe | ||
|
|
208b86ae53 | ||
|
|
e4179aa114 | ||
|
|
df5ef8e553 | ||
|
|
9b93b81f84 | ||
|
|
a7c90925ef | ||
|
|
ca2cfb5bad | ||
|
|
418ac1a8da | ||
|
|
db24af6356 | ||
|
|
72b2555953 | ||
|
|
485bd7666c | ||
|
|
6eb772c741 | ||
|
|
184d8dfd7e | ||
|
|
0a8cecfbfa | ||
|
|
b143c71da2 | ||
|
|
04287eb6d6 | ||
|
|
39b06dc3b6 | ||
|
|
e358a66fb1 | ||
|
|
a6624a4b16 | ||
|
|
7e241a3904 | ||
|
|
42382b5167 | ||
|
|
8732b64b6b | ||
|
|
32ea31144e | ||
|
|
b80f0751f6 | ||
|
|
0280ee30c7 | ||
|
|
8fe4f789d4 | ||
|
|
dacf7e015d | ||
|
|
ac83568194 | ||
|
|
10108fcd04 | ||
|
|
74ccec2812 | ||
|
|
29ae231ee0 | ||
|
|
de70d0cb64 | ||
|
|
3974e53cc3 | ||
|
|
92140118f2 | ||
|
|
e9eb661b83 | ||
|
|
bdb3778f8b | ||
|
|
6e8481e157 | ||
|
|
143cb4cc34 | ||
|
|
2217fc4fd9 | ||
|
|
8029bf0dca | ||
|
|
6b6ce36c96 | ||
|
|
6f869ded43 | ||
|
|
4959e91fa7 | ||
|
|
f77a076c16 | ||
|
|
8b194bc781 | ||
|
|
bd7d2978f1 | ||
|
|
c7e3ae9f39 | ||
|
|
92a8ea743a | ||
|
|
3c4accaeb3 | ||
|
|
a9c22bffc9 | ||
|
|
d89e37cc7c | ||
|
|
8eacd26573 | ||
|
|
90bcf45b7b | ||
|
|
bbb4e75df8 | ||
|
|
b052b5cced | ||
|
|
5db783d67b | ||
|
|
43d0db5eea | ||
|
|
41f64da0d6 | ||
|
|
5cd0b99ae0 | ||
|
|
571d078a82 | ||
|
|
f72fa9b269 | ||
|
|
f63e76e653 | ||
|
|
66d73cb1de | ||
|
|
950f3cdcc0 | ||
|
|
f907e7c979 | ||
|
|
24c1b8911c | ||
|
|
b41fc1d2f4 | ||
|
|
dfd4037132 | ||
|
|
a904f874ee | ||
|
|
fb2950201a | ||
|
|
e9b799676b | ||
|
|
c7f743ddb7 | ||
|
|
16a9210321 | ||
|
|
c049837a11 | ||
|
|
ca34ba318e | ||
|
|
0d8e24320e | ||
|
|
b7ef9a03ac | ||
|
|
4ce1e88750 | ||
|
|
f32b042a00 | ||
|
|
792beb095e | ||
|
|
4c06683e95 | ||
|
|
0cef844168 | ||
|
|
c56d6963b0 | ||
|
|
92e2cfdd2f | ||
|
|
3ad5a7d97c | ||
|
|
867fddb821 | ||
|
|
de69c08a1d | ||
|
|
30cc51ec82 | ||
|
|
ba69c751ca | ||
|
|
be85f7cc35 | ||
|
|
4191ec692e | ||
|
|
6b5f99bf66 | ||
|
|
ef301aa16b | ||
|
|
32b27749ef | ||
|
|
b81a5833c7 | ||
|
|
6f635d8b7d | ||
|
|
549989e9ec | ||
|
|
1c665592e4 | ||
|
|
c61790b355 | ||
|
|
3a79840a87 | ||
|
|
a515a5df1a | ||
|
|
ba72d4625f | ||
|
|
3dc34c2402 | ||
|
|
be912f1529 | ||
|
|
d5fd3b3600 | ||
|
|
34ec10a78c | ||
|
|
015ac2f532 | ||
|
|
030b6b26af | ||
|
|
6d1c18e118 | ||
|
|
54dda08d39 | ||
|
|
7bfe4c6a43 | ||
|
|
cc1dc6cb78 | ||
|
|
3f58a17e47 | ||
|
|
3e1be8b958 | ||
|
|
d704b8b686 | ||
|
|
12c2138982 | ||
|
|
337ece4d50 | ||
|
|
1299c11650 | ||
|
|
628a10e34a | ||
|
|
513895c2d5 | ||
|
|
ccbc46fbbe | ||
|
|
8acc1ab425 | ||
|
|
616e9e2760 | ||
|
|
c95a65a4bd | ||
|
|
220d35ebcb | ||
|
|
947f5e840e | ||
|
|
ebf5cb22ab | ||
|
|
d84a3c0bf4 | ||
|
|
9143716463 | ||
|
|
13e7a3dee9 | ||
|
|
9657474a8d | ||
|
|
b38711a581 | ||
|
|
04b9065f08 | ||
|
|
827326e1a2 | ||
|
|
bfa5550cc3 | ||
|
|
e60fa7927d | ||
|
|
1fea4f794f | ||
|
|
7c8597765f | ||
|
|
08b6ea0e17 | ||
|
|
68f5ff540c | ||
|
|
9509528deb | ||
|
|
4ca0ea1e55 | ||
|
|
3886dee1ce | ||
|
|
305435b4ef | ||
|
|
4abe2cfa7b | ||
|
|
a6d6c928d9 | ||
|
|
af518cacbf | ||
|
|
54e4c1e0cc | ||
|
|
f6b20ca109 | ||
|
|
994f17b430 | ||
|
|
a655bb5a63 | ||
|
|
cf5201aada | ||
|
|
2df9f7fb4d | ||
|
|
731251d11a | ||
|
|
2b8be86f23 | ||
|
|
5e46c27806 | ||
|
|
b34401a087 | ||
|
|
61a9d9927d | ||
|
|
f761d2cf90 | ||
|
|
0f40cb0de4 | ||
|
|
b50dcb1862 | ||
|
|
b9f55a44b7 | ||
|
|
f1bb43707e | ||
|
|
519bb64238 | ||
|
|
410af53eca | ||
|
|
c2de7efa2f | ||
|
|
6424c8b644 | ||
|
|
bd2021e8e1 | ||
|
|
ccc64ac6b7 | ||
|
|
7620a6bf8c | ||
|
|
54b30e0b7f | ||
|
|
f56983632f | ||
|
|
3a553f7208 | ||
|
|
571e4c9600 | ||
|
|
a38f3c7617 | ||
|
|
b1fb298bee | ||
|
|
176a7fc986 | ||
|
|
efd50c3c5a | ||
|
|
263086aa91 | ||
|
|
a3bb7df610 | ||
|
|
3a6a05cef1 | ||
|
|
d62353c140 | ||
|
|
803b39b00c | ||
|
|
ed5de96d1d | ||
|
|
c667d27c38 | ||
|
|
e088f1ef23 | ||
|
|
ad1486c8bd | ||
|
|
6359cb55fe | ||
|
|
96618967bd | ||
|
|
19bf7289c1 | ||
|
|
369f461691 | ||
|
|
9c760ace80 | ||
|
|
45e9d05240 | ||
|
|
ace3303ff5 | ||
|
|
62e57a4cf5 | ||
|
|
2a4dfc02a2 | ||
|
|
e0e151f4f4 | ||
|
|
ea07e242f5 | ||
|
|
53f2ab1676 | ||
|
|
500f4d73e1 | ||
|
|
6c957f2407 | ||
|
|
1dcbec71ec | ||
|
|
87dcbd198c | ||
|
|
d7927506f1 | ||
|
|
f65dc715f9 | ||
|
|
c69278c13c | ||
|
|
a129e0954e | ||
|
|
5e1cb76b93 | ||
|
|
8e14372dd8 | ||
|
|
9d8d4393a6 | ||
|
|
c4a3a9c616 | ||
|
|
8c6f4f6579 | ||
|
|
86ce111edd | ||
|
|
0889dd3a51 | ||
|
|
de182ddec2 | ||
|
|
bfdcc12cdc | ||
|
|
ed210517c2 | ||
|
|
7b5247810c | ||
|
|
9cc00afc6b | ||
|
|
5c055b73a3 | ||
|
|
853adad894 | ||
|
|
5aabe21cbe | ||
|
|
e63b8b3879 | ||
|
|
0a8199a056 | ||
|
|
7b36466c1c | ||
|
|
27da31dc83 | ||
|
|
c36acd46cc | ||
|
|
413297b5c7 | ||
|
|
f94965909c | ||
|
|
982c84c8ac | ||
|
|
1f45b9df2e | ||
|
|
3dd2e80dec | ||
|
|
67a612fe24 | ||
|
|
791dd24ace | ||
|
|
9cf3381381 | ||
|
|
b0d74a59f1 | ||
|
|
1a30b3746e | ||
|
|
0a3817ed86 | ||
|
|
0a845db8ec | ||
|
|
7680ac2517 | ||
|
|
b3eb3b3c05 | ||
|
|
d5e866d839 | ||
|
|
db0a0b395f | ||
|
|
8f31b6d74a | ||
|
|
0afe972bc6 |
@@ -7,6 +7,15 @@ OPENAI_API_KEY=''
|
||||
|
||||
# AUTOMATIC1111_BASE_URL="http://localhost:7860"
|
||||
|
||||
# For production, you should only need one host as
|
||||
# fastapi serves the svelte-kit built frontend and backend from the same host and port.
|
||||
# To test with CORS locally, you can set something like
|
||||
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
|
||||
CORS_ALLOW_ORIGIN='*'
|
||||
|
||||
# For production you should set this to match the proxy configuration (127.0.0.1)
|
||||
FORWARDED_ALLOW_IPS='*'
|
||||
|
||||
# DO NOT TRACK
|
||||
SCARF_NO_ANALYTICS=true
|
||||
DO_NOT_TRACK=true
|
||||
|
||||
+49
-1
@@ -1 +1,49 @@
|
||||
*.sh text eol=lf
|
||||
# TypeScript
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
|
||||
# JavaScript
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
|
||||
# Svelte
|
||||
*.svelte text eol=lf
|
||||
|
||||
# HTML/CSS
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.less text eol=lf
|
||||
|
||||
# Config files and JSON
|
||||
*.json text eol=lf
|
||||
*.jsonc text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
|
||||
# Shell scripts
|
||||
*.sh text eol=lf
|
||||
|
||||
# Markdown & docs
|
||||
*.md text eol=lf
|
||||
*.mdx text eol=lf
|
||||
*.txt text eol=lf
|
||||
|
||||
# Git-related
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
|
||||
# Prettier and other dotfiles
|
||||
.prettierrc text eol=lf
|
||||
.prettierignore text eol=lf
|
||||
.eslintrc text eol=lf
|
||||
.eslintignore text eol=lf
|
||||
.stylelintrc text eol=lf
|
||||
.editorconfig text eol=lf
|
||||
|
||||
# Misc
|
||||
*.env text eol=lf
|
||||
*.lock text eol=lf
|
||||
@@ -89,9 +89,20 @@ body:
|
||||
required: true
|
||||
- label: I have included the Docker container logs.
|
||||
required: true
|
||||
- label: I have listed steps to reproduce the bug in detail.
|
||||
- label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
|
||||
required: true
|
||||
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
|
||||
required: true
|
||||
- label: |
|
||||
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
|
||||
- Start with the initial platform/version/OS and dependencies used,
|
||||
- Specify exact install/launch/configure commands,
|
||||
- List URLs visited, user input (incl. example values/emails/passwords if needed),
|
||||
- Describe all options and toggles enabled or changed,
|
||||
- Include any files or environmental changes,
|
||||
- Identify the expected and actual result at each stage,
|
||||
- Ensure any reasonably skilled user can follow and hit the same issue.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
@@ -112,15 +123,25 @@ body:
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Providing clear, step-by-step instructions helps us reproduce and fix the issue faster. If we can't reproduce it, we can't fix it.
|
||||
description: |
|
||||
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
|
||||
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
|
||||
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See the error message '...'
|
||||
Example (include every detail):
|
||||
1. Start with a clean Ubuntu 22.04 install.
|
||||
2. Install Docker v24.0.5 and start the service.
|
||||
3. Clone the Open WebUI repo (git clone ...).
|
||||
4. Use the Docker Compose file without modifications.
|
||||
5. Open browser Chrome 115.0 in incognito mode.
|
||||
6. Go to http://localhost:8080 and log in with user "test@example.com".
|
||||
7. Set the language to "English" and theme to "Dark".
|
||||
8. Attempt to connect to Ollama at "http://localhost:11434".
|
||||
9. Observe that the error message "Connection refused" appears at the top right.
|
||||
|
||||
Please list each step carefully and include all relevant configuration, settings, and options.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs-screenshots
|
||||
attributes:
|
||||
|
||||
@@ -14,16 +14,18 @@ env:
|
||||
|
||||
jobs:
|
||||
build-main-image:
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
@@ -111,16 +113,18 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build-cuda-image:
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
@@ -210,17 +214,122 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-ollama-image:
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
build-cuda126-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (cuda126 tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda126,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-cuda126-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (cuda126)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
USE_CUDA_VER=cu126
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-cuda126-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-ollama-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
@@ -420,6 +529,62 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-cuda126-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-cuda126-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: digests-cuda126-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda126,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-ollama-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-ollama-image]
|
||||
|
||||
@@ -17,6 +17,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Git
|
||||
run: sudo apt-get update && sudo apt-get install -y git
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
+2
-1
@@ -5,5 +5,6 @@
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
|
||||
+258
@@ -5,6 +5,264 @@ 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.15] - 2025-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- 🖼️ **Global Image Compression Option**: Effortlessly set image compression globally so all image uploads and outputs are optimized, speeding up load times and saving bandwidth—perfect for teams dealing with large files or limited network resources.
|
||||
- 🎤 **Custom Speech-to-Text Content-Type for Transcription**: Define custom content types for audio transcription, ensuring compatibility with diverse audio sources and unlocking smoother, more accurate transcriptions in advanced setups.
|
||||
- 🗂️ **LDAP Group Synchronization (Experimental)**: Automatically sync user groups from your LDAP directory directly into Open WebUI for seamless enterprise access management—simplifies identity integration and governance across your organization.
|
||||
- 📈 **OpenTelemetry Metrics via OTLP Exporter (Experimental)**: Gain enterprise-grade analytics and monitor your AI usage in real time with experimental OpenTelemetry Metrics support—connect to any OTLP-compatible backend for instant insights into performance, load, and user interactions.
|
||||
- 🕰️ **See User Message Timestamps on Hover (Chat Bubble UI)**: Effortlessly check when any user message was sent by hovering over it in Chat Bubble mode—no more switching screens or digging through logs for context.
|
||||
- 🗂️ **Leaderboard Sorting Options**: Sort the leaderboard directly in the UI for a clearer, more actionable view of top performers, models, or tools—making analysis and recognition quick and easy for teams.
|
||||
- 🏆 **Evaluation Details Modal in Feedbacks and Leaderboard**: Dive deeper with new modals that display detailed evaluation information when reviewing feedbacks and leaderboard rankings—accelerates learning, progress tracking, and quality improvement.
|
||||
- 🔄 **Support for Multiple Pages in External Document Loaders**: Effortlessly extract and work with content spanning multiple pages in external documents, giving you complete flexibility for in-depth research and document workflows.
|
||||
- 🌐 **New Accessibility Enhancements Across the Interface**: Benefit from significant accessibility improvements—tab navigation, ARIA roles/labels, better high-contrast text/modes, accessible modals, and more—making Open WebUI more usable and equitable for everyone, including those using assistive technologies.
|
||||
- ⚡ **Performance & Stability Upgrades Across Frontend and Backend**: Enjoy a smoother, more reliable experience with numerous behind-the-scenes optimizations and refactoring on both frontend and backend—resulting in faster load times, fewer errors, and even greater stability throughout your workflows.
|
||||
- 🌏 **Updated and Expanded Localizations**: Enjoy improved, up-to-date translations for Finnish, German (now with model pinning features), Korean, Russian, Simplified Chinese, Spanish, and more—making every interaction smoother, clearer, and more intuitive for international users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🦾 **Ollama Error Messages More Descriptive**: Receive clearer, more actionable error messages when something goes wrong with Ollama models—making troubleshooting and user support faster and more effective.
|
||||
- 🌐 **Bypass Webloader Now Works as Expected**: Resolved an issue where the "bypass webloader" feature failed to function correctly, ensuring web search bypasses operate smoothly and reliably for lighter, faster query results.
|
||||
- 🔍 **Prevent Redundant Documents in Citation List**: The expanded citation list no longer shows duplicate documents, offering a cleaner, easier-to-digest reference experience when reviewing sources in knowledge and research workflows.
|
||||
- 🛡️ **Trusted Header Email Matching is Now Case-Insensitive**: Fixed a critical authentication issue where email case sensitivity could cause secure headers to mismatch, ensuring robust, seamless login and session management in all environments.
|
||||
- ⚙️ **Direct Tool Server Input Accepts Empty Strings**: You can now submit direct tool server commands without unexpected errors when passing empty-string values, improving integration and automation efficiency.
|
||||
- 📄 **Citation Page Number for Page 1 is Now Displayed**: Corrected an oversight where references for page 1 documents were missing the page number; citations are now always accurate and fully visible.
|
||||
- 📒 **Notes Access Restored**: Fixed an issue where some users could not access their notes—everyone can now view and manage their notes reliably, ensuring seamless documentation and workflow continuity.
|
||||
- 🛑 **OAuth Callback Double-Slash Issue Resolved**: Fixed rare cases where an extra slash in OAuth callbacks caused failed logins or redirects, making third-party login integrations more reliable.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔑 **Dedicated Permission for System Prompts**: System prompt access is now controlled by its own specific permission instead of being grouped with general chat controls, empowering admins with finer-grained management over who can view or modify system prompts for enhanced security and workflow customization.
|
||||
- 🛠️ **YouTube Transcript API and python-pptx Updated**: Enjoy better performance, reliability, and broader compatibility thanks to underlying library upgrades—less friction with media-rich and presentation workflows.
|
||||
|
||||
### Removed
|
||||
|
||||
- 🗑️ **Console Logging Disabled in Production**: All 'console.log' and 'console.debug' statements are now disabled in production, guaranteeing improved security and cleaner browser logs for end users by removing extraneous technical output.
|
||||
|
||||
## [0.6.14] - 2025-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- 🤖 **Automatic "Follow Up" Suggestions**: Open WebUI now intelligently generates actionable "Follow Up" suggestions automatically with each message you send, helping you stay productive and inspired without interrupting your flow; you can always disable this in Settings if you prefer a distraction-free experience.
|
||||
- 🧩 **OpenAI-Compatible Embeddings Endpoint**: Introducing a fully OpenAI-style '/api/embeddings' endpoint—now you can plug in OpenAI-style embeddings workflows with zero hassle, making integrations with external tools and platforms seamless and familiar.
|
||||
- ↗️ **Model Pinning for Quick Access**: Pin your favorite or most-used models to the sidebar for instant selection—no more scrolling through long model lists; your go-to models are always visible and ready for fast access.
|
||||
- 📌 **Selector Model Item Menu**: Each model in the selector now features a menu where you can easily pin/unpin to the sidebar and copy a direct link—simplifying collaboration and staying organized in even the busiest environments.
|
||||
- 🛑 **Reliable Stop for Ongoing Chats in Multi-Replica Setups**: Stopping or cancelling an in-progress chat now works reliably even in clustered deployments—ensuring every user can interrupt AI output at any time, no matter your scale.
|
||||
- 🧠 **'Think' Parameter for Ollama Models**: Leverage new 'think' parameter support for Ollama—giving you advanced control over AI reasoning process and further tuning model behavior for your unique use cases.
|
||||
- 💬 **Picture Description Modes for Docling**: Customize how images are described/extracted by Docling Loader for smarter, more detailed, and workflow-tailored image understanding in your document pipelines.
|
||||
- 🛠 **Settings Modal Deep Linking**: Every tab in Settings now has its own route—making direct navigation and sharing of precise settings faster and more intuitive.
|
||||
- 🎤 **Audio HTML Component Token**: Easily embed and play audio directly in your chats, improving voice-based workflows and making audio content instantly accessible and manageable from any conversation.
|
||||
- 🔑 **Support for Secret Key File**: Now you can specify 'WEBUI_SECRET_KEY_FILE' for more secure and flexible key management—ideal for advanced deployments and tighter security standards.
|
||||
- 💡 **Clarity When Cloning Prompts**: Cloned workspace prompts are clearly labelled with "(Clone)" and IDs have "-clone", keeping your prompt library organized and preventing accidental overwrites.
|
||||
- 📝 **Dedicated User Role Edit Modal**: Updating user roles now reliably opens a dedicated edit user modal instead of cycling through roles—making it safer and more clear to manage team permissions.
|
||||
- 🏞️ **Better Handling & Storage of Interpreter-Generated Images**: Code interpreter-generated images are now centrally stored and reliably loaded from the database or cloud storage, ensuring your artifacts are always available.
|
||||
- 🚀 **Pinecone & Vector Search Optimizations**: Applied latest best practices from Pinecone for smarter timeouts, intelligent retry control, improved connection pooling, faster DNS, and concurrent batch handling—giving you more reliable, faster document search and RAG performance without manual tweaks.
|
||||
- ⚙️ **Ollama Advanced Parameters Unified**: 'keep_alive' and 'format' options are now integrated into the advanced params section—edit everything from the model editor for flexible model control.
|
||||
- 🛠️ **CUDA 12.6 Docker Image Support**: Deploy to NVIDIA GPUs with capability 7.0 and below (e.g., V100, GTX1080) via new cuda126 image—broadening your hardware options for scalable AI workloads.
|
||||
- 🔒 **Experimental Table-Level PGVector Data Encryption**: Activate pgcrypto encryption support for pgvector to secure your vector search table contents, giving organizations enhanced compliance and data protection—perfect for enterprise or regulated environments.
|
||||
- 👁 **Accessibility Upgrades Across Interface**: Chat buttons and close controls are now labelled and structured for optimal accessibility support, ensuring smoother operation with assistive technologies.
|
||||
- 🎨 **High-Contrast Mode Expansions**: High-contrast accessibility mode now also applies to menu items, tabs, and search input fields, offering a more readable experience for all users.
|
||||
- 🛠️ **Tooltip & Translation Clarity**: Improved translation and tooltip clarity, especially over radio buttons, making the UI more understandable for all users.
|
||||
- 🔠 **Global Localization & Translation Improvements**: Hefty upgrades to Traditional Chinese, Simplified Chinese, Hebrew, Russian, Irish, German, and Danish translation packs—making the platform feel native and intuitive for even more users worldwide.
|
||||
- ⚡ **General Backend Stability & Security Enhancements**: Refined numerous backend routines to minimize memory use, improve performance, and streamline integration with external APIs—making the entire platform more robust and secure for daily work.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🏷 **Feedback Score Display Improved**: Addressed overflow and visibility issues with feedback scores for more readable and accessible evaluations.
|
||||
- 🗂 **Admin Settings Model Edits Apply Immediately**: Changes made in the Model Editor within Admin Settings now take effect instantly, eliminating confusion during model management.
|
||||
- 🔄 **Assigned Tools Update Instantly on New Chats**: Models assigned with specific tools now consistently update and are available in every new chat—making tool workflows more predictable and robust.
|
||||
- 🛠 **Document Settings Saved Only on User Action**: Document settings now save only when you press the Save button, reducing accidental changes and ensuring greater control.
|
||||
- 🔊 **Voice Recording on Older iOS Devices Restored**: Voice input is now fully functional on older iOS devices, keeping voice workflows accessible to all users.
|
||||
- 🔒 **Trusted Email Header Session Security**: User sessions now strictly verify the trusted email header matches the logged-in user's email, ensuring secure authentication and preventing accidental session switching.
|
||||
- 🔒 **Consistent User Signout on Email Mismatch**: When the trusted email in the header changes, you will now be properly signed out and redirected, safeguarding your session's integrity.
|
||||
- 🛠 **General Error & Content Validation Improvements**: Smarter error handling means clearer messages and fewer unnecessary retries—making batch uploads, document handling, and knowledge indexing more resilient.
|
||||
- 🕵️ **Better Feedback on Chat Title Edits**: Error messages now show clearly if problems occur while editing chat titles.
|
||||
|
||||
## [0.6.13] - 2025-05-30
|
||||
|
||||
### Added
|
||||
|
||||
- 🟦 **Azure OpenAI Embedding Support**: You can now select Azure OpenAI endpoints for text embeddings, unlocking seamless integration with enterprise-scale Azure AI for powerful RAG and knowledge workflows—no more workarounds, connect and scale effortlessly.
|
||||
- 🧩 **Smarter Custom Parameter Handling**: Instantly enjoy more flexible model setup—any JSON pasted into custom parameter fields is now parsed automatically, so you can define rich, nested parameters without tedious manual adjustment. This streamlines advanced configuration for all models and accelerates experimentation.
|
||||
- ⚙️ **General Backend Refactoring**: Significant backend improvements deliver a cleaner codebase for better maintainability, faster performance, and even greater platform reliability—making all your workflows run more smoothly.
|
||||
- 🌏 **Localization Upgrades**: Experience highly improved user interface translations and clarity in Simplified, Traditional Chinese, Korean, and Finnish, offering a more natural, accurate, and accessible experience for global users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Robust Message Handling on Chat Load**: Fixed an issue where chat pages could fail to load if a referenced message was missing or undefined; now, chats always load smoothly and missing IDs no longer disrupt your workflow.
|
||||
- 📝 **Correct Prompt Access Control**: Ensured that the prompt access controls register properly, restoring reliable permissioning and safeguarding your prompt workflows.
|
||||
- 🛠 **Open WebUI-Specific Params No Longer Sent to Models**: Fixed a bug that sent internal WebUI parameters to APIs, ensuring only intended model options are transmitted—restoring predictable, error-free model operation.
|
||||
- 🧠 **Refined Memory Error Handling**: Enhanced stability during memory-related operations, so even uncommon memory errors are gracefully managed without disrupting your session—resulting in a more reliable, worry-free experience.
|
||||
|
||||
## [0.6.12] - 2025-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- 🧩 **Custom Advanced Model Parameters**: You can now add your own tailor-made advanced parameters to any model, empowering you to fine-tune behavior and unlock greater flexibility beyond just the built-in options—accelerate your experimentation.
|
||||
- 🪧 **Datalab Marker API Content Extraction Support**: Seamlessly extract content from files and documents using the Datalab Marker API directly in your workflows, enabling more robust structured data extraction for RAG and document processing with just a simple engine switch in the UI.
|
||||
- ⚡ **Parallelized Base Model Fetching**: Experience noticeably faster startup and model refresh times—base model data now loads in parallel, drastically shortening delays in busy or large-scale deployments.
|
||||
- 🧠 **Efficient Function Loading and Caching**: Functions are now only reloaded if their content changes, preventing unnecessary duplicate loads, saving bandwidth, and boosting performance.
|
||||
- 🌍 **Localization & Translation Enhancements**: Improved and expanded Simplified, Traditional Chinese, and Russian translations, providing smoother, more accurate, and context-aware experiences for global users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💬 **Stable Message Input Box**: Fixed an issue where the message input box would shift unexpectedly (especially on mobile or with screen reader support), ensuring a smooth and reliable typing experience for every user.
|
||||
- 🔊 **Reliable Read Aloud (Text-to-Speech)**: Read aloud now works seamlessly across messages, so users depending on TTS for accessibility or multitasking will experience uninterrupted and clear voice playback.
|
||||
- 🖼 **Image Preview and Download Restored**: Fixed problems with image preview and downloads, ensuring frictionless creation, previewing, and downloading of images in your chats—no more interruptions in creative or documentation workflows.
|
||||
- 📱 **Improved Mobile Styling for Workspace Capabilities**: Capabilities management is now readable and easy-to-use even on mobile devices, empowering admins and users to manage access quickly on the go.
|
||||
- 🔁 **/api/v1/retrieval/query/collection Endpoint Reliability**: Queries to retrieval collections now return the expected results, bolstering the reliability of your knowledge workflows and citation-ready responses.
|
||||
|
||||
### Removed
|
||||
|
||||
- 🧹 **Duplicate CSS Elements**: Streamlined the UI by removing redundant CSS, reducing clutter and improving load times for a smoother visual experience.
|
||||
|
||||
## [0.6.11] - 2025-05-27
|
||||
|
||||
### Added
|
||||
|
||||
- 🟢 **Ollama Model Status Indicator in Model Selector**: Instantly see which Ollama models are currently loaded with a clear indicator in the model selector, helping you stay organized and optimize local model usage.
|
||||
- 🗑️ **Unload Ollama Model Directly from Model Selector**: Easily release memory and resources by unloading any loaded Ollama model right in the model selector—streamline hardware management without switching pages.
|
||||
- 🗣️ **User-Configurable Speech-to-Text Language Setting**: Improve transcription accuracy by letting individual users explicitly set their preferred STT language in their settings—ideal for multilingual teams and clear audio capture.
|
||||
- ⚡ **Granular Audio Playback Speed Control**: Instead of just presets, you can now choose granular audio speed using a numeric input, giving you complete control over playback pace in transcriptions and media reviews.
|
||||
- 📦 **GZip, Brotli, ZStd Compression Middleware**: Enjoy significantly faster page loads and reduced bandwidth usage with new server-side compression—giving users a snappier, more efficient experience.
|
||||
- 🏷️ **Configurable Weight for BM25 in Hybrid Search**: Fine-tune search relevance by adjusting the weight for BM25 inside hybrid search from the UI, letting you tailor knowledge search results to your workflow.
|
||||
- 🧪 **Bypass File Creation with CTRL + SHIFT + V**: When “Paste Large Text as File” is enabled, use CTRL + SHIFT + V to skip the file creation dialog and instantly upload text as a file—perfect for rapid document prep.
|
||||
- 🌐 **Bypass Web Loader in Web Search**: Choose to bypass web content loading and use snippets directly in web search for faster, more reliable results when page loads are slow or blocked.
|
||||
- 🚀 **Environment Variable: WEBUI_AUTH_TRUSTED_GROUPS_HEADER**: Now sync and manage user groups directly via trusted HTTP header, unlocking smoother single sign-on and identity integrations for organizations.
|
||||
- 🏢 **Workspace Models Visibility Controls**: You can now hide workspace-level models from both the model selector and shared environments—keep your team focused and reduce clutter from rarely-used endpoints.
|
||||
- 🛡️ **Copy Model Link**: You can now copy a direct link to any model—including those hidden from the selector—making sharing and onboarding others more seamless.
|
||||
- 🔗 **Load Function Directly from URL**: Simplify custom function management—just paste any GitHub function URL into Open WebUI and import new functions in seconds.
|
||||
- ⚙️ **Custom Name/Description for External Tool Servers**: Personalize and clarify external tool servers by assigning custom names and descriptions, making it easier to manage integrations in large-scale workspaces.
|
||||
- 🌍 **Custom OpenAPI JSON URL Support for Tool Servers**: Supports specifying any custom OpenAPI JSON URL, unlocking more flexible integration with any backend for tool calls.
|
||||
- 📊 **Source Field Now Displays in Non-Streaming Responses with Attachments**: When files or knowledge are attached, the "source" field now appears for all responses, even in non-streaming mode—enabling improved citation workflow.
|
||||
- 🎛 **Pinned Chats**: Reduced payload size on pinned chat requests—leading to faster load times and less data usage, especially on busy warehouses.
|
||||
- 🛠 **Import/Export Default Prompt Suggestions**: Enjoy one-click import/export of prompt suggestions, making it much easier to share, reuse, and manage best practices across teams or deployments.
|
||||
- 🍰 **Banners Now Sortable from Admin Settings**: Quickly re-order or prioritize banners, letting you highlight the most critical info for your team.
|
||||
- 🛠 **Advanced Chat Parameters—Clearer Ollama Support Labels**: Parameters and advanced settings now explicitly indicate if they are Ollama-specific, reducing confusion and improving setup accuracy.
|
||||
- 🤏 **Scroll Bar Thumb Improved for Better Visibility**: Enhanced scrollbar styling makes navigation more accessible and visually intuitive.
|
||||
- 🗄️ **Modal Redesign for Archived and User Chat Listings**: Clean, modern modal interface for browsing archived and user-specific chats makes locating conversations faster and more pleasant.
|
||||
- 📝 **Add/Edit Memory Modal UX**: Memory modals are now larger and have resizable input fields, supporting easier editing of long or complex memory content.
|
||||
- 🏆 **Translation & Localization Enhancements**: Major upgrades to Chinese (Simplified & Traditional), Korean, Russian, German, Danish, Finnish—not just fixing typos, but consistency, tone, and terminology for a more natural native-language experience.
|
||||
- ⚡ **General Backend Stability & Security Enhancements**: Various backend refinements ensure a more resilient, reliable, and secure platform for smoother operation and peace of mind.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🖼️ **Image Generation with Allowed File Extensions Now Works Reliably**: Ensure seamless image generation even when strict file extension rules are set—no more blocked creative workflows due to technical hiccups.
|
||||
- 🗂 **Remove Leading Dot for File Extension Check**: Fixed an issue where file validation failed because of a leading dot, making file uploads and knowledge management more robust.
|
||||
- 🏷️ **Correct Local/External Model Classification**: The platform now accurately distinguishes between local and external models—preventing local models from showing up as external (and vice versa)—ensuring seamless setup, clarity, and management of your AI model endpoints.
|
||||
- 📄 **External Document Loader Now Functions as Intended**: External document loaders are reliably invoked, ensuring smoother knowledge ingestion from external sources—expanding your RAG and knowledge workflows.
|
||||
- 🎯 **Correct Handling of Toggle Filters**: Toggle filters are now robustly managed, preventing accidental auto-activation and ensuring user preferences are always respected.
|
||||
- 🗃 **S3 Tagging Character Restrictions Fixed**: Tags for files in S3 now automatically meet Amazon’s allowed character set, avoiding upload errors and ensuring cross-cloud compatibility.
|
||||
- 🛡️ **Authentication Now Uses Password Hash When Duplicate Emails Exist**: Ensures account security and prevents access issues if duplicate emails are present in your system.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧩 **Admin Settings: OAuth Redirects Now Use WEBUI_URL**: The OAuth redirect URL is now based on the explicitly set WEBUI_URL, ensuring single sign-on and identity provider integrations always send users to the correct frontend.
|
||||
|
||||
### Removed
|
||||
|
||||
- 💡 **Duplicate/Typo Component Removals**: Obsolete components have been cleaned up, reducing confusion and improving overall code quality for the team.
|
||||
- 🚫 **Streaming Upsert in Pinecone Removed**: Removed streaming upsert references for better compatibility and future-proofing with latest Pinecone SDK updates.
|
||||
|
||||
## [0.6.10] - 2025-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- 🧩 **Experimental Azure OpenAI Support**: Instantly connect to Azure OpenAI endpoints by simply pasting your Azure OpenAI URL into the model connections—bringing flexible, enterprise-grade AI integration directly to your workflow.
|
||||
- 💧 **Watermark AI Responses**: Easily add a visible watermark to AI-generated responses for clear content provenance and compliance with EU AI regulations—perfect for regulated environments and transparent communication.
|
||||
- 🔍 **Enhanced Search Experience with Dedicated Modal**: Enjoy a modern, powerful search UI in a dedicated modal (open with Ctrl/Cmd + K) accessible from anywhere—quickly find chats, models, or content and boost your productivity.
|
||||
- 🔲 **"Toggle" Filter Type for Chat Input**: Add interactive toggle filters (e.g. Web Search, Image, Code Interpreter) right into the chat input—giving you one-click control to activate features and simplifying the chat configuration process.
|
||||
- 🧰 **Granular Model Capabilities Editor**: Define detailed capabilities and feature access for each AI model directly from the model editor—enabling tailored behavior, modular control, and a more customized AI environment for every team or use case.
|
||||
- 🌐 **Flexible Local and External Connection Support**: Now you can designate any AI connection—whether OpenAI, Ollama, or others—as local or external, enabling seamless setup for on-premise, self-hosted, or cloud configurations and giving you maximum control and flexibility.
|
||||
- 🗂️ **Allowed File Extensions for RAG**: Gain full control over your Retrieval-Augmented Generation (RAG) workflows by specifying exactly which file extensions are permitted for upload, improving security and relevance of indexed documents.
|
||||
- 🔊 **Enhanced Audio Transcription Logic**: Experience smoother, more reliable audio transcription—very long audio files are now automatically split and processed in segments, preventing errors and ensuring even challenging files are transcribed seamlessly, all part of a broader stability upgrade for robust media workflows.
|
||||
- 🦾 **External Document Loader Support**: Enhance knowledge base building by integrating documents using external loaders from a wide range of data sources, expanding what your AI can read and process.
|
||||
- 📝 **Preview Button for Code Artifacts**: Instantly jump from an HTML code block to its associated artifacts page with the click of a new preview button—speeding up review and streamlining analysis.
|
||||
- 🦻 **Screen Reader Support for Response Messages**: All chat responses are now fully compatible with screen readers, making the platform more inclusive and accessible for everyone.
|
||||
- 🧑💼 **Customizable Pending User Overlay**: You can now tailor the overlay title and content shown to pending users, ensuring onboarding messaging is perfectly aligned with your organization’s tone and requirements.
|
||||
- 🔐 **Option to Disable LDAP Certificate Validation**: You can now disable LDAP certificate validation for maximum flexibility in diverse IT environments—making integrations and troubleshooting much easier.
|
||||
- 🎯 **Workspace Search by Username or Email**: Easily search across workspace pages using any username or email address, streamlining user and resource management.
|
||||
- 🎨 **High Contrast & Dark Mode Enhancements**: Further improved placeholder, input, suggestion, toast, and model selector contrasts—including a dedicated placeholder dark mode—for more comfortable viewing in all lighting conditions.
|
||||
- 🛡️ **Refined Security for Pipelines & Model Uploads**: Strengthened safeguards against path traversal vulnerabilities during uploads—ensuring your platform’s document and model management remains secure.
|
||||
- 🌏 **Major Localization Upgrades**: Comprehensive translation updates and improvements across Korean, Bulgarian, Catalan, Japanese, Italian, Traditional Chinese, and Spanish—including more accurate AI terminology for clarity; your experience is now more natural, inclusive, and professional everywhere.
|
||||
- 🦾 **General Backend Stability & Security**: Multiple backend improvements (including file upload, command navigation, and logging refactorings) deliver increased resilience, better error handling, and a more robust platform for all users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅ **Evaluation Feedback Endpoint Reliability**: Addressed issues with feedback submission endpoints to ensure seamless user feedback collection on model responses.
|
||||
- 🫰 **Model List State Fixes**: Resolved issues where model status toggles in the workspace page might inadvertently switch or confuse state, making the management of active/inactive models more dependable.
|
||||
- ✍️ **Admin Signup Logic**: Admin self-signup experience and validation flow is smoother and more robust.
|
||||
- 🔁 **Signout Redirect Flow Improved**: Logging out now redirects more reliably, reducing confusion and making session management seamless.
|
||||
|
||||
## [0.6.9] - 2025-05-10
|
||||
|
||||
### Added
|
||||
|
||||
- 📝 **Edit Attached Images/Files in Messages**: You can now easily edit your sent messages by removing attached files—streamlining document management, correcting mistakes on the fly, and keeping your chats clutter-free.
|
||||
- 🚨 **Clear Alerts for Private Task Models**: When interacting with private task models, the UI now clearly alerts you—making it easier to understand resource availability and access, reducing confusion during workflow setup.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Confirm Dialog Focus Trap Reliability**: The focus now stays correctly within confirmation dialogs, ensuring keyboard navigation and accessibility is seamless and preventing accidental operations—especially helpful during critical or rapid workflows.
|
||||
- 💬 **Temporary Chat Admin Controls & Session Cleanliness**: Admins are now able to properly enable temporary chat mode without errors, and previous session prompts or tool selections no longer carry over—delivering a fresh, predictable, and consistent temporary chat experience every time.
|
||||
- 🤖 **External Reranker Integration Functionality Restored**: External reranker integrations now work correctly, allowing you to fully leverage advanced ranking services for sharper, more relevant search results in your RAG and knowledge base workflows.
|
||||
|
||||
## [0.6.8] - 2025-05-10
|
||||
|
||||
### Added
|
||||
|
||||
- 🏆 **External Reranker Support for Knowledge Base Search**: Supercharge your Retrieval-Augmented Generation (RAG) workflows with the new External Reranker integration; easily plug in advanced reranking services via the UI to deliver sharper and more relevant search results, accelerating research and insight discovery.
|
||||
- 📤 **Unstylized PDF Export Option (Reduced File Size)**: When exporting chat transcripts or documents, you can now choose an unstylized PDF export for snappier downloads, minimal file size, and clean data archiving—perfect for large-scale storage or sharing.
|
||||
- 📝 **Vazirmatn Font for Persian & Arabic**: Arabic and Persian users will now see their text beautifully rendered with the specialized Vazirmatn font for an improved localized reading experience.
|
||||
- 🏷️ **SharePoint Tenant ID Support for OneDrive**: You can now specify a SharePoint tenant ID in OneDrive settings for seamless authentication and granular enterprise integration.
|
||||
- 👤 **Refresh OAuth Profile Picture**: Your OAuth profile picture now updates in real-time, ensuring your presence and avatar always match your latest identity across integrated platforms.
|
||||
- 🔧 **Milvus Configuration Improvements**: Configure index and metric types for Milvus directly within settings; take full control of your vector database for more accurate and robust AI search experiences.
|
||||
- 🛡️ **S3 Tagging Toggle for Compatibility**: Optional S3 tagging via an environment toggle grants full compatibility with all storage backends—including those that don’t support tagging like Cloudflare R2—ensuring error-free attachment and document management.
|
||||
- 👨🦯 **Icon Button Accessibility Improvements**: Key interactive icon-buttons now include aria-labels and ARIA descriptions, so screen readers provide precise guidance about what action each button performs for improved accessibility.
|
||||
- ♿ **Enhanced Accessibility with Modal Focus Trap**: Modal dialogs and pop-ups now feature a focus trap and improved ARIA roles, ensuring seamless navigation and screen reader support—making the interface friendlier for everyone, including keyboard and assistive tech users.
|
||||
- 🏃 **Improved Admin User List Loading Indicator**: The user list loading experience is now clearer and more responsive in the admin panel.
|
||||
- 🧑🤝🧑 **Larger Admin User List Page Size**: Admins can now manage up to 30 users per page in the admin interface, drastically reducing pagination and making large user teams easier and faster to manage.
|
||||
- 🌠 **Default Code Interpreter Prompt Clarified**: The built-in code interpreter prompt is now more explicit, preventing AI from wrapping code in Markdown blocks when not needed—ensuring properly formatted code runs as intended every time.
|
||||
- 🧾 **Improved Default Title Generation Prompt Template**: Title generation now uses a robust template for reliable JSON output, improving chat organization and searchability.
|
||||
- 🔗 **Support Jupyter Notebooks with Non-Root Base URLs**: Notebook-based code execution now supports non-root deployed Jupyter servers, granting full flexibility for hybrid or multi-user setups.
|
||||
- 📰 **UI Scrollbar Always Visible for Overflow Tools**: When available tools overflow the display, the scrollbar is now always visible and there’s a handy "show all" toggle, making navigation of large toolsets snappier and more intuitive.
|
||||
- 🛠️ **General Backend Refactoring for Stability**: Multiple under-the-hood improvements have been made across backend components, ensuring smoother performance, fewer errors, and a more reliable overall experience for all users.
|
||||
- 🚀 **Optimized Web Search for Faster Results**: Web search speed and performance have been significantly enhanced, delivering answers and sources in record time to accelerate your research-heavy workflows.
|
||||
- 💡 **More Supported Languages**: Expanded language support ensures an even wider range of users can enjoy an intuitive and natural interface in their native tongue.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🏃♂️ **Exhausting Workers in Nginx Reverse Proxy Due to Websocket Fix**: Websocket sessions are now fully compatible behind Nginx, eliminating worker exhaustion and restoring 24/7 reliability for real-time chats even in complex deployments.
|
||||
- 🎤 **Audio Transcription Issue with OpenAI Resolved**: OpenAI-based audio transcription now handles WebM and newer formats without error, ensuring seamless voice-to-text workflows every time.
|
||||
- 👉 **Message Input RTL Issue Fixed**: The chat message input now displays correctly for right-to-left languages, creating a flawless typing and reading experience for Arabic, Hebrew, and more.
|
||||
- 🀄 **Katex: Proper Rendering of Chinese Characters Next to Math**: Math formulas now render perfectly even when directly adjacent to Chinese (CJK) characters, improving visual clarity for multilingual teams and cross-language documents.
|
||||
- 🔂 **Duplicate Web Search URLs Eliminated**: Search results now reliably filter out URL duplicates, so your knowledge and search citations are always clean, trimmed, and easy to review.
|
||||
- 📄 **Markdown Rendering Fixed in Knowledge Bases**: Markdown is now displayed correctly within knowledge bases, enabling better formatting and clarity of information-rich files.
|
||||
- 🗂️ **LDAP Import/Loading Issue Resolved**: LDAP user imports process correctly, ensuring smooth onboarding and access without interruption.
|
||||
- 🌎 **Pinecone Batch Operations and Async Safety**: All Pinecone operations (batch insert, upsert, delete) now run efficiently and safely in an async environment, boosting performance and preventing slowdowns in large-scale RAG jobs.
|
||||
|
||||
## [0.6.7] - 2025-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- 🌐 **Custom Azure TTS API URL Support Added**: You can now define a custom Azure Text-to-Speech endpoint—enabling flexibility for enterprise deployments and regional compliance.
|
||||
- ⚙️ **TOOL_SERVER_CONNECTIONS Environment Variable Suppor**: Easily configure and deploy tool servers via environment variables, streamlining setup and enabling faster enterprise provisioning.
|
||||
- 👥 **Enhanced OAuth Group Handling as String or List**: OAuth group data can now be passed as either a list or a comma-separated string, improving compatibility with varied identity provider formats and reducing onboarding friction.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🧠 **Embedding with Ollama Proxy Endpoints Restored**: Fixed an issue where missing API config broke embedding for proxied Ollama models—ensuring consistent performance and compatibility.
|
||||
- 🔐 **OIDC OAuth Login Issue Resolved**: Users can once again sign in seamlessly using OpenID Connect-based OAuth, eliminating login interruptions and improving reliability.
|
||||
- 📝 **Notes Feature Access Fixed for Non-Admins**: Fixed an issue preventing non-admin users from accessing the Notes feature, restoring full cross-role collaboration capabilities.
|
||||
- 🖼️ **Tika Loader Image Extraction Problem Resolved**: Ensured TikaLoader now processes 'extract_images' parameter correctly, restoring complete file extraction functionality in document workflows.
|
||||
- 🎨 **Automatic1111 Image Model Setting Applied Properly**: Fixed an issue where switching to a specific image model via the UI wasn’t reflected in generation, re-enabling full visual creativity control.
|
||||
- 🏷️ **Multiple XML Tags in Messages Now Parsed Correctly**: Fixed parsing issues when messages included multiple XML-style tags, ensuring clean and unbroken rendering of rich content in chats.
|
||||
- 🖌️ **OpenAI Image Generation Issues Resolved**: Resolved broken image output when using OpenAI’s image generation, ensuring fully functional visual creation workflows.
|
||||
- 🔎 **Tool Server Settings UI Privacy Restored**: Prevented restricted users from accessing tool server settings via search—restoring tight permissions control and safeguarding sensitive configurations.
|
||||
- 🎧 **WebM Audio Transcription Now Supported**: Fixed an issue where WebM files failed during audio transcription—these formats are now fully supported, ensuring smoother voice note workflows and broader file compatibility.
|
||||
|
||||
## [0.6.6] - 2025-05-05
|
||||
|
||||
### Added
|
||||
|
||||
+3
-3
@@ -2,13 +2,13 @@
|
||||
|
||||
## Our Pledge
|
||||
|
||||
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
As members, contributors, and leaders of this community, we pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct.
|
||||
|
||||
## Why These Standards Are Important
|
||||
|
||||
Open-source projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved.
|
||||
Projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved.
|
||||
|
||||
Maintaining a positive and respectful environment is essential to safeguarding the integrity of this project and protecting contributors' efforts. Behavior that disrupts this atmosphere—whether through hostility, entitlement, or unprofessional conduct—can severely harm the morale and productivity of the community. **Strict enforcement of these standards ensures a safe and supportive space for meaningful collaboration.**
|
||||
|
||||
@@ -79,7 +79,7 @@ This approach ensures that disruptive behaviors are addressed swiftly and decisi
|
||||
|
||||
## Why Zero Tolerance Is Necessary
|
||||
|
||||
Open-source projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do.
|
||||
Projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do.
|
||||
|
||||
By enforcing a zero-tolerance policy, we ensure that the community remains a safe, welcoming space for all participants. These measures are not about harshness—they are about protecting contributors and fostering a productive environment where innovation can thrive.
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Run with
|
||||
# caddy run --envfile ./example.env --config ./Caddyfile.localhost
|
||||
#
|
||||
# This is configured for
|
||||
# - Automatic HTTPS (even for localhost)
|
||||
# - Reverse Proxying to Ollama API Base URL (http://localhost:11434/api)
|
||||
# - CORS
|
||||
# - HTTP Basic Auth API Tokens (uncomment basicauth section)
|
||||
|
||||
|
||||
# CORS Preflight (OPTIONS) + Request (GET, POST, PATCH, PUT, DELETE)
|
||||
(cors-api) {
|
||||
@match-cors-api-preflight method OPTIONS
|
||||
handle @match-cors-api-preflight {
|
||||
header {
|
||||
Access-Control-Allow-Origin "{http.request.header.origin}"
|
||||
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With"
|
||||
Access-Control-Allow-Credentials "true"
|
||||
Access-Control-Max-Age "3600"
|
||||
defer
|
||||
}
|
||||
respond "" 204
|
||||
}
|
||||
|
||||
@match-cors-api-request {
|
||||
not {
|
||||
header Origin "{http.request.scheme}://{http.request.host}"
|
||||
}
|
||||
header Origin "{http.request.header.origin}"
|
||||
}
|
||||
handle @match-cors-api-request {
|
||||
header {
|
||||
Access-Control-Allow-Origin "{http.request.header.origin}"
|
||||
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With"
|
||||
Access-Control-Allow-Credentials "true"
|
||||
Access-Control-Max-Age "3600"
|
||||
defer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# replace localhost with example.com or whatever
|
||||
localhost {
|
||||
## HTTP Basic Auth
|
||||
## (uncomment to enable)
|
||||
# basicauth {
|
||||
# # see .example.env for how to generate tokens
|
||||
# {env.OLLAMA_API_ID} {env.OLLAMA_API_TOKEN_DIGEST}
|
||||
# }
|
||||
|
||||
handle /api/* {
|
||||
# Comment to disable CORS
|
||||
import cors-api
|
||||
|
||||
reverse_proxy localhost:11434
|
||||
}
|
||||
|
||||
# Same-Origin Static Web Server
|
||||
file_server {
|
||||
root ./build/
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ ARG BUILD_HASH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# to store git revision in build
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
|
||||
@@ -73,7 +73,27 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
|
||||
<a href="https://n8n.io/">n8n</a> • Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://warp.dev/open-webui" target="_blank">
|
||||
<img src="https://docs.openwebui.com/sponsors/logos/warp.png" alt="Warp" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://warp.dev/open-webui">Warp</a> • The intelligent terminal for developers
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://tailscale.com/blog/self-host-a-local-ai-stack/?utm_source=OpenWebUI&utm_medium=paid-ad-placement&utm_campaign=OpenWebUI-Docs" target="_blank">
|
||||
<img src="https://docs.openwebui.com/sponsors/logos/tailscale.png" alt="Tailscale" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tailscale.com/blog/self-host-a-local-ai-stack/?utm_source=OpenWebUI&utm_medium=paid-ad-placement&utm_campaign=OpenWebUI-Docs">Tailscale</a> • Connect self-hosted AI to any device with Tailscale
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -171,6 +191,8 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
|
||||
|
||||
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
|
||||
|
||||
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/advanced-topics/development) for instructions on setting up a local development environment.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s).
|
||||
|
||||
+378
-70
@@ -109,54 +109,7 @@ if os.path.exists(f"{DATA_DIR}/config.json"):
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"version": 0,
|
||||
"ui": {
|
||||
"default_locale": "",
|
||||
"prompt_suggestions": [
|
||||
{
|
||||
"title": [
|
||||
"Help me study",
|
||||
"vocabulary for a college entrance exam",
|
||||
],
|
||||
"content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.",
|
||||
},
|
||||
{
|
||||
"title": [
|
||||
"Give me ideas",
|
||||
"for what to do with my kids' art",
|
||||
],
|
||||
"content": "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.",
|
||||
},
|
||||
{
|
||||
"title": ["Tell me a fun fact", "about the Roman Empire"],
|
||||
"content": "Tell me a random fun fact about the Roman Empire",
|
||||
},
|
||||
{
|
||||
"title": [
|
||||
"Show me a code snippet",
|
||||
"of a website's sticky header",
|
||||
],
|
||||
"content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.",
|
||||
},
|
||||
{
|
||||
"title": [
|
||||
"Explain options trading",
|
||||
"if I'm familiar with buying and selling stocks",
|
||||
],
|
||||
"content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.",
|
||||
},
|
||||
{
|
||||
"title": ["Overcome procrastination", "give me tips"],
|
||||
"content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?",
|
||||
},
|
||||
{
|
||||
"title": [
|
||||
"Grammar check",
|
||||
"rewrite it for better readability ",
|
||||
],
|
||||
"content": 'Check the following sentence for grammar and clarity: "[sentence]". Rewrite it for better readability while maintaining its original meaning.',
|
||||
},
|
||||
],
|
||||
},
|
||||
"ui": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +347,24 @@ MICROSOFT_CLIENT_TENANT_ID = PersistentConfig(
|
||||
os.environ.get("MICROSOFT_CLIENT_TENANT_ID", ""),
|
||||
)
|
||||
|
||||
MICROSOFT_CLIENT_LOGIN_BASE_URL = PersistentConfig(
|
||||
"MICROSOFT_CLIENT_LOGIN_BASE_URL",
|
||||
"oauth.microsoft.login_base_url",
|
||||
os.environ.get(
|
||||
"MICROSOFT_CLIENT_LOGIN_BASE_URL", "https://login.microsoftonline.com"
|
||||
),
|
||||
)
|
||||
|
||||
MICROSOFT_CLIENT_PICTURE_URL = PersistentConfig(
|
||||
"MICROSOFT_CLIENT_PICTURE_URL",
|
||||
"oauth.microsoft.picture_url",
|
||||
os.environ.get(
|
||||
"MICROSOFT_CLIENT_PICTURE_URL",
|
||||
"https://graph.microsoft.com/v1.0/me/photo/$value",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
MICROSOFT_OAUTH_SCOPE = PersistentConfig(
|
||||
"MICROSOFT_OAUTH_SCOPE",
|
||||
"oauth.microsoft.scope",
|
||||
@@ -552,6 +523,12 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig(
|
||||
],
|
||||
)
|
||||
|
||||
OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig(
|
||||
"OAUTH_UPDATE_PICTURE_ON_LOGIN",
|
||||
"oauth.update_picture_on_login",
|
||||
os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
@@ -583,7 +560,7 @@ def load_oauth_providers():
|
||||
name="microsoft",
|
||||
client_id=MICROSOFT_CLIENT_ID.value,
|
||||
client_secret=MICROSOFT_CLIENT_SECRET.value,
|
||||
server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
|
||||
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
|
||||
client_kwargs={
|
||||
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
||||
},
|
||||
@@ -592,7 +569,7 @@ def load_oauth_providers():
|
||||
|
||||
OAUTH_PROVIDERS["microsoft"] = {
|
||||
"redirect_uri": MICROSOFT_REDIRECT_URI.value,
|
||||
"picture_url": "https://graph.microsoft.com/v1.0/me/photo/$value",
|
||||
"picture_url": MICROSOFT_CLIENT_PICTURE_URL.value,
|
||||
"register": microsoft_oauth_register,
|
||||
}
|
||||
|
||||
@@ -761,9 +738,10 @@ S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
|
||||
S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None)
|
||||
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
|
||||
S3_USE_ACCELERATE_ENDPOINT = (
|
||||
os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "False").lower() == "true"
|
||||
os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "false").lower() == "true"
|
||||
)
|
||||
S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None)
|
||||
S3_ENABLE_TAGGING = os.getenv("S3_ENABLE_TAGGING", "false").lower() == "true"
|
||||
|
||||
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
|
||||
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
|
||||
@@ -921,11 +899,19 @@ OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
||||
# TOOL_SERVERS
|
||||
####################################
|
||||
|
||||
try:
|
||||
tool_server_connections = json.loads(
|
||||
os.environ.get("TOOL_SERVER_CONNECTIONS", "[]")
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Error loading TOOL_SERVER_CONNECTIONS: {e}")
|
||||
tool_server_connections = []
|
||||
|
||||
|
||||
TOOL_SERVER_CONNECTIONS = PersistentConfig(
|
||||
"TOOL_SERVER_CONNECTIONS",
|
||||
"tool_server.connections",
|
||||
[],
|
||||
tool_server_connections,
|
||||
)
|
||||
|
||||
####################################
|
||||
@@ -933,9 +919,7 @@ TOOL_SERVER_CONNECTIONS = PersistentConfig(
|
||||
####################################
|
||||
|
||||
|
||||
WEBUI_URL = PersistentConfig(
|
||||
"WEBUI_URL", "webui.url", os.environ.get("WEBUI_URL", "http://localhost:3000")
|
||||
)
|
||||
WEBUI_URL = PersistentConfig("WEBUI_URL", "webui.url", os.environ.get("WEBUI_URL", ""))
|
||||
|
||||
|
||||
ENABLE_SIGNUP = PersistentConfig(
|
||||
@@ -1002,6 +986,7 @@ if default_prompt_suggestions == []:
|
||||
"content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?",
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
|
||||
"DEFAULT_PROMPT_SUGGESTIONS",
|
||||
"ui.prompt_suggestions",
|
||||
@@ -1020,6 +1005,26 @@ DEFAULT_USER_ROLE = PersistentConfig(
|
||||
os.getenv("DEFAULT_USER_ROLE", "pending"),
|
||||
)
|
||||
|
||||
PENDING_USER_OVERLAY_TITLE = PersistentConfig(
|
||||
"PENDING_USER_OVERLAY_TITLE",
|
||||
"ui.pending_user_overlay_title",
|
||||
os.environ.get("PENDING_USER_OVERLAY_TITLE", ""),
|
||||
)
|
||||
|
||||
PENDING_USER_OVERLAY_CONTENT = PersistentConfig(
|
||||
"PENDING_USER_OVERLAY_CONTENT",
|
||||
"ui.pending_user_overlay_content",
|
||||
os.environ.get("PENDING_USER_OVERLAY_CONTENT", ""),
|
||||
)
|
||||
|
||||
|
||||
RESPONSE_WATERMARK = PersistentConfig(
|
||||
"RESPONSE_WATERMARK",
|
||||
"ui.watermark",
|
||||
os.environ.get("RESPONSE_WATERMARK", ""),
|
||||
)
|
||||
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS", "False").lower()
|
||||
== "true"
|
||||
@@ -1072,6 +1077,10 @@ USER_PERMISSIONS_CHAT_CONTROLS = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_SYSTEM_PROMPT", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
|
||||
)
|
||||
@@ -1157,6 +1166,7 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
},
|
||||
"chat": {
|
||||
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
|
||||
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
|
||||
"delete": USER_PERMISSIONS_CHAT_DELETE,
|
||||
"edit": USER_PERMISSIONS_CHAT_EDIT,
|
||||
@@ -1246,13 +1256,16 @@ ENABLE_USER_WEBHOOKS = PersistentConfig(
|
||||
)
|
||||
|
||||
# FastAPI / AnyIO settings
|
||||
THREAD_POOL_SIZE = int(os.getenv("THREAD_POOL_SIZE", "0"))
|
||||
THREAD_POOL_SIZE = os.getenv("THREAD_POOL_SIZE", None)
|
||||
|
||||
|
||||
def validate_cors_origins(origins):
|
||||
for origin in origins:
|
||||
if origin != "*":
|
||||
validate_cors_origin(origin)
|
||||
if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
|
||||
try:
|
||||
THREAD_POOL_SIZE = int(THREAD_POOL_SIZE)
|
||||
except ValueError:
|
||||
log.warning(
|
||||
f"THREAD_POOL_SIZE is not a valid integer: {THREAD_POOL_SIZE}. Defaulting to None."
|
||||
)
|
||||
THREAD_POOL_SIZE = None
|
||||
|
||||
|
||||
def validate_cors_origin(origin):
|
||||
@@ -1274,16 +1287,17 @@ def validate_cors_origin(origin):
|
||||
# To test CORS_ALLOW_ORIGIN locally, you can set something like
|
||||
# CORS_ALLOW_ORIGIN=http://localhost:5173;http://localhost:8080
|
||||
# in your .env file depending on your frontend port, 5173 in this case.
|
||||
CORS_ALLOW_ORIGIN = os.environ.get(
|
||||
"CORS_ALLOW_ORIGIN", "*;http://localhost:5173;http://localhost:8080"
|
||||
).split(";")
|
||||
CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";")
|
||||
|
||||
if "*" in CORS_ALLOW_ORIGIN:
|
||||
if CORS_ALLOW_ORIGIN == ["*"]:
|
||||
log.warning(
|
||||
"\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n"
|
||||
)
|
||||
|
||||
validate_cors_origins(CORS_ALLOW_ORIGIN)
|
||||
else:
|
||||
# You have to pick between a single wildcard or a list of origins.
|
||||
# Doing both will result in CORS errors in the browser.
|
||||
for origin in CORS_ALLOW_ORIGIN:
|
||||
validate_cors_origin(origin)
|
||||
|
||||
|
||||
class BannerModel(BaseModel):
|
||||
@@ -1348,6 +1362,9 @@ Generate a concise, 3-5 word title with an emoji summarizing the chat history.
|
||||
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
|
||||
- Write the title in the chat's primary language; default to English if multilingual.
|
||||
- Prioritize accuracy over excessive creativity; keep it clear and simple.
|
||||
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
|
||||
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
|
||||
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
|
||||
### Output:
|
||||
JSON format: { "title": "your concise title here" }
|
||||
### Examples:
|
||||
@@ -1412,6 +1429,35 @@ Strictly return in JSON format:
|
||||
{{MESSAGES:END:6}}
|
||||
</chat_history>"""
|
||||
|
||||
|
||||
FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
|
||||
"FOLLOW_UP_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.follow_up.prompt_template",
|
||||
os.environ.get("FOLLOW_UP_GENERATION_PROMPT_TEMPLATE", ""),
|
||||
)
|
||||
|
||||
DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = """### Task:
|
||||
Suggest 3-5 relevant follow-up questions or prompts that the user might naturally ask next in this conversation as a **user**, based on the chat history, to help continue or deepen the discussion.
|
||||
### Guidelines:
|
||||
- Write all follow-up questions from the user’s point of view, directed to the assistant.
|
||||
- Make questions concise, clear, and directly related to the discussed topic(s).
|
||||
- Only suggest follow-ups that make sense given the chat content and do not repeat what was already covered.
|
||||
- If the conversation is very short or not specific, suggest more general (but relevant) follow-ups the user might ask.
|
||||
- Use the conversation's primary language; default to English if multilingual.
|
||||
- Response must be a JSON array of strings, no extra text or formatting.
|
||||
### Output:
|
||||
JSON format: { "follow_ups": ["Question 1?", "Question 2?", "Question 3?"] }
|
||||
### Chat History:
|
||||
<chat_history>
|
||||
{{MESSAGES:END:6}}
|
||||
</chat_history>"""
|
||||
|
||||
ENABLE_FOLLOW_UP_GENERATION = PersistentConfig(
|
||||
"ENABLE_FOLLOW_UP_GENERATION",
|
||||
"task.follow_up.enable",
|
||||
os.environ.get("ENABLE_FOLLOW_UP_GENERATION", "True").lower() == "true",
|
||||
)
|
||||
|
||||
ENABLE_TAGS_GENERATION = PersistentConfig(
|
||||
"ENABLE_TAGS_GENERATION",
|
||||
"task.tags.enable",
|
||||
@@ -1690,7 +1736,8 @@ DEFAULT_CODE_INTERPRETER_PROMPT = """
|
||||
1. **Code Interpreter**: `<code_interpreter type="code" lang="python"></code_interpreter>`
|
||||
- You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response.
|
||||
- The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**.
|
||||
- To use it, **you must enclose your code within `<code_interpreter type="code" lang="python">` XML tags** and stop right away. If you don't, the code won't execute. Do NOT use triple backticks.
|
||||
- To use it, **you must enclose your code within `<code_interpreter type="code" lang="python">` XML tags** and stop right away. If you don't, the code won't execute.
|
||||
- When writing code in the code_interpreter XML tag, Do NOT use the triple backticks code block for markdown formatting, example: ```py # python code ``` will cause an error because it is markdown formatting, it is not python code.
|
||||
- When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user.
|
||||
- After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.**
|
||||
- If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary.
|
||||
@@ -1737,12 +1784,21 @@ 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"))
|
||||
|
||||
# Qdrant
|
||||
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
||||
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
|
||||
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
|
||||
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true"
|
||||
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
|
||||
ENABLE_QDRANT_MULTITENANCY_MODE = (
|
||||
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# OpenSearch
|
||||
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
||||
@@ -1774,6 +1830,13 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
|
||||
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
|
||||
)
|
||||
|
||||
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:
|
||||
raise ValueError(
|
||||
"PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key."
|
||||
)
|
||||
|
||||
# Pinecone
|
||||
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
|
||||
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
|
||||
@@ -1824,6 +1887,11 @@ ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
|
||||
os.environ.get("ONEDRIVE_SHAREPOINT_URL", ""),
|
||||
)
|
||||
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID = PersistentConfig(
|
||||
"ONEDRIVE_SHAREPOINT_TENANT_ID",
|
||||
"onedrive.sharepoint_tenant_id",
|
||||
os.environ.get("ONEDRIVE_SHAREPOINT_TENANT_ID", ""),
|
||||
)
|
||||
|
||||
# RAG Content Extraction
|
||||
CONTENT_EXTRACTION_ENGINE = PersistentConfig(
|
||||
@@ -1832,6 +1900,73 @@ CONTENT_EXTRACTION_ENGINE = PersistentConfig(
|
||||
os.environ.get("CONTENT_EXTRACTION_ENGINE", "").lower(),
|
||||
)
|
||||
|
||||
DATALAB_MARKER_API_KEY = PersistentConfig(
|
||||
"DATALAB_MARKER_API_KEY",
|
||||
"rag.datalab_marker_api_key",
|
||||
os.environ.get("DATALAB_MARKER_API_KEY", ""),
|
||||
)
|
||||
|
||||
DATALAB_MARKER_LANGS = PersistentConfig(
|
||||
"DATALAB_MARKER_LANGS",
|
||||
"rag.datalab_marker_langs",
|
||||
os.environ.get("DATALAB_MARKER_LANGS", ""),
|
||||
)
|
||||
|
||||
DATALAB_MARKER_USE_LLM = PersistentConfig(
|
||||
"DATALAB_MARKER_USE_LLM",
|
||||
"rag.DATALAB_MARKER_USE_LLM",
|
||||
os.environ.get("DATALAB_MARKER_USE_LLM", "false").lower() == "true",
|
||||
)
|
||||
|
||||
DATALAB_MARKER_SKIP_CACHE = PersistentConfig(
|
||||
"DATALAB_MARKER_SKIP_CACHE",
|
||||
"rag.datalab_marker_skip_cache",
|
||||
os.environ.get("DATALAB_MARKER_SKIP_CACHE", "false").lower() == "true",
|
||||
)
|
||||
|
||||
DATALAB_MARKER_FORCE_OCR = PersistentConfig(
|
||||
"DATALAB_MARKER_FORCE_OCR",
|
||||
"rag.datalab_marker_force_ocr",
|
||||
os.environ.get("DATALAB_MARKER_FORCE_OCR", "false").lower() == "true",
|
||||
)
|
||||
|
||||
DATALAB_MARKER_PAGINATE = PersistentConfig(
|
||||
"DATALAB_MARKER_PAGINATE",
|
||||
"rag.datalab_marker_paginate",
|
||||
os.environ.get("DATALAB_MARKER_PAGINATE", "false").lower() == "true",
|
||||
)
|
||||
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR = PersistentConfig(
|
||||
"DATALAB_MARKER_STRIP_EXISTING_OCR",
|
||||
"rag.datalab_marker_strip_existing_ocr",
|
||||
os.environ.get("DATALAB_MARKER_STRIP_EXISTING_OCR", "false").lower() == "true",
|
||||
)
|
||||
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig(
|
||||
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION",
|
||||
"rag.datalab_marker_disable_image_extraction",
|
||||
os.environ.get("DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", "false").lower()
|
||||
== "true",
|
||||
)
|
||||
|
||||
DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig(
|
||||
"DATALAB_MARKER_OUTPUT_FORMAT",
|
||||
"rag.datalab_marker_output_format",
|
||||
os.environ.get("DATALAB_MARKER_OUTPUT_FORMAT", "markdown"),
|
||||
)
|
||||
|
||||
EXTERNAL_DOCUMENT_LOADER_URL = PersistentConfig(
|
||||
"EXTERNAL_DOCUMENT_LOADER_URL",
|
||||
"rag.external_document_loader_url",
|
||||
os.environ.get("EXTERNAL_DOCUMENT_LOADER_URL", ""),
|
||||
)
|
||||
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY = PersistentConfig(
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY",
|
||||
"rag.external_document_loader_api_key",
|
||||
os.environ.get("EXTERNAL_DOCUMENT_LOADER_API_KEY", ""),
|
||||
)
|
||||
|
||||
TIKA_SERVER_URL = PersistentConfig(
|
||||
"TIKA_SERVER_URL",
|
||||
"rag.tika_server_url",
|
||||
@@ -1856,6 +1991,46 @@ DOCLING_OCR_LANG = PersistentConfig(
|
||||
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
|
||||
)
|
||||
|
||||
DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
|
||||
"DOCLING_DO_PICTURE_DESCRIPTION",
|
||||
"rag.docling_do_picture_description",
|
||||
os.getenv("DOCLING_DO_PICTURE_DESCRIPTION", "False").lower() == "true",
|
||||
)
|
||||
|
||||
DOCLING_PICTURE_DESCRIPTION_MODE = PersistentConfig(
|
||||
"DOCLING_PICTURE_DESCRIPTION_MODE",
|
||||
"rag.docling_picture_description_mode",
|
||||
os.getenv("DOCLING_PICTURE_DESCRIPTION_MODE", ""),
|
||||
)
|
||||
|
||||
|
||||
docling_picture_description_local = os.getenv("DOCLING_PICTURE_DESCRIPTION_LOCAL", "")
|
||||
try:
|
||||
docling_picture_description_local = json.loads(docling_picture_description_local)
|
||||
except json.JSONDecodeError:
|
||||
docling_picture_description_local = {}
|
||||
|
||||
|
||||
DOCLING_PICTURE_DESCRIPTION_LOCAL = PersistentConfig(
|
||||
"DOCLING_PICTURE_DESCRIPTION_LOCAL",
|
||||
"rag.docling_picture_description_local",
|
||||
docling_picture_description_local,
|
||||
)
|
||||
|
||||
docling_picture_description_api = os.getenv("DOCLING_PICTURE_DESCRIPTION_API", "")
|
||||
try:
|
||||
docling_picture_description_api = json.loads(docling_picture_description_api)
|
||||
except json.JSONDecodeError:
|
||||
docling_picture_description_api = {}
|
||||
|
||||
|
||||
DOCLING_PICTURE_DESCRIPTION_API = PersistentConfig(
|
||||
"DOCLING_PICTURE_DESCRIPTION_API",
|
||||
"rag.docling_picture_description_api",
|
||||
docling_picture_description_api,
|
||||
)
|
||||
|
||||
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
|
||||
"DOCUMENT_INTELLIGENCE_ENDPOINT",
|
||||
"rag.document_intelligence_endpoint",
|
||||
@@ -1894,6 +2069,11 @@ RAG_RELEVANCE_THRESHOLD = PersistentConfig(
|
||||
"rag.relevance_threshold",
|
||||
float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")),
|
||||
)
|
||||
RAG_HYBRID_BM25_WEIGHT = PersistentConfig(
|
||||
"RAG_HYBRID_BM25_WEIGHT",
|
||||
"rag.hybrid_bm25_weight",
|
||||
float(os.environ.get("RAG_HYBRID_BM25_WEIGHT", "0.5")),
|
||||
)
|
||||
|
||||
ENABLE_RAG_HYBRID_SEARCH = PersistentConfig(
|
||||
"ENABLE_RAG_HYBRID_SEARCH",
|
||||
@@ -1927,6 +2107,37 @@ RAG_FILE_MAX_SIZE = PersistentConfig(
|
||||
),
|
||||
)
|
||||
|
||||
FILE_IMAGE_COMPRESSION_WIDTH = PersistentConfig(
|
||||
"FILE_IMAGE_COMPRESSION_WIDTH",
|
||||
"file.image_compression_width",
|
||||
(
|
||||
int(os.environ.get("FILE_IMAGE_COMPRESSION_WIDTH"))
|
||||
if os.environ.get("FILE_IMAGE_COMPRESSION_WIDTH")
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT = PersistentConfig(
|
||||
"FILE_IMAGE_COMPRESSION_HEIGHT",
|
||||
"file.image_compression_height",
|
||||
(
|
||||
int(os.environ.get("FILE_IMAGE_COMPRESSION_HEIGHT"))
|
||||
if os.environ.get("FILE_IMAGE_COMPRESSION_HEIGHT")
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
RAG_ALLOWED_FILE_EXTENSIONS = PersistentConfig(
|
||||
"RAG_ALLOWED_FILE_EXTENSIONS",
|
||||
"rag.file.allowed_extensions",
|
||||
[
|
||||
ext.strip()
|
||||
for ext in os.environ.get("RAG_ALLOWED_FILE_EXTENSIONS", "").split(",")
|
||||
if ext.strip()
|
||||
],
|
||||
)
|
||||
|
||||
RAG_EMBEDDING_ENGINE = PersistentConfig(
|
||||
"RAG_EMBEDDING_ENGINE",
|
||||
"rag.embedding_engine",
|
||||
@@ -1972,6 +2183,12 @@ RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get(
|
||||
"RAG_EMBEDDING_PREFIX_FIELD_NAME", None
|
||||
)
|
||||
|
||||
RAG_RERANKING_ENGINE = PersistentConfig(
|
||||
"RAG_RERANKING_ENGINE",
|
||||
"rag.reranking_engine",
|
||||
os.environ.get("RAG_RERANKING_ENGINE", ""),
|
||||
)
|
||||
|
||||
RAG_RERANKING_MODEL = PersistentConfig(
|
||||
"RAG_RERANKING_MODEL",
|
||||
"rag.reranking_model",
|
||||
@@ -1980,6 +2197,7 @@ RAG_RERANKING_MODEL = PersistentConfig(
|
||||
if RAG_RERANKING_MODEL.value != "":
|
||||
log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}")
|
||||
|
||||
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE = (
|
||||
not OFFLINE_MODE
|
||||
and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true"
|
||||
@@ -1989,6 +2207,18 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = (
|
||||
os.environ.get("RAG_RERANKING_MODEL_TRUST_REMOTE_CODE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
RAG_EXTERNAL_RERANKER_URL = PersistentConfig(
|
||||
"RAG_EXTERNAL_RERANKER_URL",
|
||||
"rag.external_reranker_url",
|
||||
os.environ.get("RAG_EXTERNAL_RERANKER_URL", ""),
|
||||
)
|
||||
|
||||
RAG_EXTERNAL_RERANKER_API_KEY = PersistentConfig(
|
||||
"RAG_EXTERNAL_RERANKER_API_KEY",
|
||||
"rag.external_reranker_api_key",
|
||||
os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""),
|
||||
)
|
||||
|
||||
|
||||
RAG_TEXT_SPLITTER = PersistentConfig(
|
||||
"RAG_TEXT_SPLITTER",
|
||||
@@ -2061,6 +2291,22 @@ RAG_OPENAI_API_KEY = PersistentConfig(
|
||||
os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY),
|
||||
)
|
||||
|
||||
RAG_AZURE_OPENAI_BASE_URL = PersistentConfig(
|
||||
"RAG_AZURE_OPENAI_BASE_URL",
|
||||
"rag.azure_openai.base_url",
|
||||
os.getenv("RAG_AZURE_OPENAI_BASE_URL", ""),
|
||||
)
|
||||
RAG_AZURE_OPENAI_API_KEY = PersistentConfig(
|
||||
"RAG_AZURE_OPENAI_API_KEY",
|
||||
"rag.azure_openai.api_key",
|
||||
os.getenv("RAG_AZURE_OPENAI_API_KEY", ""),
|
||||
)
|
||||
RAG_AZURE_OPENAI_API_VERSION = PersistentConfig(
|
||||
"RAG_AZURE_OPENAI_API_VERSION",
|
||||
"rag.azure_openai.api_version",
|
||||
os.getenv("RAG_AZURE_OPENAI_API_VERSION", ""),
|
||||
)
|
||||
|
||||
RAG_OLLAMA_BASE_URL = PersistentConfig(
|
||||
"RAG_OLLAMA_BASE_URL",
|
||||
"rag.ollama.url",
|
||||
@@ -2114,6 +2360,12 @@ BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
|
||||
)
|
||||
|
||||
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER = PersistentConfig(
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER",
|
||||
"rag.web.search.bypass_web_loader",
|
||||
os.getenv("BYPASS_WEB_SEARCH_WEB_LOADER", "False").lower() == "true",
|
||||
)
|
||||
|
||||
WEB_SEARCH_RESULT_COUNT = PersistentConfig(
|
||||
"WEB_SEARCH_RESULT_COUNT",
|
||||
"rag.web.search.result_count",
|
||||
@@ -2139,6 +2391,7 @@ WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
|
||||
int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")),
|
||||
)
|
||||
|
||||
|
||||
WEB_LOADER_ENGINE = PersistentConfig(
|
||||
"WEB_LOADER_ENGINE",
|
||||
"rag.web.loader.engine",
|
||||
@@ -2298,6 +2551,18 @@ PERPLEXITY_API_KEY = PersistentConfig(
|
||||
os.getenv("PERPLEXITY_API_KEY", ""),
|
||||
)
|
||||
|
||||
PERPLEXITY_MODEL = PersistentConfig(
|
||||
"PERPLEXITY_MODEL",
|
||||
"rag.web.search.perplexity_model",
|
||||
os.getenv("PERPLEXITY_MODEL", "sonar"),
|
||||
)
|
||||
|
||||
PERPLEXITY_SEARCH_CONTEXT_USAGE = PersistentConfig(
|
||||
"PERPLEXITY_SEARCH_CONTEXT_USAGE",
|
||||
"rag.web.search.perplexity_search_context_usage",
|
||||
os.getenv("PERPLEXITY_SEARCH_CONTEXT_USAGE", "medium"),
|
||||
)
|
||||
|
||||
SOUGOU_API_SID = PersistentConfig(
|
||||
"SOUGOU_API_SID",
|
||||
"rag.web.search.sougou_api_sid",
|
||||
@@ -2662,6 +2927,18 @@ AUDIO_STT_MODEL = PersistentConfig(
|
||||
os.getenv("AUDIO_STT_MODEL", ""),
|
||||
)
|
||||
|
||||
AUDIO_STT_SUPPORTED_CONTENT_TYPES = PersistentConfig(
|
||||
"AUDIO_STT_SUPPORTED_CONTENT_TYPES",
|
||||
"audio.stt.supported_content_types",
|
||||
[
|
||||
content_type.strip()
|
||||
for content_type in os.environ.get(
|
||||
"AUDIO_STT_SUPPORTED_CONTENT_TYPES", ""
|
||||
).split(",")
|
||||
if content_type.strip()
|
||||
],
|
||||
)
|
||||
|
||||
AUDIO_STT_AZURE_API_KEY = PersistentConfig(
|
||||
"AUDIO_STT_AZURE_API_KEY",
|
||||
"audio.stt.azure.api_key",
|
||||
@@ -2689,7 +2966,7 @@ AUDIO_STT_AZURE_BASE_URL = PersistentConfig(
|
||||
AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig(
|
||||
"AUDIO_STT_AZURE_MAX_SPEAKERS",
|
||||
"audio.stt.azure.max_speakers",
|
||||
os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", "3"),
|
||||
os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", ""),
|
||||
)
|
||||
|
||||
AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig(
|
||||
@@ -2737,7 +3014,13 @@ AUDIO_TTS_SPLIT_ON = PersistentConfig(
|
||||
AUDIO_TTS_AZURE_SPEECH_REGION = PersistentConfig(
|
||||
"AUDIO_TTS_AZURE_SPEECH_REGION",
|
||||
"audio.tts.azure.speech_region",
|
||||
os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", "eastus"),
|
||||
os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", ""),
|
||||
)
|
||||
|
||||
AUDIO_TTS_AZURE_SPEECH_BASE_URL = PersistentConfig(
|
||||
"AUDIO_TTS_AZURE_SPEECH_BASE_URL",
|
||||
"audio.tts.azure.speech_base_url",
|
||||
os.getenv("AUDIO_TTS_AZURE_SPEECH_BASE_URL", ""),
|
||||
)
|
||||
|
||||
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = PersistentConfig(
|
||||
@@ -2821,6 +3104,31 @@ LDAP_CA_CERT_FILE = PersistentConfig(
|
||||
os.environ.get("LDAP_CA_CERT_FILE", ""),
|
||||
)
|
||||
|
||||
LDAP_VALIDATE_CERT = PersistentConfig(
|
||||
"LDAP_VALIDATE_CERT",
|
||||
"ldap.server.validate_cert",
|
||||
os.environ.get("LDAP_VALIDATE_CERT", "True").lower() == "true",
|
||||
)
|
||||
|
||||
LDAP_CIPHERS = PersistentConfig(
|
||||
"LDAP_CIPHERS", "ldap.server.ciphers", os.environ.get("LDAP_CIPHERS", "ALL")
|
||||
)
|
||||
|
||||
# For LDAP Group Management
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT = PersistentConfig(
|
||||
"ENABLE_LDAP_GROUP_MANAGEMENT",
|
||||
"ldap.group.enable_management",
|
||||
os.environ.get("ENABLE_LDAP_GROUP_MANAGEMENT", "False").lower() == "true",
|
||||
)
|
||||
|
||||
ENABLE_LDAP_GROUP_CREATION = PersistentConfig(
|
||||
"ENABLE_LDAP_GROUP_CREATION",
|
||||
"ldap.group.enable_creation",
|
||||
os.environ.get("ENABLE_LDAP_GROUP_CREATION", "False").lower() == "true",
|
||||
)
|
||||
|
||||
LDAP_ATTRIBUTE_FOR_GROUPS = PersistentConfig(
|
||||
"LDAP_ATTRIBUTE_FOR_GROUPS",
|
||||
"ldap.server.attribute_for_groups",
|
||||
os.environ.get("LDAP_ATTRIBUTE_FOR_GROUPS", "memberOf"),
|
||||
)
|
||||
|
||||
@@ -111,6 +111,7 @@ class TASKS(str, Enum):
|
||||
|
||||
DEFAULT = lambda task="": f"{task if task else 'generation'}"
|
||||
TITLE_GENERATION = "title_generation"
|
||||
FOLLOW_UP_GENERATION = "follow_up_generation"
|
||||
TAGS_GENERATION = "tags_generation"
|
||||
EMOJI_GENERATION = "emoji_generation"
|
||||
QUERY_GENERATION = "query_generation"
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import shutil
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
@@ -130,6 +131,7 @@ else:
|
||||
PACKAGE_DATA = {"version": "0.0.0"}
|
||||
|
||||
VERSION = PACKAGE_DATA["version"]
|
||||
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
|
||||
|
||||
|
||||
# Function to parse each section
|
||||
@@ -349,6 +351,10 @@ WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
|
||||
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
|
||||
)
|
||||
WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None)
|
||||
WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get(
|
||||
"WEBUI_AUTH_TRUSTED_GROUPS_HEADER", None
|
||||
)
|
||||
|
||||
|
||||
BYPASS_MODEL_ACCESS_CONTROL = (
|
||||
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
|
||||
@@ -533,6 +539,7 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
|
||||
####################################
|
||||
|
||||
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
|
||||
ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
||||
)
|
||||
|
||||
@@ -25,10 +25,14 @@ from open_webui.socket.main import (
|
||||
)
|
||||
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.tools import get_tools
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
@@ -53,12 +57,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
def get_function_module_by_id(request: Request, pipe_id: str):
|
||||
# Check if function is already loaded
|
||||
if pipe_id not in request.app.state.FUNCTIONS:
|
||||
function_module, _, _ = load_function_module_by_id(pipe_id)
|
||||
request.app.state.FUNCTIONS[pipe_id] = function_module
|
||||
else:
|
||||
function_module = request.app.state.FUNCTIONS[pipe_id]
|
||||
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
valves = Functions.get_function_valves_by_id(pipe_id)
|
||||
@@ -229,12 +228,7 @@ async def generate_function_chat_completion(
|
||||
"__task__": __task__,
|
||||
"__task_body__": __task_body__,
|
||||
"__files__": files,
|
||||
"__user__": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
},
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__request__": request,
|
||||
}
|
||||
@@ -255,8 +249,13 @@ async def generate_function_chat_completion(
|
||||
form_data["model"] = model_info.base_model_id
|
||||
|
||||
params = model_info.params.model_dump()
|
||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||
form_data = apply_model_system_prompt_to_body(params, form_data, metadata, user)
|
||||
|
||||
if params:
|
||||
system = params.pop("system", None)
|
||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||
form_data = apply_model_system_prompt_to_body(
|
||||
system, form_data, metadata, user
|
||||
)
|
||||
|
||||
pipe_id = get_pipe_id(form_data)
|
||||
function_module = get_function_module_by_id(request, pipe_id)
|
||||
|
||||
@@ -43,7 +43,7 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
|
||||
|
||||
|
||||
def register_connection(db_url):
|
||||
db = connect(db_url, unquote_password=True)
|
||||
db = connect(db_url, unquote_user=True, unquote_password=True)
|
||||
if isinstance(db, PostgresqlDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
@@ -51,7 +51,7 @@ def register_connection(db_url):
|
||||
log.info("Connected to PostgreSQL database")
|
||||
|
||||
# Get the connection details
|
||||
connection = parse(db_url, unquote_password=True)
|
||||
connection = parse(db_url, unquote_user=True, unquote_password=True)
|
||||
|
||||
# Use our custom database class that supports reconnection
|
||||
db = ReconnectingPostgresqlDatabase(**connection)
|
||||
|
||||
+252
-21
@@ -8,6 +8,8 @@ import shutil
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from urllib.parse import urlencode, parse_qs, urlparse
|
||||
@@ -19,6 +21,7 @@ from aiocache import cached
|
||||
import aiohttp
|
||||
import anyio.to_thread
|
||||
import requests
|
||||
from redis import Redis
|
||||
|
||||
|
||||
from fastapi import (
|
||||
@@ -37,9 +40,11 @@ from fastapi import (
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from starlette_compress import CompressMiddleware
|
||||
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
@@ -52,6 +57,8 @@ from open_webui.utils.logger import start_logger
|
||||
from open_webui.socket.main import (
|
||||
app as socket_app,
|
||||
periodic_usage_pool_cleanup,
|
||||
get_models_in_use,
|
||||
get_active_user_ids,
|
||||
)
|
||||
from open_webui.routers import (
|
||||
audio,
|
||||
@@ -103,6 +110,7 @@ from open_webui.config import (
|
||||
ENABLE_OPENAI_API,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
OPENAI_API_BASE_URLS,
|
||||
OPENAI_API_KEYS,
|
||||
OPENAI_API_CONFIGS,
|
||||
@@ -151,6 +159,7 @@ from open_webui.config import (
|
||||
# Audio
|
||||
AUDIO_STT_ENGINE,
|
||||
AUDIO_STT_MODEL,
|
||||
AUDIO_STT_SUPPORTED_CONTENT_TYPES,
|
||||
AUDIO_STT_OPENAI_API_BASE_URL,
|
||||
AUDIO_STT_OPENAI_API_KEY,
|
||||
AUDIO_STT_AZURE_API_KEY,
|
||||
@@ -166,6 +175,7 @@ from open_webui.config import (
|
||||
AUDIO_TTS_SPLIT_ON,
|
||||
AUDIO_TTS_VOICE,
|
||||
AUDIO_TTS_AZURE_SPEECH_REGION,
|
||||
AUDIO_TTS_AZURE_SPEECH_BASE_URL,
|
||||
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
PLAYWRIGHT_WS_URL,
|
||||
PLAYWRIGHT_TIMEOUT,
|
||||
@@ -186,30 +196,55 @@ from open_webui.config import (
|
||||
RAG_EMBEDDING_MODEL,
|
||||
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
|
||||
RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
|
||||
RAG_RERANKING_ENGINE,
|
||||
RAG_RERANKING_MODEL,
|
||||
RAG_EXTERNAL_RERANKER_URL,
|
||||
RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE,
|
||||
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
|
||||
RAG_EMBEDDING_ENGINE,
|
||||
RAG_EMBEDDING_BATCH_SIZE,
|
||||
RAG_TOP_K,
|
||||
RAG_TOP_K_RERANKER,
|
||||
RAG_RELEVANCE_THRESHOLD,
|
||||
RAG_HYBRID_BM25_WEIGHT,
|
||||
RAG_ALLOWED_FILE_EXTENSIONS,
|
||||
RAG_FILE_MAX_COUNT,
|
||||
RAG_FILE_MAX_SIZE,
|
||||
FILE_IMAGE_COMPRESSION_WIDTH,
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT,
|
||||
RAG_OPENAI_API_BASE_URL,
|
||||
RAG_OPENAI_API_KEY,
|
||||
RAG_AZURE_OPENAI_BASE_URL,
|
||||
RAG_AZURE_OPENAI_API_KEY,
|
||||
RAG_AZURE_OPENAI_API_VERSION,
|
||||
RAG_OLLAMA_BASE_URL,
|
||||
RAG_OLLAMA_API_KEY,
|
||||
CHUNK_OVERLAP,
|
||||
CHUNK_SIZE,
|
||||
CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_LANGS,
|
||||
DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
DATALAB_MARKER_USE_LLM,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL,
|
||||
DOCLING_OCR_ENGINE,
|
||||
DOCLING_OCR_LANG,
|
||||
DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
DOCLING_PICTURE_DESCRIPTION_API,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY,
|
||||
RAG_TOP_K,
|
||||
RAG_TOP_K_RERANKER,
|
||||
RAG_TEXT_SPLITTER,
|
||||
TIKTOKEN_ENCODING_NAME,
|
||||
PDF_EXTRACT_IMAGES,
|
||||
@@ -219,6 +254,7 @@ from open_webui.config import (
|
||||
ENABLE_WEB_SEARCH,
|
||||
WEB_SEARCH_ENGINE,
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
WEB_SEARCH_RESULT_COUNT,
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
WEB_SEARCH_TRUST_ENV,
|
||||
@@ -243,6 +279,8 @@ from open_webui.config import (
|
||||
BRAVE_SEARCH_API_KEY,
|
||||
EXA_API_KEY,
|
||||
PERPLEXITY_API_KEY,
|
||||
PERPLEXITY_MODEL,
|
||||
PERPLEXITY_SEARCH_CONTEXT_USAGE,
|
||||
SOUGOU_API_SID,
|
||||
SOUGOU_API_SK,
|
||||
KAGI_SEARCH_API_KEY,
|
||||
@@ -254,6 +292,7 @@ from open_webui.config import (
|
||||
GOOGLE_DRIVE_API_KEY,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
ENABLE_RAG_HYBRID_SEARCH,
|
||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||
ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
@@ -285,6 +324,8 @@ from open_webui.config import (
|
||||
ENABLE_EVALUATION_ARENA_MODELS,
|
||||
USER_PERMISSIONS,
|
||||
DEFAULT_USER_ROLE,
|
||||
PENDING_USER_OVERLAY_CONTENT,
|
||||
PENDING_USER_OVERLAY_TITLE,
|
||||
DEFAULT_PROMPT_SUGGESTIONS,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
@@ -311,7 +352,12 @@ from open_webui.config import (
|
||||
LDAP_APP_PASSWORD,
|
||||
LDAP_USE_TLS,
|
||||
LDAP_CA_CERT_FILE,
|
||||
LDAP_VALIDATE_CERT,
|
||||
LDAP_CIPHERS,
|
||||
# LDAP Group Management
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT,
|
||||
ENABLE_LDAP_GROUP_CREATION,
|
||||
LDAP_ATTRIBUTE_FOR_GROUPS,
|
||||
# Misc
|
||||
ENV,
|
||||
CACHE_DIR,
|
||||
@@ -321,6 +367,7 @@ from open_webui.config import (
|
||||
DEFAULT_LOCALE,
|
||||
OAUTH_PROVIDERS,
|
||||
WEBUI_URL,
|
||||
RESPONSE_WATERMARK,
|
||||
# Admin
|
||||
ENABLE_ADMIN_CHAT_ACCESS,
|
||||
ENABLE_ADMIN_EXPORT,
|
||||
@@ -329,10 +376,12 @@ from open_webui.config import (
|
||||
TASK_MODEL_EXTERNAL,
|
||||
ENABLE_TAGS_GENERATION,
|
||||
ENABLE_TITLE_GENERATION,
|
||||
ENABLE_FOLLOW_UP_GENERATION,
|
||||
ENABLE_SEARCH_QUERY_GENERATION,
|
||||
ENABLE_RETRIEVAL_QUERY_GENERATION,
|
||||
ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
@@ -354,6 +403,7 @@ from open_webui.env import (
|
||||
SAFE_MODE,
|
||||
SRC_LOG_LEVELS,
|
||||
VERSION,
|
||||
INSTANCE_ID,
|
||||
WEBUI_BUILD_HASH,
|
||||
WEBUI_SECRET_KEY,
|
||||
WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
@@ -367,6 +417,7 @@ from open_webui.env import (
|
||||
OFFLINE_MODE,
|
||||
ENABLE_OTEL,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
|
||||
@@ -380,6 +431,7 @@ from open_webui.utils.chat import (
|
||||
chat_completed as chat_completed_handler,
|
||||
chat_action as chat_action_handler,
|
||||
)
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.middleware import process_chat_payload, process_chat_response
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
@@ -393,8 +445,10 @@ from open_webui.utils.auth import (
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.oauth import OAuthManager
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
from open_webui.tasks import (
|
||||
redis_task_command_listener,
|
||||
list_task_ids_by_chat_id,
|
||||
stop_task,
|
||||
list_tasks,
|
||||
@@ -437,7 +491,7 @@ print(
|
||||
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚══╝╚══╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝
|
||||
|
||||
|
||||
v{VERSION} - building the best open-source AI user interface.
|
||||
v{VERSION} - building the best AI user interface.
|
||||
{f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""}
|
||||
https://github.com/open-webui/open-webui
|
||||
"""
|
||||
@@ -446,7 +500,9 @@ https://github.com/open-webui/open-webui
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.instance_id = INSTANCE_ID
|
||||
start_logger()
|
||||
|
||||
if RESET_CONFIG_ON_START:
|
||||
reset_config()
|
||||
|
||||
@@ -458,15 +514,30 @@ async def lifespan(app: FastAPI):
|
||||
log.info("Installing external dependencies of functions and tools...")
|
||||
install_tool_and_function_dependencies()
|
||||
|
||||
pool_size = THREAD_POOL_SIZE
|
||||
if pool_size and pool_size > 0:
|
||||
app.state.redis = get_redis_connection(
|
||||
redis_url=REDIS_URL,
|
||||
redis_sentinels=get_sentinels_from_env(
|
||||
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
|
||||
),
|
||||
async_mode=True,
|
||||
)
|
||||
|
||||
if app.state.redis is not None:
|
||||
app.state.redis_task_command_listener = asyncio.create_task(
|
||||
redis_task_command_listener(app)
|
||||
)
|
||||
|
||||
if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0:
|
||||
limiter = anyio.to_thread.current_default_thread_limiter()
|
||||
limiter.total_tokens = pool_size
|
||||
limiter.total_tokens = THREAD_POOL_SIZE
|
||||
|
||||
asyncio.create_task(periodic_usage_pool_cleanup())
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(app.state, "redis_task_command_listener"):
|
||||
app.state.redis_task_command_listener.cancel()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Open WebUI",
|
||||
@@ -478,10 +549,12 @@ app = FastAPI(
|
||||
|
||||
oauth_manager = OAuthManager(app)
|
||||
|
||||
app.state.instance_id = None
|
||||
app.state.config = AppConfig(
|
||||
redis_url=REDIS_URL,
|
||||
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
|
||||
)
|
||||
app.state.redis = None
|
||||
|
||||
app.state.WEBUI_NAME = WEBUI_NAME
|
||||
app.state.LICENSE_METADATA = None
|
||||
@@ -568,6 +641,11 @@ app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
|
||||
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
|
||||
app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
|
||||
|
||||
app.state.config.PENDING_USER_OVERLAY_CONTENT = PENDING_USER_OVERLAY_CONTENT
|
||||
app.state.config.PENDING_USER_OVERLAY_TITLE = PENDING_USER_OVERLAY_TITLE
|
||||
|
||||
app.state.config.RESPONSE_WATERMARK = RESPONSE_WATERMARK
|
||||
|
||||
app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
|
||||
app.state.config.WEBHOOK_URL = WEBHOOK_URL
|
||||
app.state.config.BANNERS = WEBUI_BANNERS
|
||||
@@ -604,8 +682,14 @@ app.state.config.LDAP_SEARCH_BASE = LDAP_SEARCH_BASE
|
||||
app.state.config.LDAP_SEARCH_FILTERS = LDAP_SEARCH_FILTERS
|
||||
app.state.config.LDAP_USE_TLS = LDAP_USE_TLS
|
||||
app.state.config.LDAP_CA_CERT_FILE = LDAP_CA_CERT_FILE
|
||||
app.state.config.LDAP_VALIDATE_CERT = LDAP_VALIDATE_CERT
|
||||
app.state.config.LDAP_CIPHERS = LDAP_CIPHERS
|
||||
|
||||
# For LDAP Group Management
|
||||
app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT = ENABLE_LDAP_GROUP_MANAGEMENT
|
||||
app.state.config.ENABLE_LDAP_GROUP_CREATION = ENABLE_LDAP_GROUP_CREATION
|
||||
app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS = LDAP_ATTRIBUTE_FOR_GROUPS
|
||||
|
||||
|
||||
app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER
|
||||
app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER
|
||||
@@ -613,8 +697,12 @@ app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL
|
||||
app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL
|
||||
|
||||
app.state.USER_COUNT = None
|
||||
|
||||
app.state.TOOLS = {}
|
||||
app.state.TOOL_CONTENTS = {}
|
||||
|
||||
app.state.FUNCTIONS = {}
|
||||
app.state.FUNCTION_CONTENTS = {}
|
||||
|
||||
########################################
|
||||
#
|
||||
@@ -626,8 +714,14 @@ app.state.FUNCTIONS = {}
|
||||
app.state.config.TOP_K = RAG_TOP_K
|
||||
app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER
|
||||
app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD
|
||||
app.state.config.HYBRID_BM25_WEIGHT = RAG_HYBRID_BM25_WEIGHT
|
||||
|
||||
|
||||
app.state.config.ALLOWED_FILE_EXTENSIONS = RAG_ALLOWED_FILE_EXTENSIONS
|
||||
app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE
|
||||
app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT
|
||||
app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = FILE_IMAGE_COMPRESSION_WIDTH
|
||||
app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
|
||||
|
||||
app.state.config.RAG_FULL_CONTEXT = RAG_FULL_CONTEXT
|
||||
@@ -636,10 +730,27 @@ app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH
|
||||
app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERIFICATION
|
||||
|
||||
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
|
||||
app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY
|
||||
app.state.config.DATALAB_MARKER_LANGS = DATALAB_MARKER_LANGS
|
||||
app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE
|
||||
app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR
|
||||
app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE
|
||||
app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTING_OCR
|
||||
app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
|
||||
)
|
||||
app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM
|
||||
app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT
|
||||
app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
|
||||
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
|
||||
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
|
||||
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
|
||||
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
|
||||
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
|
||||
app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
|
||||
app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE
|
||||
app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
|
||||
app.state.config.DOCLING_PICTURE_DESCRIPTION_API = DOCLING_PICTURE_DESCRIPTION_API
|
||||
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
|
||||
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
|
||||
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
|
||||
@@ -653,12 +764,21 @@ app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP
|
||||
app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
|
||||
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
|
||||
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
|
||||
|
||||
app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
|
||||
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY
|
||||
|
||||
app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
|
||||
|
||||
app.state.config.RAG_OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL
|
||||
app.state.config.RAG_OPENAI_API_KEY = RAG_OPENAI_API_KEY
|
||||
|
||||
app.state.config.RAG_AZURE_OPENAI_BASE_URL = RAG_AZURE_OPENAI_BASE_URL
|
||||
app.state.config.RAG_AZURE_OPENAI_API_KEY = RAG_AZURE_OPENAI_API_KEY
|
||||
app.state.config.RAG_AZURE_OPENAI_API_VERSION = RAG_AZURE_OPENAI_API_VERSION
|
||||
|
||||
app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL
|
||||
app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY
|
||||
|
||||
@@ -678,6 +798,7 @@ app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
|
||||
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||
)
|
||||
app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
|
||||
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
|
||||
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
|
||||
@@ -705,6 +826,8 @@ app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
|
||||
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
|
||||
app.state.config.EXA_API_KEY = EXA_API_KEY
|
||||
app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY
|
||||
app.state.config.PERPLEXITY_MODEL = PERPLEXITY_MODEL
|
||||
app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = PERPLEXITY_SEARCH_CONTEXT_USAGE
|
||||
app.state.config.SOUGOU_API_SID = SOUGOU_API_SID
|
||||
app.state.config.SOUGOU_API_SK = SOUGOU_API_SK
|
||||
app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
|
||||
@@ -734,7 +857,10 @@ try:
|
||||
)
|
||||
|
||||
app.state.rf = get_rf(
|
||||
app.state.config.RAG_RERANKING_ENGINE,
|
||||
app.state.config.RAG_RERANKING_MODEL,
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -749,14 +875,27 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||
(
|
||||
app.state.config.RAG_OPENAI_API_BASE_URL
|
||||
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||
else app.state.config.RAG_OLLAMA_BASE_URL
|
||||
else (
|
||||
app.state.config.RAG_OLLAMA_BASE_URL
|
||||
if app.state.config.RAG_EMBEDDING_ENGINE == "ollama"
|
||||
else app.state.config.RAG_AZURE_OPENAI_BASE_URL
|
||||
)
|
||||
),
|
||||
(
|
||||
app.state.config.RAG_OPENAI_API_KEY
|
||||
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||
else app.state.config.RAG_OLLAMA_API_KEY
|
||||
else (
|
||||
app.state.config.RAG_OLLAMA_API_KEY
|
||||
if app.state.config.RAG_EMBEDDING_ENGINE == "ollama"
|
||||
else app.state.config.RAG_AZURE_OPENAI_API_KEY
|
||||
)
|
||||
),
|
||||
app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
azure_api_version=(
|
||||
app.state.config.RAG_AZURE_OPENAI_API_VERSION
|
||||
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
########################################
|
||||
@@ -827,10 +966,12 @@ app.state.config.IMAGE_STEPS = IMAGE_STEPS
|
||||
#
|
||||
########################################
|
||||
|
||||
app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL
|
||||
app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY
|
||||
app.state.config.STT_ENGINE = AUDIO_STT_ENGINE
|
||||
app.state.config.STT_MODEL = AUDIO_STT_MODEL
|
||||
app.state.config.STT_SUPPORTED_CONTENT_TYPES = AUDIO_STT_SUPPORTED_CONTENT_TYPES
|
||||
|
||||
app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL
|
||||
app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY
|
||||
|
||||
app.state.config.WHISPER_MODEL = WHISPER_MODEL
|
||||
app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER
|
||||
@@ -852,6 +993,7 @@ app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON
|
||||
|
||||
|
||||
app.state.config.TTS_AZURE_SPEECH_REGION = AUDIO_TTS_AZURE_SPEECH_REGION
|
||||
app.state.config.TTS_AZURE_SPEECH_BASE_URL = AUDIO_TTS_AZURE_SPEECH_BASE_URL
|
||||
app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT
|
||||
|
||||
|
||||
@@ -876,6 +1018,7 @@ app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = ENABLE_RETRIEVAL_QUERY_GENE
|
||||
app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ENABLE_AUTOCOMPLETE_GENERATION
|
||||
app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
|
||||
app.state.config.ENABLE_TITLE_GENERATION = ENABLE_TITLE_GENERATION
|
||||
app.state.config.ENABLE_FOLLOW_UP_GENERATION = ENABLE_FOLLOW_UP_GENERATION
|
||||
|
||||
|
||||
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
@@ -883,6 +1026,9 @@ app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLA
|
||||
app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
)
|
||||
app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = (
|
||||
FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
)
|
||||
|
||||
app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
|
||||
@@ -926,6 +1072,7 @@ class RedirectMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
# Add the middleware to the app
|
||||
app.add_middleware(CompressMiddleware)
|
||||
app.add_middleware(RedirectMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
@@ -1113,6 +1260,37 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)):
|
||||
return {"data": models}
|
||||
|
||||
|
||||
##################################
|
||||
# Embeddings
|
||||
##################################
|
||||
|
||||
|
||||
@app.post("/api/embeddings")
|
||||
async def embeddings(
|
||||
request: Request, form_data: dict, user=Depends(get_verified_user)
|
||||
):
|
||||
"""
|
||||
OpenAI-compatible embeddings endpoint.
|
||||
|
||||
This handler:
|
||||
- Performs user/model checks and dispatches to the correct backend.
|
||||
- Supports OpenAI, Ollama, arena models, pipelines, and any compatible provider.
|
||||
|
||||
Args:
|
||||
request (Request): Request context.
|
||||
form_data (dict): OpenAI-like payload (e.g., {"model": "...", "input": [...]})
|
||||
user (UserModel): Authenticated user.
|
||||
|
||||
Returns:
|
||||
dict: OpenAI-compatible embeddings response.
|
||||
"""
|
||||
# Make sure models are loaded in app state
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request, user=user)
|
||||
# Use generic dispatcher in utils.embeddings
|
||||
return await generate_embeddings(request, form_data, user)
|
||||
|
||||
|
||||
@app.post("/api/chat/completions")
|
||||
async def chat_completion(
|
||||
request: Request,
|
||||
@@ -1153,11 +1331,12 @@ async def chat_completion(
|
||||
"chat_id": form_data.pop("chat_id", None),
|
||||
"message_id": form_data.pop("id", None),
|
||||
"session_id": form_data.pop("session_id", None),
|
||||
"filter_ids": form_data.pop("filter_ids", []),
|
||||
"tool_ids": form_data.get("tool_ids", None),
|
||||
"tool_servers": form_data.pop("tool_servers", None),
|
||||
"files": form_data.get("files", None),
|
||||
"features": form_data.get("features", None),
|
||||
"variables": form_data.get("variables", None),
|
||||
"features": form_data.get("features", {}),
|
||||
"variables": form_data.get("variables", {}),
|
||||
"model": model,
|
||||
"direct": model_item.get("direct", False),
|
||||
**(
|
||||
@@ -1203,6 +1382,17 @@ async def chat_completion(
|
||||
request, response, form_data, user, metadata, model, events, tasks
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Error in chat completion: {e}")
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
# Update the chat message with the error
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"error": {"content": str(e)},
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
@@ -1253,26 +1443,30 @@ async def chat_action(
|
||||
|
||||
|
||||
@app.post("/api/tasks/stop/{task_id}")
|
||||
async def stop_task_endpoint(task_id: str, user=Depends(get_verified_user)):
|
||||
async def stop_task_endpoint(
|
||||
request: Request, task_id: str, user=Depends(get_verified_user)
|
||||
):
|
||||
try:
|
||||
result = await stop_task(task_id)
|
||||
result = await stop_task(request, task_id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
async def list_tasks_endpoint(user=Depends(get_verified_user)):
|
||||
return {"tasks": list_tasks()}
|
||||
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
|
||||
return {"tasks": await list_tasks(request)}
|
||||
|
||||
|
||||
@app.get("/api/tasks/chat/{chat_id}")
|
||||
async def list_tasks_by_chat_id_endpoint(chat_id: str, user=Depends(get_verified_user)):
|
||||
async def list_tasks_by_chat_id_endpoint(
|
||||
request: Request, chat_id: str, user=Depends(get_verified_user)
|
||||
):
|
||||
chat = Chats.get_chat_by_id(chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
return {"task_ids": []}
|
||||
|
||||
task_ids = list_task_ids_by_chat_id(chat_id)
|
||||
task_ids = await list_task_ids_by_chat_id(request, chat_id)
|
||||
|
||||
print(f"Task IDs for chat {chat_id}: {task_ids}")
|
||||
return {"task_ids": task_ids}
|
||||
@@ -1370,6 +1564,10 @@ async def get_app_config(request: Request):
|
||||
"file": {
|
||||
"max_size": app.state.config.FILE_MAX_SIZE,
|
||||
"max_count": app.state.config.FILE_MAX_COUNT,
|
||||
"image_compression": {
|
||||
"width": app.state.config.FILE_IMAGE_COMPRESSION_WIDTH,
|
||||
"height": app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT,
|
||||
},
|
||||
},
|
||||
"permissions": {**app.state.config.USER_PERMISSIONS},
|
||||
"google_drive": {
|
||||
@@ -1379,6 +1577,12 @@ async def get_app_config(request: Request):
|
||||
"onedrive": {
|
||||
"client_id": ONEDRIVE_CLIENT_ID.value,
|
||||
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
|
||||
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
|
||||
},
|
||||
"ui": {
|
||||
"pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
"pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
"response_watermark": app.state.config.RESPONSE_WATERMARK,
|
||||
},
|
||||
"license_metadata": app.state.LICENSE_METADATA,
|
||||
**(
|
||||
@@ -1431,7 +1635,8 @@ async def get_app_latest_release_version(user=Depends(get_verified_user)):
|
||||
timeout = aiohttp.ClientTimeout(total=1)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
"https://api.github.com/repos/open-webui/open-webui/releases/latest"
|
||||
"https://api.github.com/repos/open-webui/open-webui/releases/latest",
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
@@ -1448,6 +1653,19 @@ async def get_app_changelog():
|
||||
return {key: CHANGELOG[key] for idx, key in enumerate(CHANGELOG) if idx < 5}
|
||||
|
||||
|
||||
@app.get("/api/usage")
|
||||
async def get_current_usage(user=Depends(get_verified_user)):
|
||||
"""
|
||||
Get current usage statistics for Open WebUI.
|
||||
This is an experimental endpoint and subject to change.
|
||||
"""
|
||||
try:
|
||||
return {"model_ids": get_models_in_use(), "user_ids": get_active_user_ids()}
|
||||
except Exception as e:
|
||||
log.error(f"Error getting usage statistics: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
|
||||
############################
|
||||
# OAuth Login & Callback
|
||||
############################
|
||||
@@ -1536,7 +1754,20 @@ async def healthcheck_with_db():
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache")
|
||||
|
||||
|
||||
@app.get("/cache/{path:path}")
|
||||
async def serve_cache_file(
|
||||
path: str,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
file_path = os.path.abspath(os.path.join(CACHE_DIR, path))
|
||||
# prevent path traversal
|
||||
if not file_path.startswith(os.path.abspath(CACHE_DIR)):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(file_path)
|
||||
|
||||
|
||||
def swagger_ui_html(*args, **kwargs):
|
||||
|
||||
@@ -129,12 +129,16 @@ class AuthsTable:
|
||||
|
||||
def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user: {email}")
|
||||
|
||||
user = Users.get_user_by_email(email)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
try:
|
||||
with get_db() as db:
|
||||
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
||||
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
|
||||
if auth:
|
||||
if verify_password(password, auth.password):
|
||||
user = Users.get_user_by_id(auth.id)
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
@@ -155,8 +159,8 @@ class AuthsTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def authenticate_user_by_trusted_header(self, email: str) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user_by_trusted_header: {email}")
|
||||
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user_by_email: {email}")
|
||||
try:
|
||||
with get_db() as db:
|
||||
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
||||
|
||||
@@ -377,22 +377,47 @@ class ChatTable:
|
||||
return False
|
||||
|
||||
def get_archived_chat_list_by_user_id(
|
||||
self, user_id: str, skip: int = 0, limit: int = 50
|
||||
self,
|
||||
user_id: str,
|
||||
filter: Optional[dict] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[ChatModel]:
|
||||
|
||||
with get_db() as db:
|
||||
all_chats = (
|
||||
db.query(Chat)
|
||||
.filter_by(user_id=user_id, archived=True)
|
||||
.order_by(Chat.updated_at.desc())
|
||||
# .limit(limit).offset(skip)
|
||||
.all()
|
||||
)
|
||||
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by and direction and getattr(Chat, order_by):
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_chat_list_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
include_archived: bool = False,
|
||||
filter: Optional[dict] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[ChatModel]:
|
||||
@@ -401,7 +426,23 @@ class ChatTable:
|
||||
if not include_archived:
|
||||
query = query.filter_by(archived=False)
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by and direction and getattr(Chat, order_by):
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
@@ -542,7 +583,9 @@ class ChatTable:
|
||||
search_text = search_text.lower().strip()
|
||||
|
||||
if not search_text:
|
||||
return self.get_chat_list_by_user_id(user_id, include_archived, skip, limit)
|
||||
return self.get_chat_list_by_user_id(
|
||||
user_id, include_archived, filter={}, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
search_text_words = search_text.split(" ")
|
||||
|
||||
|
||||
@@ -108,6 +108,54 @@ class FunctionsTable:
|
||||
log.exception(f"Error creating a new function: {e}")
|
||||
return None
|
||||
|
||||
def sync_functions(
|
||||
self, user_id: str, functions: list[FunctionModel]
|
||||
) -> list[FunctionModel]:
|
||||
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
|
||||
try:
|
||||
with get_db() as db:
|
||||
# Get existing functions
|
||||
existing_functions = db.query(Function).all()
|
||||
existing_ids = {func.id for func in existing_functions}
|
||||
|
||||
# Prepare a set of new function IDs
|
||||
new_function_ids = {func.id for func in functions}
|
||||
|
||||
# Update or insert functions
|
||||
for func in functions:
|
||||
if func.id in existing_ids:
|
||||
db.query(Function).filter_by(id=func.id).update(
|
||||
{
|
||||
**func.model_dump(),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
else:
|
||||
new_func = Function(
|
||||
**{
|
||||
**func.model_dump(),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(new_func)
|
||||
|
||||
# Remove functions that are no longer present
|
||||
for func in existing_functions:
|
||||
if func.id not in new_function_ids:
|
||||
db.delete(func)
|
||||
|
||||
db.commit()
|
||||
|
||||
return [
|
||||
FunctionModel.model_validate(func)
|
||||
for func in db.query(Function).all()
|
||||
]
|
||||
except Exception as e:
|
||||
log.exception(f"Error syncing functions for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
|
||||
@@ -207,5 +207,73 @@ class GroupTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def create_groups_by_group_names(
|
||||
self, user_id: str, group_names: list[str]
|
||||
) -> list[GroupModel]:
|
||||
|
||||
# check for existing groups
|
||||
existing_groups = self.get_groups()
|
||||
existing_group_names = {group.name for group in existing_groups}
|
||||
|
||||
new_groups = []
|
||||
|
||||
with get_db() as db:
|
||||
for group_name in group_names:
|
||||
if group_name not in existing_group_names:
|
||||
new_group = GroupModel(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=group_name,
|
||||
description="",
|
||||
created_at=int(time.time()),
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
try:
|
||||
result = Group(**new_group.model_dump())
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
new_groups.append(GroupModel.model_validate(result))
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
continue
|
||||
return new_groups
|
||||
|
||||
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool:
|
||||
with get_db() as db:
|
||||
try:
|
||||
groups = db.query(Group).filter(Group.name.in_(group_names)).all()
|
||||
group_ids = [group.id for group in groups]
|
||||
|
||||
# Remove user from groups not in the new list
|
||||
existing_groups = self.get_groups_by_member_id(user_id)
|
||||
|
||||
for group in existing_groups:
|
||||
if group.id not in group_ids:
|
||||
group.user_ids.remove(user_id)
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{
|
||||
"user_ids": group.user_ids,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
# Add user to new groups
|
||||
for group in groups:
|
||||
if user_id not in group.user_ids:
|
||||
group.user_ids.append(user_id)
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{
|
||||
"user_ids": group.user_ids,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return False
|
||||
|
||||
|
||||
Groups = GroupTable()
|
||||
|
||||
@@ -95,6 +95,7 @@ class UserRoleUpdateForm(BaseModel):
|
||||
|
||||
|
||||
class UserUpdateForm(BaseModel):
|
||||
role: str
|
||||
name: str
|
||||
email: str
|
||||
profile_image_url: str
|
||||
@@ -369,7 +370,7 @@ class UsersTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def update_user_api_key_by_id(self, id: str, api_key: str) -> str:
|
||||
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
|
||||
try:
|
||||
with get_db() as db:
|
||||
result = db.query(User).filter_by(id=id).update({"api_key": api_key})
|
||||
@@ -391,5 +392,13 @@ class UsersTable:
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
return [user.id for user in users]
|
||||
|
||||
def get_super_admin_user(self) -> Optional[UserModel]:
|
||||
with get_db() as db:
|
||||
user = db.query(User).filter_by(role="admin").first()
|
||||
if user:
|
||||
return UserModel.model_validate(user)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
Users = UsersTable()
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from langchain_core.documents import Document
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DatalabMarkerLoader:
|
||||
def __init__(
|
||||
self,
|
||||
file_path: str,
|
||||
api_key: str,
|
||||
langs: Optional[str] = None,
|
||||
use_llm: bool = False,
|
||||
skip_cache: bool = False,
|
||||
force_ocr: bool = False,
|
||||
paginate: bool = False,
|
||||
strip_existing_ocr: bool = False,
|
||||
disable_image_extraction: bool = False,
|
||||
output_format: str = None,
|
||||
):
|
||||
self.file_path = file_path
|
||||
self.api_key = api_key
|
||||
self.langs = langs
|
||||
self.use_llm = use_llm
|
||||
self.skip_cache = skip_cache
|
||||
self.force_ocr = force_ocr
|
||||
self.paginate = paginate
|
||||
self.strip_existing_ocr = strip_existing_ocr
|
||||
self.disable_image_extraction = disable_image_extraction
|
||||
self.output_format = output_format
|
||||
|
||||
def _get_mime_type(self, filename: str) -> str:
|
||||
ext = filename.rsplit(".", 1)[-1].lower()
|
||||
mime_map = {
|
||||
"pdf": "application/pdf",
|
||||
"xls": "application/vnd.ms-excel",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
"doc": "application/msword",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"odt": "application/vnd.oasis.opendocument.text",
|
||||
"ppt": "application/vnd.ms-powerpoint",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"odp": "application/vnd.oasis.opendocument.presentation",
|
||||
"html": "text/html",
|
||||
"epub": "application/epub+zip",
|
||||
"png": "image/png",
|
||||
"jpeg": "image/jpeg",
|
||||
"jpg": "image/jpeg",
|
||||
"webp": "image/webp",
|
||||
"gif": "image/gif",
|
||||
"tiff": "image/tiff",
|
||||
}
|
||||
return mime_map.get(ext, "application/octet-stream")
|
||||
|
||||
def check_marker_request_status(self, request_id: str) -> dict:
|
||||
url = f"https://www.datalab.to/api/v1/marker/{request_id}"
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
log.info(f"Marker API status check for request {request_id}: {result}")
|
||||
return result
|
||||
except requests.HTTPError as e:
|
||||
log.error(f"Error checking Marker request status: {e}")
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Failed to check Marker request: {e}",
|
||||
)
|
||||
except ValueError as e:
|
||||
log.error(f"Invalid JSON checking Marker request: {e}")
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON: {e}"
|
||||
)
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
url = "https://www.datalab.to/api/v1/marker"
|
||||
filename = os.path.basename(self.file_path)
|
||||
mime_type = self._get_mime_type(filename)
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
|
||||
form_data = {
|
||||
"langs": self.langs,
|
||||
"use_llm": str(self.use_llm).lower(),
|
||||
"skip_cache": str(self.skip_cache).lower(),
|
||||
"force_ocr": str(self.force_ocr).lower(),
|
||||
"paginate": str(self.paginate).lower(),
|
||||
"strip_existing_ocr": str(self.strip_existing_ocr).lower(),
|
||||
"disable_image_extraction": str(self.disable_image_extraction).lower(),
|
||||
"output_format": self.output_format,
|
||||
}
|
||||
|
||||
log.info(
|
||||
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
files = {"file": (filename, f, mime_type)}
|
||||
response = requests.post(
|
||||
url, data=form_data, files=files, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
|
||||
)
|
||||
except requests.HTTPError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Datalab Marker request failed: {e}",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON response: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Datalab Marker request failed: {result.get('error', 'Unknown error')}",
|
||||
)
|
||||
|
||||
check_url = result.get("request_check_url")
|
||||
request_id = result.get("request_id")
|
||||
if not check_url:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned."
|
||||
)
|
||||
|
||||
for _ in range(300): # Up to 10 minutes
|
||||
time.sleep(2)
|
||||
try:
|
||||
poll_response = requests.get(check_url, headers=headers)
|
||||
poll_response.raise_for_status()
|
||||
poll_result = poll_response.json()
|
||||
except (requests.HTTPError, ValueError) as e:
|
||||
raw_body = poll_response.text
|
||||
log.error(f"Polling error: {e}, response body: {raw_body}")
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
|
||||
)
|
||||
|
||||
status_val = poll_result.get("status")
|
||||
success_val = poll_result.get("success")
|
||||
|
||||
if status_val == "complete":
|
||||
summary = {
|
||||
k: poll_result.get(k)
|
||||
for k in (
|
||||
"status",
|
||||
"output_format",
|
||||
"success",
|
||||
"error",
|
||||
"page_count",
|
||||
"total_cost",
|
||||
)
|
||||
}
|
||||
log.info(
|
||||
f"Marker processing completed successfully: {json.dumps(summary, indent=2)}"
|
||||
)
|
||||
break
|
||||
|
||||
if status_val == "failed" or success_val is False:
|
||||
log.error(
|
||||
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
|
||||
)
|
||||
error_msg = (
|
||||
poll_result.get("error")
|
||||
or "Marker returned failure without error message"
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Marker processing failed: {error_msg}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out"
|
||||
)
|
||||
|
||||
if not poll_result.get("success", False):
|
||||
error_msg = poll_result.get("error") or "Unknown processing error"
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Final processing failed: {error_msg}",
|
||||
)
|
||||
|
||||
content_key = self.output_format.lower()
|
||||
raw_content = poll_result.get(content_key)
|
||||
|
||||
if content_key == "json":
|
||||
full_text = json.dumps(raw_content, indent=2)
|
||||
elif content_key in {"markdown", "html"}:
|
||||
full_text = str(raw_content).strip()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported output format: {self.output_format}",
|
||||
)
|
||||
|
||||
if not full_text:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="Datalab Marker returned empty content",
|
||||
)
|
||||
|
||||
marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output")
|
||||
os.makedirs(marker_output_dir, exist_ok=True)
|
||||
|
||||
file_ext_map = {"markdown": "md", "json": "json", "html": "html"}
|
||||
file_ext = file_ext_map.get(content_key, "txt")
|
||||
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
|
||||
output_path = os.path.join(marker_output_dir, output_filename)
|
||||
|
||||
try:
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(full_text)
|
||||
log.info(f"Saved Marker output to: {output_path}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to write marker output to disk: {e}")
|
||||
|
||||
metadata = {
|
||||
"source": filename,
|
||||
"output_format": poll_result.get("output_format", self.output_format),
|
||||
"page_count": poll_result.get("page_count", 0),
|
||||
"processed_with_llm": self.use_llm,
|
||||
"request_id": request_id or "",
|
||||
}
|
||||
|
||||
images = poll_result.get("images", {})
|
||||
if images:
|
||||
metadata["image_count"] = len(images)
|
||||
metadata["images"] = json.dumps(list(images.keys()))
|
||||
|
||||
for k, v in metadata.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
metadata[k] = json.dumps(v)
|
||||
elif v is None:
|
||||
metadata[k] = ""
|
||||
|
||||
return [Document(page_content=full_text, metadata=metadata)]
|
||||
@@ -0,0 +1,83 @@
|
||||
import requests
|
||||
import logging, os
|
||||
from typing import Iterator, List, Union
|
||||
|
||||
from langchain_core.document_loaders import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class ExternalDocumentLoader(BaseLoader):
|
||||
def __init__(
|
||||
self,
|
||||
file_path,
|
||||
url: str,
|
||||
api_key: str,
|
||||
mime_type=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.api_key = api_key
|
||||
|
||||
self.file_path = file_path
|
||||
self.mime_type = mime_type
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
with open(self.file_path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
headers = {}
|
||||
if self.mime_type is not None:
|
||||
headers["Content-Type"] = self.mime_type
|
||||
|
||||
if self.api_key is not None:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
try:
|
||||
headers["X-Filename"] = os.path.basename(self.file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
url = self.url
|
||||
if url.endswith("/"):
|
||||
url = url[:-1]
|
||||
|
||||
try:
|
||||
response = requests.put(f"{url}/process", data=data, headers=headers)
|
||||
except Exception as e:
|
||||
log.error(f"Error connecting to endpoint: {e}")
|
||||
raise Exception(f"Error connecting to endpoint: {e}")
|
||||
|
||||
if response.ok:
|
||||
|
||||
response_data = response.json()
|
||||
if response_data:
|
||||
if isinstance(response_data, dict):
|
||||
return [
|
||||
Document(
|
||||
page_content=response_data.get("page_content"),
|
||||
metadata=response_data.get("metadata"),
|
||||
)
|
||||
]
|
||||
elif isinstance(response_data, list):
|
||||
documents = []
|
||||
for document in response_data:
|
||||
documents.append(
|
||||
Document(
|
||||
page_content=document.get("page_content"),
|
||||
metadata=document.get("metadata"),
|
||||
)
|
||||
)
|
||||
return documents
|
||||
else:
|
||||
raise Exception("Error loading document: Unable to parse content")
|
||||
|
||||
else:
|
||||
raise Exception("Error loading document: No content returned")
|
||||
else:
|
||||
raise Exception(
|
||||
f"Error loading document: {response.status_code} {response.text}"
|
||||
)
|
||||
+2
-2
@@ -10,7 +10,7 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class ExternalLoader(BaseLoader):
|
||||
class ExternalWebLoader(BaseLoader):
|
||||
def __init__(
|
||||
self,
|
||||
web_paths: Union[str, List[str]],
|
||||
@@ -32,7 +32,7 @@ class ExternalLoader(BaseLoader):
|
||||
response = requests.post(
|
||||
self.external_url,
|
||||
headers={
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) External Web Loader",
|
||||
"Authorization": f"Bearer {self.external_api_key}",
|
||||
},
|
||||
json={
|
||||
@@ -2,6 +2,7 @@ import requests
|
||||
import logging
|
||||
import ftfy
|
||||
import sys
|
||||
import json
|
||||
|
||||
from langchain_community.document_loaders import (
|
||||
AzureAIDocumentIntelligenceLoader,
|
||||
@@ -21,7 +22,11 @@ from langchain_community.document_loaders import (
|
||||
)
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader
|
||||
|
||||
from open_webui.retrieval.loaders.mistral import MistralLoader
|
||||
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
|
||||
@@ -72,7 +77,6 @@ known_source_ext = [
|
||||
"swift",
|
||||
"vue",
|
||||
"svelte",
|
||||
"msg",
|
||||
"ex",
|
||||
"exs",
|
||||
"erl",
|
||||
@@ -85,11 +89,13 @@ known_source_ext = [
|
||||
|
||||
|
||||
class TikaLoader:
|
||||
def __init__(self, url, file_path, mime_type=None):
|
||||
def __init__(self, url, file_path, mime_type=None, extract_images=None):
|
||||
self.url = url
|
||||
self.file_path = file_path
|
||||
self.mime_type = mime_type
|
||||
|
||||
self.extract_images = extract_images
|
||||
|
||||
def load(self) -> list[Document]:
|
||||
with open(self.file_path, "rb") as f:
|
||||
data = f.read()
|
||||
@@ -99,7 +105,7 @@ class TikaLoader:
|
||||
else:
|
||||
headers = {}
|
||||
|
||||
if self.kwargs.get("PDF_EXTRACT_IMAGES") == True:
|
||||
if self.extract_images == True:
|
||||
headers["X-Tika-PDFextractInlineImages"] = "true"
|
||||
|
||||
endpoint = self.url
|
||||
@@ -124,14 +130,12 @@ class TikaLoader:
|
||||
|
||||
|
||||
class DoclingLoader:
|
||||
def __init__(
|
||||
self, url, file_path=None, mime_type=None, ocr_engine=None, ocr_lang=None
|
||||
):
|
||||
def __init__(self, url, file_path=None, mime_type=None, params=None):
|
||||
self.url = url.rstrip("/")
|
||||
self.file_path = file_path
|
||||
self.mime_type = mime_type
|
||||
self.ocr_engine = ocr_engine
|
||||
self.ocr_lang = ocr_lang
|
||||
|
||||
self.params = params or {}
|
||||
|
||||
def load(self) -> list[Document]:
|
||||
with open(self.file_path, "rb") as f:
|
||||
@@ -143,16 +147,39 @@ class DoclingLoader:
|
||||
)
|
||||
}
|
||||
|
||||
params = {
|
||||
"image_export_mode": "placeholder",
|
||||
"table_mode": "accurate",
|
||||
}
|
||||
params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
|
||||
|
||||
if self.ocr_engine and self.ocr_lang:
|
||||
params["ocr_engine"] = self.ocr_engine
|
||||
params["ocr_lang"] = [
|
||||
lang.strip() for lang in self.ocr_lang.split(",") if lang.strip()
|
||||
]
|
||||
if self.params:
|
||||
if self.params.get("do_picture_description"):
|
||||
params["do_picture_description"] = self.params.get(
|
||||
"do_picture_description"
|
||||
)
|
||||
|
||||
picture_description_mode = self.params.get(
|
||||
"picture_description_mode", ""
|
||||
).lower()
|
||||
|
||||
if picture_description_mode == "local" and self.params.get(
|
||||
"picture_description_local", {}
|
||||
):
|
||||
params["picture_description_local"] = json.dumps(
|
||||
self.params.get("picture_description_local", {})
|
||||
)
|
||||
|
||||
elif picture_description_mode == "api" and self.params.get(
|
||||
"picture_description_api", {}
|
||||
):
|
||||
params["picture_description_api"] = json.dumps(
|
||||
self.params.get("picture_description_api", {})
|
||||
)
|
||||
|
||||
if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
|
||||
params["ocr_engine"] = self.params.get("ocr_engine")
|
||||
params["ocr_lang"] = [
|
||||
lang.strip()
|
||||
for lang in self.params.get("ocr_lang").split(",")
|
||||
if lang.strip()
|
||||
]
|
||||
|
||||
endpoint = f"{self.url}/v1alpha/convert/file"
|
||||
r = requests.post(endpoint, files=files, data=params)
|
||||
@@ -205,7 +232,18 @@ class Loader:
|
||||
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
||||
file_ext = filename.split(".")[-1].lower()
|
||||
|
||||
if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
|
||||
if (
|
||||
self.engine == "external"
|
||||
and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL")
|
||||
and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY")
|
||||
):
|
||||
loader = ExternalDocumentLoader(
|
||||
file_path=file_path,
|
||||
url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"),
|
||||
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
|
||||
mime_type=file_content_type,
|
||||
)
|
||||
elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
|
||||
if self._is_text_file(file_ext, file_content_type):
|
||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||
else:
|
||||
@@ -213,17 +251,69 @@ class Loader:
|
||||
url=self.kwargs.get("TIKA_SERVER_URL"),
|
||||
file_path=file_path,
|
||||
mime_type=file_content_type,
|
||||
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
|
||||
)
|
||||
elif (
|
||||
self.engine == "datalab_marker"
|
||||
and self.kwargs.get("DATALAB_MARKER_API_KEY")
|
||||
and file_ext
|
||||
in [
|
||||
"pdf",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ods",
|
||||
"doc",
|
||||
"docx",
|
||||
"odt",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"odp",
|
||||
"html",
|
||||
"epub",
|
||||
"png",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"webp",
|
||||
"gif",
|
||||
"tiff",
|
||||
]
|
||||
):
|
||||
loader = DatalabMarkerLoader(
|
||||
file_path=file_path,
|
||||
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
|
||||
langs=self.kwargs.get("DATALAB_MARKER_LANGS"),
|
||||
use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False),
|
||||
skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False),
|
||||
force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False),
|
||||
paginate=self.kwargs.get("DATALAB_MARKER_PAGINATE", False),
|
||||
strip_existing_ocr=self.kwargs.get(
|
||||
"DATALAB_MARKER_STRIP_EXISTING_OCR", False
|
||||
),
|
||||
disable_image_extraction=self.kwargs.get(
|
||||
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
|
||||
),
|
||||
output_format=self.kwargs.get(
|
||||
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
|
||||
),
|
||||
)
|
||||
elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
|
||||
if self._is_text_file(file_ext, file_content_type):
|
||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||
else:
|
||||
# Build params for DoclingLoader
|
||||
params = self.kwargs.get("DOCLING_PARAMS", {})
|
||||
if not isinstance(params, dict):
|
||||
try:
|
||||
params = json.loads(params)
|
||||
except json.JSONDecodeError:
|
||||
log.error("Invalid DOCLING_PARAMS format, expected JSON object")
|
||||
params = {}
|
||||
|
||||
loader = DoclingLoader(
|
||||
url=self.kwargs.get("DOCLING_SERVER_URL"),
|
||||
file_path=file_path,
|
||||
mime_type=file_content_type,
|
||||
ocr_engine=self.kwargs.get("DOCLING_OCR_ENGINE"),
|
||||
ocr_lang=self.kwargs.get("DOCLING_OCR_LANG"),
|
||||
params=params,
|
||||
)
|
||||
elif (
|
||||
self.engine == "document_intelligence"
|
||||
@@ -255,6 +345,15 @@ class Loader:
|
||||
loader = MistralLoader(
|
||||
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
|
||||
)
|
||||
elif (
|
||||
self.engine == "external"
|
||||
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
|
||||
and file_ext
|
||||
in ["pdf"] # Mistral OCR currently only supports PDF and images
|
||||
):
|
||||
loader = MistralLoader(
|
||||
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
|
||||
)
|
||||
else:
|
||||
if file_ext == "pdf":
|
||||
loader = PyPDFLoader(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import requests
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
@@ -14,18 +18,37 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
class MistralLoader:
|
||||
"""
|
||||
Enhanced Mistral OCR loader with both sync and async support.
|
||||
Loads documents by processing them through the Mistral OCR API.
|
||||
|
||||
Performance Optimizations:
|
||||
- Differentiated timeouts for different operations
|
||||
- Intelligent retry logic with exponential backoff
|
||||
- Memory-efficient file streaming for large files
|
||||
- Connection pooling and keepalive optimization
|
||||
- Semaphore-based concurrency control for batch processing
|
||||
- Enhanced error handling with retryable error classification
|
||||
"""
|
||||
|
||||
BASE_API_URL = "https://api.mistral.ai/v1"
|
||||
|
||||
def __init__(self, api_key: str, file_path: str):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
file_path: str,
|
||||
timeout: int = 300, # 5 minutes default
|
||||
max_retries: int = 3,
|
||||
enable_debug_logging: bool = False,
|
||||
):
|
||||
"""
|
||||
Initializes the loader.
|
||||
Initializes the loader with enhanced features.
|
||||
|
||||
Args:
|
||||
api_key: Your Mistral API key.
|
||||
file_path: The local path to the PDF file to process.
|
||||
timeout: Request timeout in seconds.
|
||||
max_retries: Maximum number of retry attempts.
|
||||
enable_debug_logging: Enable detailed debug logs.
|
||||
"""
|
||||
if not api_key:
|
||||
raise ValueError("API key cannot be empty.")
|
||||
@@ -34,7 +57,46 @@ class MistralLoader:
|
||||
|
||||
self.api_key = api_key
|
||||
self.file_path = file_path
|
||||
self.headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.debug = enable_debug_logging
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations
|
||||
# This prevents long-running OCR operations from affecting quick operations
|
||||
# and improves user experience by failing fast on operations that should be quick
|
||||
self.upload_timeout = min(
|
||||
timeout, 120
|
||||
) # Cap upload at 2 minutes - prevents hanging on large files
|
||||
self.url_timeout = (
|
||||
30 # URL requests should be fast - fail quickly if API is slow
|
||||
)
|
||||
self.ocr_timeout = (
|
||||
timeout # OCR can take the full timeout - this is the heavy operation
|
||||
)
|
||||
self.cleanup_timeout = (
|
||||
30 # Cleanup should be quick - don't hang on file deletion
|
||||
)
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Pre-compute file info to avoid repeated filesystem calls
|
||||
# This avoids multiple os.path.basename() and os.path.getsize() calls during processing
|
||||
self.file_name = os.path.basename(file_path)
|
||||
self.file_size = os.path.getsize(file_path)
|
||||
|
||||
# ENHANCEMENT: Added User-Agent for better API tracking and debugging
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"User-Agent": "OpenWebUI-MistralLoader/2.0", # Helps API provider track usage
|
||||
}
|
||||
|
||||
def _debug_log(self, message: str, *args) -> None:
|
||||
"""
|
||||
PERFORMANCE OPTIMIZATION: Conditional debug logging for performance.
|
||||
|
||||
Only processes debug messages when debug mode is enabled, avoiding
|
||||
string formatting overhead in production environments.
|
||||
"""
|
||||
if self.debug:
|
||||
log.debug(message, *args)
|
||||
|
||||
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
||||
"""Checks response status and returns JSON content."""
|
||||
@@ -54,24 +116,154 @@ class MistralLoader:
|
||||
log.error(f"JSON decode error: {json_err} - Response: {response.text}")
|
||||
raise # Re-raise after logging
|
||||
|
||||
def _upload_file(self) -> str:
|
||||
"""Uploads the file to Mistral for OCR processing."""
|
||||
log.info("Uploading file to Mistral API")
|
||||
url = f"{self.BASE_API_URL}/files"
|
||||
file_name = os.path.basename(self.file_path)
|
||||
|
||||
async def _handle_response_async(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of response handling with better error info."""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
files = {"file": (file_name, f, "application/pdf")}
|
||||
data = {"purpose": "ocr"}
|
||||
response.raise_for_status()
|
||||
|
||||
upload_headers = self.headers.copy() # Avoid modifying self.headers
|
||||
|
||||
response = requests.post(
|
||||
url, headers=upload_headers, files=files, data=data
|
||||
# Check content type
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" not in content_type:
|
||||
if response.status == 204:
|
||||
return {}
|
||||
text = await response.text()
|
||||
raise ValueError(
|
||||
f"Unexpected content type: {content_type}, body: {text[:200]}..."
|
||||
)
|
||||
|
||||
response_data = self._handle_response(response)
|
||||
return await response.json()
|
||||
|
||||
except aiohttp.ClientResponseError as e:
|
||||
error_text = await response.text() if response else "No response"
|
||||
log.error(f"HTTP {e.status}: {e.message} - Response: {error_text[:500]}")
|
||||
raise
|
||||
except aiohttp.ClientError as e:
|
||||
log.error(f"Client error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"Unexpected error processing response: {e}")
|
||||
raise
|
||||
|
||||
def _is_retryable_error(self, error: Exception) -> bool:
|
||||
"""
|
||||
ENHANCEMENT: Intelligent error classification for retry logic.
|
||||
|
||||
Determines if an error is retryable based on its type and status code.
|
||||
This prevents wasting time retrying errors that will never succeed
|
||||
(like authentication errors) while ensuring transient errors are retried.
|
||||
|
||||
Retryable errors:
|
||||
- Network connection errors (temporary network issues)
|
||||
- Timeouts (server might be temporarily overloaded)
|
||||
- Server errors (5xx status codes - server-side issues)
|
||||
- Rate limiting (429 status - temporary throttling)
|
||||
|
||||
Non-retryable errors:
|
||||
- Authentication errors (401, 403 - won't fix with retry)
|
||||
- Bad request errors (400 - malformed request)
|
||||
- Not found errors (404 - resource doesn't exist)
|
||||
"""
|
||||
if isinstance(error, requests.exceptions.ConnectionError):
|
||||
return True # Network issues are usually temporary
|
||||
if isinstance(error, requests.exceptions.Timeout):
|
||||
return True # Timeouts might resolve on retry
|
||||
if isinstance(error, requests.exceptions.HTTPError):
|
||||
# Only retry on server errors (5xx) or rate limits (429)
|
||||
if hasattr(error, "response") and error.response is not None:
|
||||
status_code = error.response.status_code
|
||||
return status_code >= 500 or status_code == 429
|
||||
return False
|
||||
if isinstance(
|
||||
error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError)
|
||||
):
|
||||
return True # Async network/timeout errors are retryable
|
||||
if isinstance(error, aiohttp.ClientResponseError):
|
||||
return error.status >= 500 or error.status == 429
|
||||
return False # All other errors are non-retryable
|
||||
|
||||
def _retry_request_sync(self, request_func, *args, **kwargs):
|
||||
"""
|
||||
ENHANCEMENT: Synchronous retry logic with intelligent error classification.
|
||||
|
||||
Uses exponential backoff with jitter to avoid thundering herd problems.
|
||||
The wait time increases exponentially but is capped at 30 seconds to
|
||||
prevent excessive delays. Only retries errors that are likely to succeed
|
||||
on subsequent attempts.
|
||||
"""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
return request_func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if attempt == self.max_retries - 1 or not self._is_retryable_error(e):
|
||||
raise
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Exponential backoff with cap
|
||||
# Prevents overwhelming the server while ensuring reasonable retry delays
|
||||
wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds
|
||||
log.warning(
|
||||
f"Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. "
|
||||
f"Retrying in {wait_time}s..."
|
||||
)
|
||||
time.sleep(wait_time)
|
||||
|
||||
async def _retry_request_async(self, request_func, *args, **kwargs):
|
||||
"""
|
||||
ENHANCEMENT: Async retry logic with intelligent error classification.
|
||||
|
||||
Async version of retry logic that doesn't block the event loop during
|
||||
wait periods. Uses the same exponential backoff strategy as sync version.
|
||||
"""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
return await request_func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if attempt == self.max_retries - 1 or not self._is_retryable_error(e):
|
||||
raise
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Non-blocking exponential backoff
|
||||
wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds
|
||||
log.warning(
|
||||
f"Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. "
|
||||
f"Retrying in {wait_time}s..."
|
||||
)
|
||||
await asyncio.sleep(wait_time) # Non-blocking wait
|
||||
|
||||
def _upload_file(self) -> str:
|
||||
"""
|
||||
PERFORMANCE OPTIMIZATION: Enhanced file upload with streaming consideration.
|
||||
|
||||
Uploads the file to Mistral for OCR processing (sync version).
|
||||
Uses context manager for file handling to ensure proper resource cleanup.
|
||||
Although streaming is not enabled for this endpoint, the file is opened
|
||||
in a context manager to minimize memory usage duration.
|
||||
"""
|
||||
log.info("Uploading file to Mistral API")
|
||||
url = f"{self.BASE_API_URL}/files"
|
||||
|
||||
def upload_request():
|
||||
# MEMORY OPTIMIZATION: Use context manager to minimize file handle lifetime
|
||||
# This ensures the file is closed immediately after reading, reducing memory usage
|
||||
with open(self.file_path, "rb") as f:
|
||||
files = {"file": (self.file_name, f, "application/pdf")}
|
||||
data = {"purpose": "ocr"}
|
||||
|
||||
# NOTE: stream=False is required for this endpoint
|
||||
# The Mistral API doesn't support chunked uploads for this endpoint
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=self.upload_timeout, # Use specialized upload timeout
|
||||
stream=False, # Keep as False for this endpoint
|
||||
)
|
||||
|
||||
return self._handle_response(response)
|
||||
|
||||
try:
|
||||
response_data = self._retry_request_sync(upload_request)
|
||||
file_id = response_data.get("id")
|
||||
if not file_id:
|
||||
raise ValueError("File ID not found in upload response.")
|
||||
@@ -81,16 +273,66 @@ class MistralLoader:
|
||||
log.error(f"Failed to upload file: {e}")
|
||||
raise
|
||||
|
||||
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
|
||||
"""Async file upload with streaming for better memory efficiency."""
|
||||
url = f"{self.BASE_API_URL}/files"
|
||||
|
||||
async def upload_request():
|
||||
# Create multipart writer for streaming upload
|
||||
writer = aiohttp.MultipartWriter("form-data")
|
||||
|
||||
# Add purpose field
|
||||
purpose_part = writer.append("ocr")
|
||||
purpose_part.set_content_disposition("form-data", name="purpose")
|
||||
|
||||
# Add file part with streaming
|
||||
file_part = writer.append_payload(
|
||||
aiohttp.streams.FilePayload(
|
||||
self.file_path,
|
||||
filename=self.file_name,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
)
|
||||
file_part.set_content_disposition(
|
||||
"form-data", name="file", filename=self.file_name
|
||||
)
|
||||
|
||||
self._debug_log(
|
||||
f"Uploading file: {self.file_name} ({self.file_size:,} bytes)"
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
data=writer,
|
||||
headers=self.headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.upload_timeout),
|
||||
) as response:
|
||||
return await self._handle_response_async(response)
|
||||
|
||||
response_data = await self._retry_request_async(upload_request)
|
||||
|
||||
file_id = response_data.get("id")
|
||||
if not file_id:
|
||||
raise ValueError("File ID not found in upload response.")
|
||||
|
||||
log.info(f"File uploaded successfully. File ID: {file_id}")
|
||||
return file_id
|
||||
|
||||
def _get_signed_url(self, file_id: str) -> str:
|
||||
"""Retrieves a temporary signed URL for the uploaded file."""
|
||||
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
|
||||
log.info(f"Getting signed URL for file ID: {file_id}")
|
||||
url = f"{self.BASE_API_URL}/files/{file_id}/url"
|
||||
params = {"expiry": 1}
|
||||
signed_url_headers = {**self.headers, "Accept": "application/json"}
|
||||
|
||||
def url_request():
|
||||
response = requests.get(
|
||||
url, headers=signed_url_headers, params=params, timeout=self.url_timeout
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=signed_url_headers, params=params)
|
||||
response_data = self._handle_response(response)
|
||||
response_data = self._retry_request_sync(url_request)
|
||||
signed_url = response_data.get("url")
|
||||
if not signed_url:
|
||||
raise ValueError("Signed URL not found in response.")
|
||||
@@ -100,8 +342,36 @@ class MistralLoader:
|
||||
log.error(f"Failed to get signed URL: {e}")
|
||||
raise
|
||||
|
||||
async def _get_signed_url_async(
|
||||
self, session: aiohttp.ClientSession, file_id: str
|
||||
) -> str:
|
||||
"""Async signed URL retrieval."""
|
||||
url = f"{self.BASE_API_URL}/files/{file_id}/url"
|
||||
params = {"expiry": 1}
|
||||
|
||||
headers = {**self.headers, "Accept": "application/json"}
|
||||
|
||||
async def url_request():
|
||||
self._debug_log(f"Getting signed URL for file ID: {file_id}")
|
||||
async with session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=aiohttp.ClientTimeout(total=self.url_timeout),
|
||||
) as response:
|
||||
return await self._handle_response_async(response)
|
||||
|
||||
response_data = await self._retry_request_async(url_request)
|
||||
|
||||
signed_url = response_data.get("url")
|
||||
if not signed_url:
|
||||
raise ValueError("Signed URL not found in response.")
|
||||
|
||||
self._debug_log("Signed URL received successfully")
|
||||
return signed_url
|
||||
|
||||
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
|
||||
"""Sends the signed URL to the OCR endpoint for processing."""
|
||||
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
|
||||
log.info("Processing OCR via Mistral API")
|
||||
url = f"{self.BASE_API_URL}/ocr"
|
||||
ocr_headers = {
|
||||
@@ -118,43 +388,217 @@ class MistralLoader:
|
||||
"include_image_base64": False,
|
||||
}
|
||||
|
||||
def ocr_request():
|
||||
response = requests.post(
|
||||
url, headers=ocr_headers, json=payload, timeout=self.ocr_timeout
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=ocr_headers, json=payload)
|
||||
ocr_response = self._handle_response(response)
|
||||
ocr_response = self._retry_request_sync(ocr_request)
|
||||
log.info("OCR processing done.")
|
||||
log.debug("OCR response: %s", ocr_response)
|
||||
self._debug_log("OCR response: %s", ocr_response)
|
||||
return ocr_response
|
||||
except Exception as e:
|
||||
log.error(f"Failed during OCR processing: {e}")
|
||||
raise
|
||||
|
||||
async def _process_ocr_async(
|
||||
self, session: aiohttp.ClientSession, signed_url: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Async OCR processing with timing metrics."""
|
||||
url = f"{self.BASE_API_URL}/ocr"
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": "mistral-ocr-latest",
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": signed_url,
|
||||
},
|
||||
"include_image_base64": False,
|
||||
}
|
||||
|
||||
async def ocr_request():
|
||||
log.info("Starting OCR processing via Mistral API")
|
||||
start_time = time.time()
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.ocr_timeout),
|
||||
) as response:
|
||||
ocr_response = await self._handle_response_async(response)
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
log.info(f"OCR processing completed in {processing_time:.2f}s")
|
||||
|
||||
return ocr_response
|
||||
|
||||
return await self._retry_request_async(ocr_request)
|
||||
|
||||
def _delete_file(self, file_id: str) -> None:
|
||||
"""Deletes the file from Mistral storage."""
|
||||
"""Deletes the file from Mistral storage (sync version)."""
|
||||
log.info(f"Deleting uploaded file ID: {file_id}")
|
||||
url = f"{self.BASE_API_URL}/files/{file_id}"
|
||||
# No specific Accept header needed, default or Authorization is usually sufficient
|
||||
|
||||
try:
|
||||
response = requests.delete(url, headers=self.headers)
|
||||
delete_response = self._handle_response(
|
||||
response
|
||||
) # Check status, ignore response body unless needed
|
||||
log.info(
|
||||
f"File deleted successfully: {delete_response}"
|
||||
) # Log the response if available
|
||||
response = requests.delete(
|
||||
url, headers=self.headers, timeout=self.cleanup_timeout
|
||||
)
|
||||
delete_response = self._handle_response(response)
|
||||
log.info(f"File deleted successfully: {delete_response}")
|
||||
except Exception as e:
|
||||
# Log error but don't necessarily halt execution if deletion fails
|
||||
log.error(f"Failed to delete file ID {file_id}: {e}")
|
||||
# Depending on requirements, you might choose to raise the error here
|
||||
|
||||
async def _delete_file_async(
|
||||
self, session: aiohttp.ClientSession, file_id: str
|
||||
) -> None:
|
||||
"""Async file deletion with error tolerance."""
|
||||
try:
|
||||
|
||||
async def delete_request():
|
||||
self._debug_log(f"Deleting file ID: {file_id}")
|
||||
async with session.delete(
|
||||
url=f"{self.BASE_API_URL}/files/{file_id}",
|
||||
headers=self.headers,
|
||||
timeout=aiohttp.ClientTimeout(
|
||||
total=self.cleanup_timeout
|
||||
), # Shorter timeout for cleanup
|
||||
) as response:
|
||||
return await self._handle_response_async(response)
|
||||
|
||||
await self._retry_request_async(delete_request)
|
||||
self._debug_log(f"File {file_id} deleted successfully")
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail the entire process if cleanup fails
|
||||
log.warning(f"Failed to delete file ID {file_id}: {e}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_session(self):
|
||||
"""Context manager for HTTP session with optimized settings."""
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=20, # Increased total connection limit for better throughput
|
||||
limit_per_host=10, # Increased per-host limit for API endpoints
|
||||
ttl_dns_cache=600, # Longer DNS cache TTL (10 minutes)
|
||||
use_dns_cache=True,
|
||||
keepalive_timeout=60, # Increased keepalive for connection reuse
|
||||
enable_cleanup_closed=True,
|
||||
force_close=False, # Allow connection reuse
|
||||
resolver=aiohttp.AsyncResolver(), # Use async DNS resolver
|
||||
)
|
||||
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=self.timeout,
|
||||
connect=30, # Connection timeout
|
||||
sock_read=60, # Socket read timeout
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
timeout=timeout,
|
||||
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
|
||||
raise_for_status=False, # We handle status codes manually
|
||||
) as session:
|
||||
yield session
|
||||
|
||||
def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]:
|
||||
"""Process OCR results into Document objects with enhanced metadata and memory efficiency."""
|
||||
pages_data = ocr_response.get("pages")
|
||||
if not pages_data:
|
||||
log.warning("No pages found in OCR response.")
|
||||
return [
|
||||
Document(
|
||||
page_content="No text content found",
|
||||
metadata={"error": "no_pages", "file_name": self.file_name},
|
||||
)
|
||||
]
|
||||
|
||||
documents = []
|
||||
total_pages = len(pages_data)
|
||||
skipped_pages = 0
|
||||
|
||||
# Process pages in a memory-efficient way
|
||||
for page_data in pages_data:
|
||||
page_content = page_data.get("markdown")
|
||||
page_index = page_data.get("index") # API uses 0-based index
|
||||
|
||||
if page_content is None or page_index is None:
|
||||
skipped_pages += 1
|
||||
self._debug_log(
|
||||
f"Skipping page due to missing 'markdown' or 'index'. Data keys: {list(page_data.keys())}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Clean up content efficiently with early exit for empty content
|
||||
if isinstance(page_content, str):
|
||||
cleaned_content = page_content.strip()
|
||||
else:
|
||||
cleaned_content = str(page_content).strip()
|
||||
|
||||
if not cleaned_content:
|
||||
skipped_pages += 1
|
||||
self._debug_log(f"Skipping empty page {page_index}")
|
||||
continue
|
||||
|
||||
# Create document with optimized metadata
|
||||
documents.append(
|
||||
Document(
|
||||
page_content=cleaned_content,
|
||||
metadata={
|
||||
"page": page_index, # 0-based index from API
|
||||
"page_label": page_index + 1, # 1-based label for convenience
|
||||
"total_pages": total_pages,
|
||||
"file_name": self.file_name,
|
||||
"file_size": self.file_size,
|
||||
"processing_engine": "mistral-ocr",
|
||||
"content_length": len(cleaned_content),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if skipped_pages > 0:
|
||||
log.info(
|
||||
f"Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages"
|
||||
)
|
||||
|
||||
if not documents:
|
||||
# Case where pages existed but none had valid markdown/index
|
||||
log.warning(
|
||||
"OCR response contained pages, but none had valid content/index."
|
||||
)
|
||||
return [
|
||||
Document(
|
||||
page_content="No valid text content found in document",
|
||||
metadata={
|
||||
"error": "no_valid_pages",
|
||||
"total_pages": total_pages,
|
||||
"file_name": self.file_name,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
return documents
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
"""
|
||||
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
|
||||
Synchronous version for backward compatibility.
|
||||
|
||||
Returns:
|
||||
A list of Document objects, one for each page processed.
|
||||
"""
|
||||
file_id = None
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 1. Upload file
|
||||
file_id = self._upload_file()
|
||||
@@ -166,53 +610,30 @@ class MistralLoader:
|
||||
ocr_response = self._process_ocr(signed_url)
|
||||
|
||||
# 4. Process results
|
||||
pages_data = ocr_response.get("pages")
|
||||
if not pages_data:
|
||||
log.warning("No pages found in OCR response.")
|
||||
return [Document(page_content="No text content found", metadata={})]
|
||||
documents = self._process_results(ocr_response)
|
||||
|
||||
documents = []
|
||||
total_pages = len(pages_data)
|
||||
for page_data in pages_data:
|
||||
page_content = page_data.get("markdown")
|
||||
page_index = page_data.get("index") # API uses 0-based index
|
||||
|
||||
if page_content is not None and page_index is not None:
|
||||
documents.append(
|
||||
Document(
|
||||
page_content=page_content,
|
||||
metadata={
|
||||
"page": page_index, # 0-based index from API
|
||||
"page_label": page_index
|
||||
+ 1, # 1-based label for convenience
|
||||
"total_pages": total_pages,
|
||||
# Add other relevant metadata from page_data if available/needed
|
||||
# e.g., page_data.get('width'), page_data.get('height')
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}"
|
||||
)
|
||||
|
||||
if not documents:
|
||||
# Case where pages existed but none had valid markdown/index
|
||||
log.warning(
|
||||
"OCR response contained pages, but none had valid content/index."
|
||||
)
|
||||
return [
|
||||
Document(
|
||||
page_content="No text content found in valid pages", metadata={}
|
||||
)
|
||||
]
|
||||
total_time = time.time() - start_time
|
||||
log.info(
|
||||
f"Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
|
||||
)
|
||||
|
||||
return documents
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"An error occurred during the loading process: {e}")
|
||||
# Return an empty list or a specific error document on failure
|
||||
return [Document(page_content=f"Error during processing: {e}", metadata={})]
|
||||
total_time = time.time() - start_time
|
||||
log.error(
|
||||
f"An error occurred during the loading process after {total_time:.2f}s: {e}"
|
||||
)
|
||||
# Return an error document on failure
|
||||
return [
|
||||
Document(
|
||||
page_content=f"Error during processing: {e}",
|
||||
metadata={
|
||||
"error": "processing_failed",
|
||||
"file_name": self.file_name,
|
||||
},
|
||||
)
|
||||
]
|
||||
finally:
|
||||
# 5. Delete file (attempt even if prior steps failed after upload)
|
||||
if file_id:
|
||||
@@ -223,3 +644,124 @@ class MistralLoader:
|
||||
log.error(
|
||||
f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}"
|
||||
)
|
||||
|
||||
async def load_async(self) -> List[Document]:
|
||||
"""
|
||||
Asynchronous OCR workflow execution with optimized performance.
|
||||
|
||||
Returns:
|
||||
A list of Document objects, one for each page processed.
|
||||
"""
|
||||
file_id = None
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
async with self._get_session() as session:
|
||||
# 1. Upload file with streaming
|
||||
file_id = await self._upload_file_async(session)
|
||||
|
||||
# 2. Get signed URL
|
||||
signed_url = await self._get_signed_url_async(session, file_id)
|
||||
|
||||
# 3. Process OCR
|
||||
ocr_response = await self._process_ocr_async(session, signed_url)
|
||||
|
||||
# 4. Process results
|
||||
documents = self._process_results(ocr_response)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
log.info(
|
||||
f"Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
|
||||
)
|
||||
|
||||
return documents
|
||||
|
||||
except Exception as e:
|
||||
total_time = time.time() - start_time
|
||||
log.error(f"Async OCR workflow failed after {total_time:.2f}s: {e}")
|
||||
return [
|
||||
Document(
|
||||
page_content=f"Error during OCR processing: {e}",
|
||||
metadata={
|
||||
"error": "processing_failed",
|
||||
"file_name": self.file_name,
|
||||
},
|
||||
)
|
||||
]
|
||||
finally:
|
||||
# 5. Cleanup - always attempt file deletion
|
||||
if file_id:
|
||||
try:
|
||||
async with self._get_session() as session:
|
||||
await self._delete_file_async(session, file_id)
|
||||
except Exception as cleanup_error:
|
||||
log.error(f"Cleanup failed for file ID {file_id}: {cleanup_error}")
|
||||
|
||||
@staticmethod
|
||||
async def load_multiple_async(
|
||||
loaders: List["MistralLoader"],
|
||||
max_concurrent: int = 5, # Limit concurrent requests
|
||||
) -> List[List[Document]]:
|
||||
"""
|
||||
Process multiple files concurrently with controlled concurrency.
|
||||
|
||||
Args:
|
||||
loaders: List of MistralLoader instances
|
||||
max_concurrent: Maximum number of concurrent requests
|
||||
|
||||
Returns:
|
||||
List of document lists, one for each loader
|
||||
"""
|
||||
if not loaders:
|
||||
return []
|
||||
|
||||
log.info(
|
||||
f"Starting concurrent processing of {len(loaders)} files with max {max_concurrent} concurrent"
|
||||
)
|
||||
start_time = time.time()
|
||||
|
||||
# Use semaphore to control concurrency
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def process_with_semaphore(loader: "MistralLoader") -> List[Document]:
|
||||
async with semaphore:
|
||||
return await loader.load_async()
|
||||
|
||||
# Process all files with controlled concurrency
|
||||
tasks = [process_with_semaphore(loader) for loader in loaders]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Handle any exceptions in results
|
||||
processed_results = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
log.error(f"File {i} failed: {result}")
|
||||
processed_results.append(
|
||||
[
|
||||
Document(
|
||||
page_content=f"Error processing file: {result}",
|
||||
metadata={
|
||||
"error": "batch_processing_failed",
|
||||
"file_index": i,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
processed_results.append(result)
|
||||
|
||||
# MONITORING: Log comprehensive batch processing statistics
|
||||
total_time = time.time() - start_time
|
||||
total_docs = sum(len(docs) for docs in processed_results)
|
||||
success_count = sum(
|
||||
1 for result in results if not isinstance(result, Exception)
|
||||
)
|
||||
failure_count = len(results) - success_count
|
||||
|
||||
log.info(
|
||||
f"Batch processing completed in {total_time:.2f}s: "
|
||||
f"{success_count} files succeeded, {failure_count} files failed, "
|
||||
f"produced {total_docs} total documents"
|
||||
)
|
||||
|
||||
return processed_results
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
from typing import Any, Dict, Generator, List, Optional, Sequence, Union
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -62,12 +63,17 @@ class YoutubeLoader:
|
||||
_video_id = _parse_video_id(video_id)
|
||||
self.video_id = _video_id if _video_id is not None else video_id
|
||||
self._metadata = {"source": video_id}
|
||||
self.language = language
|
||||
self.proxy_url = proxy_url
|
||||
|
||||
# Ensure language is a list
|
||||
if isinstance(language, str):
|
||||
self.language = [language]
|
||||
else:
|
||||
self.language = language
|
||||
self.language = list(language)
|
||||
|
||||
# Add English as fallback if not already in the list
|
||||
if "en" not in self.language:
|
||||
self.language.append("en")
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
"""Load YouTube transcripts into `Document` objects."""
|
||||
@@ -88,7 +94,6 @@ class YoutubeLoader:
|
||||
"http": self.proxy_url,
|
||||
"https": self.proxy_url,
|
||||
}
|
||||
# Don't log complete URL because it might contain secrets
|
||||
log.debug(f"Using proxy URL: {self.proxy_url[:14]}...")
|
||||
else:
|
||||
youtube_proxies = None
|
||||
@@ -101,17 +106,55 @@ class YoutubeLoader:
|
||||
log.exception("Loading YouTube transcript failed")
|
||||
return []
|
||||
|
||||
try:
|
||||
transcript = transcript_list.find_transcript(self.language)
|
||||
except NoTranscriptFound:
|
||||
transcript = transcript_list.find_transcript(["en"])
|
||||
# Try each language in order of priority
|
||||
for lang in self.language:
|
||||
try:
|
||||
transcript = transcript_list.find_transcript([lang])
|
||||
if transcript.is_generated:
|
||||
log.debug(f"Found generated transcript for language '{lang}'")
|
||||
try:
|
||||
transcript = transcript_list.find_manually_created_transcript(
|
||||
[lang]
|
||||
)
|
||||
log.debug(f"Found manual transcript for language '{lang}'")
|
||||
except NoTranscriptFound:
|
||||
log.debug(
|
||||
f"No manual transcript found for language '{lang}', using generated"
|
||||
)
|
||||
pass
|
||||
|
||||
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
|
||||
log.debug(f"Found transcript for language '{lang}'")
|
||||
try:
|
||||
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
|
||||
except ParseError:
|
||||
log.debug(f"Empty or invalid transcript for language '{lang}'")
|
||||
continue
|
||||
|
||||
transcript = " ".join(
|
||||
map(
|
||||
lambda transcript_piece: transcript_piece.text.strip(" "),
|
||||
transcript_pieces,
|
||||
)
|
||||
if not transcript_pieces:
|
||||
log.debug(f"Empty transcript for language '{lang}'")
|
||||
continue
|
||||
|
||||
transcript_text = " ".join(
|
||||
map(
|
||||
lambda transcript_piece: (
|
||||
transcript_piece.text.strip(" ")
|
||||
if hasattr(transcript_piece, "text")
|
||||
else ""
|
||||
),
|
||||
transcript_pieces,
|
||||
)
|
||||
)
|
||||
return [Document(page_content=transcript_text, metadata=self._metadata)]
|
||||
except NoTranscriptFound:
|
||||
log.debug(f"No transcript found for language '{lang}'")
|
||||
continue
|
||||
except Exception as e:
|
||||
log.info(f"Error finding transcript for language '{lang}'")
|
||||
raise e
|
||||
|
||||
# If we get here, all languages failed
|
||||
languages_tried = ", ".join(self.language)
|
||||
log.warning(
|
||||
f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed."
|
||||
)
|
||||
return [Document(page_content=transcript, metadata=self._metadata)]
|
||||
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
|
||||
class BaseReranker(ABC):
|
||||
@abstractmethod
|
||||
def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
|
||||
pass
|
||||
@@ -7,11 +7,13 @@ from colbert.modeling.checkpoint import Checkpoint
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class ColBERT:
|
||||
class ColBERT(BaseReranker):
|
||||
def __init__(self, name, **kwargs) -> None:
|
||||
log.info("ColBERT: Loading model", name)
|
||||
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class ExternalReranker(BaseReranker):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
url: str = "http://localhost:8080/v1/rerank",
|
||||
model: str = "reranker",
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.url = url
|
||||
self.model = model
|
||||
|
||||
def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
|
||||
query = sentences[0][0]
|
||||
docs = [i[1] for i in sentences]
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"query": query,
|
||||
"documents": docs,
|
||||
"top_n": len(docs),
|
||||
}
|
||||
|
||||
try:
|
||||
log.info(f"ExternalReranker:predict:model {self.model}")
|
||||
log.info(f"ExternalReranker:predict:query {query}")
|
||||
|
||||
r = requests.post(
|
||||
f"{self.url}",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if "results" in data:
|
||||
sorted_results = sorted(data["results"], key=lambda x: x["index"])
|
||||
return [result["relevance_score"] for result in sorted_results]
|
||||
else:
|
||||
log.error("No results found in external reranking response")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error in external reranking: {e}")
|
||||
return None
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Union
|
||||
import requests
|
||||
import hashlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import time
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
||||
@@ -12,7 +13,7 @@ from langchain_community.retrievers import BM25Retriever
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from open_webui.config import VECTOR_DB
|
||||
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.files import Files
|
||||
@@ -116,6 +117,7 @@ def query_doc_with_hybrid_search(
|
||||
reranking_function,
|
||||
k_reranker: int,
|
||||
r: float,
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
try:
|
||||
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
|
||||
@@ -131,9 +133,20 @@ def query_doc_with_hybrid_search(
|
||||
top_k=k,
|
||||
)
|
||||
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[bm25_retriever, vector_search_retriever], weights=[0.5, 0.5]
|
||||
)
|
||||
if hybrid_bm25_weight <= 0:
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[vector_search_retriever], weights=[1.0]
|
||||
)
|
||||
elif hybrid_bm25_weight >= 1:
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[bm25_retriever], weights=[1.0]
|
||||
)
|
||||
else:
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[bm25_retriever, vector_search_retriever],
|
||||
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
|
||||
)
|
||||
|
||||
compressor = RerankCompressor(
|
||||
embedding_function=embedding_function,
|
||||
top_n=k_reranker,
|
||||
@@ -313,6 +326,7 @@ def query_collection_with_hybrid_search(
|
||||
reranking_function,
|
||||
k_reranker: int,
|
||||
r: float,
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
results = []
|
||||
error = False
|
||||
@@ -346,6 +360,7 @@ def query_collection_with_hybrid_search(
|
||||
reranking_function=reranking_function,
|
||||
k_reranker=k_reranker,
|
||||
r=r,
|
||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||
)
|
||||
return result, None
|
||||
except Exception as e:
|
||||
@@ -386,12 +401,13 @@ def get_embedding_function(
|
||||
url,
|
||||
key,
|
||||
embedding_batch_size,
|
||||
azure_api_version=None,
|
||||
):
|
||||
if embedding_engine == "":
|
||||
return lambda query, prefix=None, user=None: embedding_function.encode(
|
||||
query, **({"prompt": prefix} if prefix else {})
|
||||
).tolist()
|
||||
elif embedding_engine in ["ollama", "openai"]:
|
||||
elif embedding_engine in ["ollama", "openai", "azure_openai"]:
|
||||
func = lambda query, prefix=None, user=None: generate_embeddings(
|
||||
engine=embedding_engine,
|
||||
model=embedding_model,
|
||||
@@ -400,6 +416,7 @@ def get_embedding_function(
|
||||
url=url,
|
||||
key=key,
|
||||
user=user,
|
||||
azure_api_version=azure_api_version,
|
||||
)
|
||||
|
||||
def generate_multiple(query, prefix, user, func):
|
||||
@@ -433,6 +450,7 @@ def get_sources_from_files(
|
||||
reranking_function,
|
||||
k_reranker,
|
||||
r,
|
||||
hybrid_bm25_weight,
|
||||
hybrid_search,
|
||||
full_context=False,
|
||||
):
|
||||
@@ -550,6 +568,7 @@ def get_sources_from_files(
|
||||
reranking_function=reranking_function,
|
||||
k_reranker=k_reranker,
|
||||
r=r,
|
||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(
|
||||
@@ -681,6 +700,60 @@ def generate_openai_batch_embeddings(
|
||||
return None
|
||||
|
||||
|
||||
def generate_azure_openai_batch_embeddings(
|
||||
model: str,
|
||||
texts: list[str],
|
||||
url: str,
|
||||
key: str = "",
|
||||
version: str = "",
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(
|
||||
f"generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}"
|
||||
)
|
||||
json_data = {"input": texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}"
|
||||
|
||||
for _ in range(5):
|
||||
r = requests.post(
|
||||
url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"api-key": key,
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
if r.status_code == 429:
|
||||
retry = float(r.headers.get("Retry-After", "1"))
|
||||
time.sleep(retry)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "data" in data:
|
||||
return [elem["embedding"] for elem in data["data"]]
|
||||
else:
|
||||
raise Exception("Something went wrong :/")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f"Error generating azure openai batch embeddings: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def generate_ollama_batch_embeddings(
|
||||
model: str,
|
||||
texts: list[str],
|
||||
@@ -745,38 +818,33 @@ def generate_embeddings(
|
||||
text = f"{prefix}{text}"
|
||||
|
||||
if engine == "ollama":
|
||||
if isinstance(text, list):
|
||||
embeddings = generate_ollama_batch_embeddings(
|
||||
**{
|
||||
"model": model,
|
||||
"texts": text,
|
||||
"url": url,
|
||||
"key": key,
|
||||
"prefix": prefix,
|
||||
"user": user,
|
||||
}
|
||||
)
|
||||
else:
|
||||
embeddings = generate_ollama_batch_embeddings(
|
||||
**{
|
||||
"model": model,
|
||||
"texts": [text],
|
||||
"url": url,
|
||||
"key": key,
|
||||
"prefix": prefix,
|
||||
"user": user,
|
||||
}
|
||||
)
|
||||
embeddings = generate_ollama_batch_embeddings(
|
||||
**{
|
||||
"model": model,
|
||||
"texts": text if isinstance(text, list) else [text],
|
||||
"url": url,
|
||||
"key": key,
|
||||
"prefix": prefix,
|
||||
"user": user,
|
||||
}
|
||||
)
|
||||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
elif engine == "openai":
|
||||
if isinstance(text, list):
|
||||
embeddings = generate_openai_batch_embeddings(
|
||||
model, text, url, key, prefix, user
|
||||
)
|
||||
else:
|
||||
embeddings = generate_openai_batch_embeddings(
|
||||
model, [text], url, key, prefix, user
|
||||
)
|
||||
embeddings = generate_openai_batch_embeddings(
|
||||
model, text if isinstance(text, list) else [text], url, key, prefix, user
|
||||
)
|
||||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
elif engine == "azure_openai":
|
||||
azure_api_version = kwargs.get("azure_api_version", "")
|
||||
embeddings = generate_azure_openai_batch_embeddings(
|
||||
model,
|
||||
text if isinstance(text, list) else [text],
|
||||
url,
|
||||
key,
|
||||
azure_api_version,
|
||||
prefix,
|
||||
user,
|
||||
)
|
||||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
|
||||
|
||||
@@ -818,7 +886,9 @@ class RerankCompressor(BaseDocumentCompressor):
|
||||
)
|
||||
scores = util.cos_sim(query_embedding, document_embedding)[0]
|
||||
|
||||
docs_with_scores = list(zip(documents, scores.tolist()))
|
||||
docs_with_scores = list(
|
||||
zip(documents, scores.tolist() if not isinstance(scores, list) else scores)
|
||||
)
|
||||
if self.r_score:
|
||||
docs_with_scores = [
|
||||
(d, s) for d, s in docs_with_scores if s >= self.r_score
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
from open_webui.config import VECTOR_DB
|
||||
|
||||
if VECTOR_DB == "milvus":
|
||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||
|
||||
VECTOR_DB_CLIENT = MilvusClient()
|
||||
elif VECTOR_DB == "qdrant":
|
||||
from open_webui.retrieval.vector.dbs.qdrant import QdrantClient
|
||||
|
||||
VECTOR_DB_CLIENT = QdrantClient()
|
||||
elif VECTOR_DB == "opensearch":
|
||||
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
|
||||
|
||||
VECTOR_DB_CLIENT = OpenSearchClient()
|
||||
elif VECTOR_DB == "pgvector":
|
||||
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
|
||||
|
||||
VECTOR_DB_CLIENT = PgvectorClient()
|
||||
elif VECTOR_DB == "elasticsearch":
|
||||
from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
|
||||
|
||||
VECTOR_DB_CLIENT = ElasticsearchClient()
|
||||
elif VECTOR_DB == "pinecone":
|
||||
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
|
||||
|
||||
VECTOR_DB_CLIENT = PineconeClient()
|
||||
else:
|
||||
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
|
||||
|
||||
VECTOR_DB_CLIENT = ChromaClient()
|
||||
@@ -3,7 +3,6 @@ from pymilvus import FieldSchema, DataType
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -14,6 +13,11 @@ from open_webui.config import (
|
||||
MILVUS_URI,
|
||||
MILVUS_DB,
|
||||
MILVUS_TOKEN,
|
||||
MILVUS_INDEX_TYPE,
|
||||
MILVUS_METRIC_TYPE,
|
||||
MILVUS_HNSW_M,
|
||||
MILVUS_HNSW_EFCONSTRUCTION,
|
||||
MILVUS_IVF_FLAT_NLIST,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
@@ -33,7 +37,6 @@ class MilvusClient(VectorDBBase):
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for match in result:
|
||||
_ids = []
|
||||
_documents = []
|
||||
@@ -42,11 +45,9 @@ class MilvusClient(VectorDBBase):
|
||||
_ids.append(item.get("id"))
|
||||
_documents.append(item.get("data", {}).get("text"))
|
||||
_metadatas.append(item.get("metadata"))
|
||||
|
||||
ids.append(_ids)
|
||||
documents.append(_documents)
|
||||
metadatas.append(_metadatas)
|
||||
|
||||
return GetResult(
|
||||
**{
|
||||
"ids": ids,
|
||||
@@ -60,13 +61,11 @@ class MilvusClient(VectorDBBase):
|
||||
distances = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for match in result:
|
||||
_ids = []
|
||||
_distances = []
|
||||
_documents = []
|
||||
_metadatas = []
|
||||
|
||||
for item in match:
|
||||
_ids.append(item.get("id"))
|
||||
# normalize milvus score from [-1, 1] to [0, 1] range
|
||||
@@ -75,12 +74,10 @@ class MilvusClient(VectorDBBase):
|
||||
_distances.append(_dist)
|
||||
_documents.append(item.get("entity", {}).get("data", {}).get("text"))
|
||||
_metadatas.append(item.get("entity", {}).get("metadata"))
|
||||
|
||||
ids.append(_ids)
|
||||
distances.append(_distances)
|
||||
documents.append(_documents)
|
||||
metadatas.append(_metadatas)
|
||||
|
||||
return SearchResult(
|
||||
**{
|
||||
"ids": ids,
|
||||
@@ -113,11 +110,39 @@ class MilvusClient(VectorDBBase):
|
||||
)
|
||||
|
||||
index_params = self.client.prepare_index_params()
|
||||
|
||||
# Use configurations from config.py
|
||||
index_type = MILVUS_INDEX_TYPE.upper()
|
||||
metric_type = MILVUS_METRIC_TYPE.upper()
|
||||
|
||||
log.info(f"Using Milvus index type: {index_type}, metric type: {metric_type}")
|
||||
|
||||
index_creation_params = {}
|
||||
if index_type == "HNSW":
|
||||
index_creation_params = {
|
||||
"M": MILVUS_HNSW_M,
|
||||
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
|
||||
}
|
||||
log.info(f"HNSW params: {index_creation_params}")
|
||||
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 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"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.
|
||||
# If Milvus errors out, the user needs to correct the MILVUS_INDEX_TYPE env var.
|
||||
|
||||
index_params.add_index(
|
||||
field_name="vector",
|
||||
index_type="HNSW",
|
||||
metric_type="COSINE",
|
||||
params={"M": 16, "efConstruction": 100},
|
||||
index_type=index_type,
|
||||
metric_type=metric_type,
|
||||
params=index_creation_params,
|
||||
)
|
||||
|
||||
self.client.create_collection(
|
||||
@@ -125,6 +150,9 @@ class MilvusClient(VectorDBBase):
|
||||
schema=schema,
|
||||
index_params=index_params,
|
||||
)
|
||||
log.info(
|
||||
f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'."
|
||||
)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
# Check if the collection exists based on the collection name.
|
||||
@@ -145,84 +173,113 @@ class MilvusClient(VectorDBBase):
|
||||
) -> Optional[SearchResult]:
|
||||
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
||||
collection_name = collection_name.replace("-", "_")
|
||||
# For some index types like IVF_FLAT, search params like nprobe can be set.
|
||||
# Example: search_params = {"nprobe": 10} if using IVF_FLAT
|
||||
# For simplicity, not adding configurable search_params here, but could be extended.
|
||||
result = self.client.search(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
data=vectors,
|
||||
limit=limit,
|
||||
output_fields=["data", "metadata"],
|
||||
# search_params=search_params # Potentially add later if needed
|
||||
)
|
||||
|
||||
return self._result_to_search_result(result)
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
||||
# Construct the filter string for querying
|
||||
collection_name = collection_name.replace("-", "_")
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(
|
||||
f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}"
|
||||
)
|
||||
return None
|
||||
|
||||
filter_string = " && ".join(
|
||||
[
|
||||
f'metadata["{key}"] == {json.dumps(value)}'
|
||||
for key, value in filter.items()
|
||||
]
|
||||
)
|
||||
|
||||
max_limit = 16383 # The maximum number of records per request
|
||||
all_results = []
|
||||
|
||||
if limit is None:
|
||||
limit = float("inf") # Use infinity as a placeholder for no limit
|
||||
# Milvus default limit for query if not specified is 16384, but docs mention iteration.
|
||||
# Let's set a practical high number if "all" is intended, or handle true pagination.
|
||||
# For now, if limit is None, we'll fetch in batches up to a very large number.
|
||||
# This part could be refined based on expected use cases for "get all".
|
||||
# For this function signature, None implies "as many as possible" up to Milvus limits.
|
||||
limit = (
|
||||
16384 * 10
|
||||
) # A large number to signify fetching many, will be capped by actual data or max_limit per call.
|
||||
log.info(
|
||||
f"Limit not specified for query, fetching up to {limit} results in batches."
|
||||
)
|
||||
|
||||
# Initialize offset and remaining to handle pagination
|
||||
offset = 0
|
||||
remaining = limit
|
||||
|
||||
try:
|
||||
log.info(
|
||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||
)
|
||||
# Loop until there are no more items to fetch or the desired limit is reached
|
||||
while remaining > 0:
|
||||
log.info(f"remaining: {remaining}")
|
||||
current_fetch = min(
|
||||
max_limit, remaining
|
||||
) # Determine how many items to fetch in this iteration
|
||||
max_limit, remaining if isinstance(remaining, int) else max_limit
|
||||
)
|
||||
log.debug(
|
||||
f"Querying with offset: {offset}, current_fetch: {current_fetch}"
|
||||
)
|
||||
|
||||
results = self.client.query(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
filter=filter_string,
|
||||
output_fields=["*"],
|
||||
output_fields=[
|
||||
"id",
|
||||
"data",
|
||||
"metadata",
|
||||
], # Explicitly list needed fields. Vector not usually needed in query.
|
||||
limit=current_fetch,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
if not results:
|
||||
log.debug("No more results from query.")
|
||||
break
|
||||
|
||||
all_results.extend(results)
|
||||
results_count = len(results)
|
||||
remaining -= (
|
||||
results_count # Decrease remaining by the number of items fetched
|
||||
)
|
||||
log.debug(f"Fetched {results_count} results in this batch.")
|
||||
|
||||
if isinstance(remaining, int):
|
||||
remaining -= results_count
|
||||
|
||||
offset += results_count
|
||||
|
||||
# Break the loop if the results returned are less than the requested fetch count
|
||||
# Break the loop if the results returned are less than the requested fetch count (means end of data)
|
||||
if results_count < current_fetch:
|
||||
log.debug(
|
||||
"Fetched less than requested, assuming end of results for this query."
|
||||
)
|
||||
break
|
||||
|
||||
log.debug(all_results)
|
||||
log.info(f"Total results from query: {len(all_results)}")
|
||||
return self._result_to_get_result([all_results])
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error querying collection {collection_name} with limit {limit}: {e}"
|
||||
f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
# Get all the items in the collection.
|
||||
# Get all the items in the collection. This can be very resource-intensive for large collections.
|
||||
collection_name = collection_name.replace("-", "_")
|
||||
result = self.client.query(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
filter='id != ""',
|
||||
log.warning(
|
||||
f"Fetching ALL items from collection '{self.collection_prefix}_{collection_name}'. This might be slow for large collections."
|
||||
)
|
||||
return self._result_to_get_result([result])
|
||||
# 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)
|
||||
|
||||
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.
|
||||
@@ -230,10 +287,23 @@ class MilvusClient(VectorDBBase):
|
||||
if not self.client.has_collection(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}"
|
||||
):
|
||||
log.info(
|
||||
f"Collection {self.collection_prefix}_{collection_name} does not exist. Creating now."
|
||||
)
|
||||
if not items:
|
||||
log.error(
|
||||
f"Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension."
|
||||
)
|
||||
raise ValueError(
|
||||
"Cannot create Milvus collection without items to determine vector dimension."
|
||||
)
|
||||
self._create_collection(
|
||||
collection_name=collection_name, dimension=len(items[0]["vector"])
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}."
|
||||
)
|
||||
return self.client.insert(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
data=[
|
||||
@@ -253,10 +323,23 @@ class MilvusClient(VectorDBBase):
|
||||
if not self.client.has_collection(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}"
|
||||
):
|
||||
log.info(
|
||||
f"Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now."
|
||||
)
|
||||
if not items:
|
||||
log.error(
|
||||
f"Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension."
|
||||
)
|
||||
raise ValueError(
|
||||
"Cannot create Milvus collection for upsert without items to determine vector dimension."
|
||||
)
|
||||
self._create_collection(
|
||||
collection_name=collection_name, dimension=len(items[0]["vector"])
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}."
|
||||
)
|
||||
return self.client.upsert(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
data=[
|
||||
@@ -276,30 +359,55 @@ class MilvusClient(VectorDBBase):
|
||||
ids: Optional[list[str]] = None,
|
||||
filter: Optional[dict] = None,
|
||||
):
|
||||
# Delete the items from the collection based on the ids.
|
||||
# Delete the items from the collection based on the ids or filter.
|
||||
collection_name = collection_name.replace("-", "_")
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(
|
||||
f"Delete attempted on non-existent collection: {self.collection_prefix}_{collection_name}"
|
||||
)
|
||||
return None
|
||||
|
||||
if ids:
|
||||
log.info(
|
||||
f"Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}"
|
||||
)
|
||||
return self.client.delete(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
ids=ids,
|
||||
)
|
||||
elif filter:
|
||||
# Convert the filter dictionary to a string using JSON_CONTAINS.
|
||||
filter_string = " && ".join(
|
||||
[
|
||||
f'metadata["{key}"] == {json.dumps(value)}'
|
||||
for key, value in filter.items()
|
||||
]
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}"
|
||||
)
|
||||
return self.client.delete(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
filter=filter_string,
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"Delete operation on {self.collection_prefix}_{collection_name} called without IDs or filter. No action taken."
|
||||
)
|
||||
return None
|
||||
|
||||
def reset(self):
|
||||
# Resets the database. This will delete all collections and item entries.
|
||||
# Resets the database. This will delete all collections and item entries that match the prefix.
|
||||
log.warning(
|
||||
f"Resetting Milvus: Deleting all collections with prefix '{self.collection_prefix}'."
|
||||
)
|
||||
collection_names = self.client.list_collections()
|
||||
for collection_name in collection_names:
|
||||
if collection_name.startswith(self.collection_prefix):
|
||||
self.client.drop_collection(collection_name=collection_name)
|
||||
deleted_collections = []
|
||||
for collection_name_full in collection_names:
|
||||
if collection_name_full.startswith(self.collection_prefix):
|
||||
try:
|
||||
self.client.drop_collection(collection_name=collection_name_full)
|
||||
deleted_collections.append(collection_name_full)
|
||||
log.info(f"Deleted collection: {collection_name_full}")
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting collection {collection_name_full}: {e}")
|
||||
log.info(f"Milvus reset complete. Deleted collections: {deleted_collections}")
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
import json
|
||||
from sqlalchemy import (
|
||||
func,
|
||||
literal,
|
||||
cast,
|
||||
column,
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
MetaData,
|
||||
LargeBinary,
|
||||
select,
|
||||
text,
|
||||
Text,
|
||||
@@ -28,7 +32,12 @@ from open_webui.retrieval.vector.main import (
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
||||
from open_webui.config import (
|
||||
PGVECTOR_DB_URL,
|
||||
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
||||
PGVECTOR_PGCRYPTO,
|
||||
PGVECTOR_PGCRYPTO_KEY,
|
||||
)
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
@@ -39,14 +48,27 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def pgcrypto_encrypt(val, key):
|
||||
return func.pgp_sym_encrypt(val, literal(key))
|
||||
|
||||
|
||||
def pgcrypto_decrypt(col, key, outtype="text"):
|
||||
return func.cast(func.pgp_sym_decrypt(col, literal(key)), outtype)
|
||||
|
||||
|
||||
class DocumentChunk(Base):
|
||||
__tablename__ = "document_chunk"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True)
|
||||
collection_name = Column(Text, nullable=False)
|
||||
text = Column(Text, nullable=True)
|
||||
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
|
||||
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
text = Column(LargeBinary, nullable=True)
|
||||
vmetadata = Column(LargeBinary, nullable=True)
|
||||
else:
|
||||
text = Column(Text, nullable=True)
|
||||
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
|
||||
|
||||
|
||||
class PgvectorClient(VectorDBBase):
|
||||
@@ -70,6 +92,15 @@ class PgvectorClient(VectorDBBase):
|
||||
# Ensure the pgvector extension is available
|
||||
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
|
||||
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
# Ensure the pgcrypto extension is available for encryption
|
||||
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto;"))
|
||||
|
||||
if not PGVECTOR_PGCRYPTO_KEY:
|
||||
raise ValueError(
|
||||
"PGVECTOR_PGCRYPTO_KEY must be set when PGVECTOR_PGCRYPTO is enabled."
|
||||
)
|
||||
|
||||
# Check vector length consistency
|
||||
self.check_vector_length()
|
||||
|
||||
@@ -147,44 +178,39 @@ class PgvectorClient(VectorDBBase):
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
try:
|
||||
new_items = []
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
new_chunk = DocumentChunk(
|
||||
id=item["id"],
|
||||
vector=vector,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=item["metadata"],
|
||||
)
|
||||
new_items.append(new_chunk)
|
||||
self.session.bulk_save_objects(new_items)
|
||||
self.session.commit()
|
||||
log.info(
|
||||
f"Inserted {len(new_items)} items into collection '{collection_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error during insert: {e}")
|
||||
raise
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
try:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
existing = (
|
||||
self.session.query(DocumentChunk)
|
||||
.filter(DocumentChunk.id == item["id"])
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.vector = vector
|
||||
existing.text = item["text"]
|
||||
existing.vmetadata = item["metadata"]
|
||||
existing.collection_name = (
|
||||
collection_name # Update collection_name if necessary
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
# Use raw SQL for BYTEA/pgcrypto
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO document_chunk
|
||||
(id, vector, collection_name, text, vmetadata)
|
||||
VALUES (
|
||||
:id, :vector, :collection_name,
|
||||
pgp_sym_encrypt(:text, :key),
|
||||
pgp_sym_encrypt(:metadata::text, :key)
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": vector,
|
||||
"collection_name": collection_name,
|
||||
"text": item["text"],
|
||||
"metadata": json.dumps(item["metadata"]),
|
||||
"key": PGVECTOR_PGCRYPTO_KEY,
|
||||
},
|
||||
)
|
||||
else:
|
||||
self.session.commit()
|
||||
log.info(f"Encrypted & inserted {len(items)} into '{collection_name}'")
|
||||
|
||||
else:
|
||||
new_items = []
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
new_chunk = DocumentChunk(
|
||||
id=item["id"],
|
||||
vector=vector,
|
||||
@@ -192,11 +218,78 @@ class PgvectorClient(VectorDBBase):
|
||||
text=item["text"],
|
||||
vmetadata=item["metadata"],
|
||||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
log.info(
|
||||
f"Upserted {len(items)} items into collection '{collection_name}'."
|
||||
)
|
||||
new_items.append(new_chunk)
|
||||
self.session.bulk_save_objects(new_items)
|
||||
self.session.commit()
|
||||
log.info(
|
||||
f"Inserted {len(new_items)} items into collection '{collection_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error during insert: {e}")
|
||||
raise
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
try:
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO document_chunk
|
||||
(id, vector, collection_name, text, vmetadata)
|
||||
VALUES (
|
||||
:id, :vector, :collection_name,
|
||||
pgp_sym_encrypt(:text, :key),
|
||||
pgp_sym_encrypt(:metadata::text, :key)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
vector = EXCLUDED.vector,
|
||||
collection_name = EXCLUDED.collection_name,
|
||||
text = EXCLUDED.text,
|
||||
vmetadata = EXCLUDED.vmetadata
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": vector,
|
||||
"collection_name": collection_name,
|
||||
"text": item["text"],
|
||||
"metadata": json.dumps(item["metadata"]),
|
||||
"key": PGVECTOR_PGCRYPTO_KEY,
|
||||
},
|
||||
)
|
||||
self.session.commit()
|
||||
log.info(f"Encrypted & upserted {len(items)} into '{collection_name}'")
|
||||
else:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
existing = (
|
||||
self.session.query(DocumentChunk)
|
||||
.filter(DocumentChunk.id == item["id"])
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.vector = vector
|
||||
existing.text = item["text"]
|
||||
existing.vmetadata = item["metadata"]
|
||||
existing.collection_name = (
|
||||
collection_name # Update collection_name if necessary
|
||||
)
|
||||
else:
|
||||
new_chunk = DocumentChunk(
|
||||
id=item["id"],
|
||||
vector=vector,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=item["metadata"],
|
||||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
log.info(
|
||||
f"Upserted {len(items)} items into collection '{collection_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error during upsert: {e}")
|
||||
@@ -230,16 +323,32 @@ class PgvectorClient(VectorDBBase):
|
||||
.alias("query_vectors")
|
||||
)
|
||||
|
||||
result_fields = [
|
||||
DocumentChunk.id,
|
||||
]
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
result_fields.append(
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text
|
||||
).label("text")
|
||||
)
|
||||
result_fields.append(
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB
|
||||
).label("vmetadata")
|
||||
)
|
||||
else:
|
||||
result_fields.append(DocumentChunk.text)
|
||||
result_fields.append(DocumentChunk.vmetadata)
|
||||
result_fields.append(
|
||||
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label(
|
||||
"distance"
|
||||
)
|
||||
)
|
||||
|
||||
# Build the lateral subquery for each query vector
|
||||
subq = (
|
||||
select(
|
||||
DocumentChunk.id,
|
||||
DocumentChunk.text,
|
||||
DocumentChunk.vmetadata,
|
||||
(
|
||||
DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)
|
||||
).label("distance"),
|
||||
)
|
||||
select(*result_fields)
|
||||
.where(DocumentChunk.collection_name == collection_name)
|
||||
.order_by(
|
||||
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
|
||||
@@ -299,17 +408,43 @@ class PgvectorClient(VectorDBBase):
|
||||
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
try:
|
||||
query = self.session.query(DocumentChunk).filter(
|
||||
DocumentChunk.collection_name == collection_name
|
||||
)
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
# Build where clause for vmetadata filter
|
||||
where_clauses = [DocumentChunk.collection_name == collection_name]
|
||||
for key, value in filter.items():
|
||||
# decrypt then check key: JSON filter after decryption
|
||||
where_clauses.append(
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB
|
||||
)[key].astext
|
||||
== str(value)
|
||||
)
|
||||
stmt = select(
|
||||
DocumentChunk.id,
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text
|
||||
).label("text"),
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB
|
||||
).label("vmetadata"),
|
||||
).where(*where_clauses)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
results = self.session.execute(stmt).all()
|
||||
else:
|
||||
query = self.session.query(DocumentChunk).filter(
|
||||
DocumentChunk.collection_name == collection_name
|
||||
)
|
||||
|
||||
for key, value in filter.items():
|
||||
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
|
||||
for key, value in filter.items():
|
||||
query = query.filter(
|
||||
DocumentChunk.vmetadata[key].astext == str(value)
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
results = query.all()
|
||||
results = query.all()
|
||||
|
||||
if not results:
|
||||
return None
|
||||
@@ -331,20 +466,38 @@ class PgvectorClient(VectorDBBase):
|
||||
self, collection_name: str, limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
try:
|
||||
query = self.session.query(DocumentChunk).filter(
|
||||
DocumentChunk.collection_name == collection_name
|
||||
)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
stmt = select(
|
||||
DocumentChunk.id,
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text
|
||||
).label("text"),
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB
|
||||
).label("vmetadata"),
|
||||
).where(DocumentChunk.collection_name == collection_name)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
results = self.session.execute(stmt).all()
|
||||
ids = [[row.id for row in results]]
|
||||
documents = [[row.text for row in results]]
|
||||
metadatas = [[row.vmetadata for row in results]]
|
||||
else:
|
||||
|
||||
results = query.all()
|
||||
query = self.session.query(DocumentChunk).filter(
|
||||
DocumentChunk.collection_name == collection_name
|
||||
)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
if not results:
|
||||
return None
|
||||
results = query.all()
|
||||
|
||||
ids = [[result.id for result in results]]
|
||||
documents = [[result.text for result in results]]
|
||||
metadatas = [[result.vmetadata for result in results]]
|
||||
if not results:
|
||||
return None
|
||||
|
||||
ids = [[result.id for result in results]]
|
||||
documents = [[result.text for result in results]]
|
||||
metadatas = [[result.vmetadata for result in results]]
|
||||
|
||||
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
|
||||
except Exception as e:
|
||||
@@ -358,17 +511,33 @@ class PgvectorClient(VectorDBBase):
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
try:
|
||||
query = self.session.query(DocumentChunk).filter(
|
||||
DocumentChunk.collection_name == collection_name
|
||||
)
|
||||
if ids:
|
||||
query = query.filter(DocumentChunk.id.in_(ids))
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
query = query.filter(
|
||||
DocumentChunk.vmetadata[key].astext == str(value)
|
||||
)
|
||||
deleted = query.delete(synchronize_session=False)
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
wheres = [DocumentChunk.collection_name == collection_name]
|
||||
if ids:
|
||||
wheres.append(DocumentChunk.id.in_(ids))
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
wheres.append(
|
||||
pgcrypto_decrypt(
|
||||
DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB
|
||||
)[key].astext
|
||||
== str(value)
|
||||
)
|
||||
stmt = DocumentChunk.__table__.delete().where(*wheres)
|
||||
result = self.session.execute(stmt)
|
||||
deleted = result.rowcount
|
||||
else:
|
||||
query = self.session.query(DocumentChunk).filter(
|
||||
DocumentChunk.collection_name == collection_name
|
||||
)
|
||||
if ids:
|
||||
query = query.filter(DocumentChunk.id.in_(ids))
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
query = query.filter(
|
||||
DocumentChunk.vmetadata[key].astext == str(value)
|
||||
)
|
||||
deleted = query.delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
from typing import Optional, List, Dict, Any, Union
|
||||
import logging
|
||||
import time # for measuring elapsed time
|
||||
from pinecone import Pinecone, ServerlessSpec
|
||||
|
||||
# Add gRPC support for better performance (Pinecone best practice)
|
||||
try:
|
||||
from pinecone.grpc import PineconeGRPC
|
||||
|
||||
GRPC_AVAILABLE = True
|
||||
except ImportError:
|
||||
GRPC_AVAILABLE = False
|
||||
|
||||
import asyncio # for async upserts
|
||||
import functools # for partial binding in async tasks
|
||||
|
||||
import concurrent.futures # for parallel batch upserts
|
||||
import random # for jitter in retry backoff
|
||||
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -40,8 +55,28 @@ class PineconeClient(VectorDBBase):
|
||||
self.metric = PINECONE_METRIC
|
||||
self.cloud = PINECONE_CLOUD
|
||||
|
||||
# Initialize Pinecone client
|
||||
self.client = Pinecone(api_key=self.api_key)
|
||||
# Initialize Pinecone client for improved performance
|
||||
if GRPC_AVAILABLE:
|
||||
# Use gRPC client for better performance (Pinecone recommendation)
|
||||
self.client = PineconeGRPC(
|
||||
api_key=self.api_key,
|
||||
pool_threads=20, # Improved connection pool size
|
||||
timeout=30, # Reasonable timeout for operations
|
||||
)
|
||||
self.using_grpc = True
|
||||
log.info("Using Pinecone gRPC client for optimal performance")
|
||||
else:
|
||||
# Fallback to HTTP client with enhanced connection pooling
|
||||
self.client = Pinecone(
|
||||
api_key=self.api_key,
|
||||
pool_threads=20, # Improved connection pool size
|
||||
timeout=30, # Reasonable timeout for operations
|
||||
)
|
||||
self.using_grpc = False
|
||||
log.info("Using Pinecone HTTP client (gRPC not available)")
|
||||
|
||||
# Persistent executor for batch operations
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5)
|
||||
|
||||
# Create index if it doesn't exist
|
||||
self._initialize_index()
|
||||
@@ -82,12 +117,53 @@ class PineconeClient(VectorDBBase):
|
||||
log.info(f"Using existing Pinecone index '{self.index_name}'")
|
||||
|
||||
# Connect to the index
|
||||
self.index = self.client.Index(self.index_name)
|
||||
self.index = self.client.Index(
|
||||
self.index_name,
|
||||
pool_threads=20, # Enhanced connection pool for index operations
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize Pinecone index: {e}")
|
||||
raise RuntimeError(f"Failed to initialize Pinecone index: {e}")
|
||||
|
||||
def _retry_pinecone_operation(self, operation_func, max_retries=3):
|
||||
"""Retry Pinecone operations with exponential backoff for rate limits and network issues."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return operation_func()
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
# Check if it's a retryable error (rate limits, network issues, timeouts)
|
||||
is_retryable = any(
|
||||
keyword in error_str
|
||||
for keyword in [
|
||||
"rate limit",
|
||||
"quota",
|
||||
"timeout",
|
||||
"network",
|
||||
"connection",
|
||||
"unavailable",
|
||||
"internal error",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
]
|
||||
)
|
||||
|
||||
if not is_retryable or attempt == max_retries - 1:
|
||||
# Don't retry for non-retryable errors or on final attempt
|
||||
raise
|
||||
|
||||
# Exponential backoff with jitter
|
||||
delay = (2**attempt) + random.uniform(0, 1)
|
||||
log.warning(
|
||||
f"Pinecone operation failed (attempt {attempt + 1}/{max_retries}), "
|
||||
f"retrying in {delay:.2f}s: {e}"
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
def _create_points(
|
||||
self, items: List[VectorItem], collection_name_with_prefix: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -135,8 +211,8 @@ class PineconeClient(VectorDBBase):
|
||||
metadatas = []
|
||||
|
||||
for match in matches:
|
||||
metadata = match.get("metadata", {})
|
||||
ids.append(match["id"])
|
||||
metadata = getattr(match, "metadata", {}) or {}
|
||||
ids.append(match.id if hasattr(match, "id") else match["id"])
|
||||
documents.append(metadata.get("text", ""))
|
||||
metadatas.append(metadata)
|
||||
|
||||
@@ -162,7 +238,8 @@ class PineconeClient(VectorDBBase):
|
||||
filter={"collection_name": collection_name_with_prefix},
|
||||
include_metadata=False,
|
||||
)
|
||||
return len(response.matches) > 0
|
||||
matches = getattr(response, "matches", []) or []
|
||||
return len(matches) > 0
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error checking collection '{collection_name_with_prefix}': {e}"
|
||||
@@ -191,27 +268,30 @@ class PineconeClient(VectorDBBase):
|
||||
log.warning("No items to insert")
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
points = self._create_points(items, collection_name_with_prefix)
|
||||
|
||||
# Insert in batches for better performance and reliability
|
||||
# Parallelize batch inserts for performance
|
||||
executor = self._executor
|
||||
futures = []
|
||||
for i in range(0, len(points), BATCH_SIZE):
|
||||
batch = points[i : i + BATCH_SIZE]
|
||||
futures.append(executor.submit(self.index.upsert, vectors=batch))
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
self.index.upsert(vectors=batch)
|
||||
log.debug(
|
||||
f"Inserted batch of {len(batch)} vectors into '{collection_name_with_prefix}'"
|
||||
)
|
||||
future.result()
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error inserting batch into '{collection_name_with_prefix}': {e}"
|
||||
)
|
||||
log.error(f"Error inserting batch: {e}")
|
||||
raise
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
log.debug(f"Insert of {len(points)} vectors took {elapsed:.2f} seconds")
|
||||
log.info(
|
||||
f"Successfully inserted {len(items)} vectors into '{collection_name_with_prefix}'"
|
||||
f"Successfully inserted {len(points)} vectors in parallel batches "
|
||||
f"into '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
@@ -220,27 +300,94 @@ class PineconeClient(VectorDBBase):
|
||||
log.warning("No items to upsert")
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
points = self._create_points(items, collection_name_with_prefix)
|
||||
|
||||
# Parallelize batch upserts for performance
|
||||
executor = self._executor
|
||||
futures = []
|
||||
for i in range(0, len(points), BATCH_SIZE):
|
||||
batch = points[i : i + BATCH_SIZE]
|
||||
futures.append(executor.submit(self.index.upsert, vectors=batch))
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
log.error(f"Error upserting batch: {e}")
|
||||
raise
|
||||
elapsed = time.time() - start_time
|
||||
log.debug(f"Upsert of {len(points)} vectors took {elapsed:.2f} seconds")
|
||||
log.info(
|
||||
f"Successfully upserted {len(points)} vectors in parallel batches "
|
||||
f"into '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Async version of insert using asyncio and run_in_executor for improved performance."""
|
||||
if not items:
|
||||
log.warning("No items to insert")
|
||||
return
|
||||
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
points = self._create_points(items, collection_name_with_prefix)
|
||||
|
||||
# Create batches
|
||||
batches = [
|
||||
points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE)
|
||||
]
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = [
|
||||
loop.run_in_executor(
|
||||
None, functools.partial(self.index.upsert, vectors=batch)
|
||||
)
|
||||
for batch in batches
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
log.error(f"Error in async insert batch: {result}")
|
||||
raise result
|
||||
log.info(
|
||||
f"Successfully async inserted {len(points)} vectors in batches "
|
||||
f"into '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Async version of upsert using asyncio and run_in_executor for improved performance."""
|
||||
if not items:
|
||||
log.warning("No items to upsert")
|
||||
return
|
||||
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
points = self._create_points(items, collection_name_with_prefix)
|
||||
|
||||
# Upsert in batches
|
||||
for i in range(0, len(points), BATCH_SIZE):
|
||||
batch = points[i : i + BATCH_SIZE]
|
||||
try:
|
||||
self.index.upsert(vectors=batch)
|
||||
log.debug(
|
||||
f"Upserted batch of {len(batch)} vectors into '{collection_name_with_prefix}'"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error upserting batch into '{collection_name_with_prefix}': {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
# Create batches
|
||||
batches = [
|
||||
points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE)
|
||||
]
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = [
|
||||
loop.run_in_executor(
|
||||
None, functools.partial(self.index.upsert, vectors=batch)
|
||||
)
|
||||
for batch in batches
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
log.error(f"Error in async upsert batch: {result}")
|
||||
raise result
|
||||
log.info(
|
||||
f"Successfully upserted {len(items)} vectors into '{collection_name_with_prefix}'"
|
||||
f"Successfully async upserted {len(points)} vectors in batches "
|
||||
f"into '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
def search(
|
||||
@@ -270,7 +417,8 @@ class PineconeClient(VectorDBBase):
|
||||
filter={"collection_name": collection_name_with_prefix},
|
||||
)
|
||||
|
||||
if not query_response.matches:
|
||||
matches = getattr(query_response, "matches", []) or []
|
||||
if not matches:
|
||||
# Return empty result if no matches
|
||||
return SearchResult(
|
||||
ids=[[]],
|
||||
@@ -280,13 +428,13 @@ class PineconeClient(VectorDBBase):
|
||||
)
|
||||
|
||||
# Convert to GetResult format
|
||||
get_result = self._result_to_get_result(query_response.matches)
|
||||
get_result = self._result_to_get_result(matches)
|
||||
|
||||
# Calculate normalized distances based on metric
|
||||
distances = [
|
||||
[
|
||||
self._normalize_distance(match.score)
|
||||
for match in query_response.matches
|
||||
self._normalize_distance(getattr(match, "score", 0.0))
|
||||
for match in matches
|
||||
]
|
||||
]
|
||||
|
||||
@@ -328,7 +476,8 @@ class PineconeClient(VectorDBBase):
|
||||
include_metadata=True,
|
||||
)
|
||||
|
||||
return self._result_to_get_result(query_response.matches)
|
||||
matches = getattr(query_response, "matches", []) or []
|
||||
return self._result_to_get_result(matches)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error querying collection '{collection_name}': {e}")
|
||||
@@ -352,7 +501,8 @@ class PineconeClient(VectorDBBase):
|
||||
filter={"collection_name": collection_name_with_prefix},
|
||||
)
|
||||
|
||||
return self._result_to_get_result(query_response.matches)
|
||||
matches = getattr(query_response, "matches", []) or []
|
||||
return self._result_to_get_result(matches)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error getting collection '{collection_name}': {e}")
|
||||
@@ -378,10 +528,12 @@ class PineconeClient(VectorDBBase):
|
||||
# This is a limitation of Pinecone - be careful with ID uniqueness
|
||||
self.index.delete(ids=batch_ids)
|
||||
log.debug(
|
||||
f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'"
|
||||
f"Deleted batch of {len(batch_ids)} vectors by ID "
|
||||
f"from '{collection_name_with_prefix}'"
|
||||
)
|
||||
log.info(
|
||||
f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'"
|
||||
f"Successfully deleted {len(ids)} vectors by ID "
|
||||
f"from '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
elif filter:
|
||||
@@ -410,3 +562,20 @@ class PineconeClient(VectorDBBase):
|
||||
except Exception as e:
|
||||
log.error(f"Failed to reset Pinecone index: {e}")
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Shut down resources."""
|
||||
try:
|
||||
# The new Pinecone client doesn't need explicit closing
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to clean up Pinecone resources: {e}")
|
||||
self._executor.shutdown(wait=True)
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit context manager, ensuring resources are cleaned up."""
|
||||
self.close()
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import grpc
|
||||
from open_webui.config import (
|
||||
QDRANT_API_KEY,
|
||||
QDRANT_GRPC_PORT,
|
||||
QDRANT_ON_DISK,
|
||||
QDRANT_PREFER_GRPC,
|
||||
QDRANT_URI,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from qdrant_client import QdrantClient as Qclient
|
||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||
from qdrant_client.http.models import PointStruct
|
||||
from qdrant_client.models import models
|
||||
|
||||
NO_LIMIT = 999999999
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class QdrantClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = "open-webui"
|
||||
self.QDRANT_URI = QDRANT_URI
|
||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||
self.PREFER_GRPC = QDRANT_PREFER_GRPC
|
||||
self.GRPC_PORT = QDRANT_GRPC_PORT
|
||||
|
||||
if not self.QDRANT_URI:
|
||||
self.client = None
|
||||
return
|
||||
|
||||
# Unified handling for either scheme
|
||||
parsed = urlparse(self.QDRANT_URI)
|
||||
host = parsed.hostname or self.QDRANT_URI
|
||||
http_port = parsed.port or 6333 # default REST port
|
||||
|
||||
if self.PREFER_GRPC:
|
||||
self.client = Qclient(
|
||||
host=host,
|
||||
port=http_port,
|
||||
grpc_port=self.GRPC_PORT,
|
||||
prefer_grpc=self.PREFER_GRPC,
|
||||
api_key=self.QDRANT_API_KEY,
|
||||
)
|
||||
else:
|
||||
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
||||
|
||||
# Main collection types for multi-tenancy
|
||||
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
||||
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
|
||||
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
|
||||
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web-search"
|
||||
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
|
||||
|
||||
def _result_to_get_result(self, points) -> GetResult:
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for point in points:
|
||||
payload = point.payload
|
||||
ids.append(point.id)
|
||||
documents.append(payload["text"])
|
||||
metadatas.append(payload["metadata"])
|
||||
|
||||
return GetResult(
|
||||
**{
|
||||
"ids": [ids],
|
||||
"documents": [documents],
|
||||
"metadatas": [metadatas],
|
||||
}
|
||||
)
|
||||
|
||||
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Maps the traditional collection name to multi-tenant collection and tenant ID.
|
||||
|
||||
Returns:
|
||||
tuple: (collection_name, tenant_id)
|
||||
"""
|
||||
# Check for user memory collections
|
||||
tenant_id = collection_name
|
||||
|
||||
if collection_name.startswith("user-memory-"):
|
||||
return self.MEMORY_COLLECTION, tenant_id
|
||||
|
||||
# Check for file collections
|
||||
elif collection_name.startswith("file-"):
|
||||
return self.FILE_COLLECTION, tenant_id
|
||||
|
||||
# Check for web search collections
|
||||
elif collection_name.startswith("web-search-"):
|
||||
return self.WEB_SEARCH_COLLECTION, tenant_id
|
||||
|
||||
# Handle hash-based collections (YouTube and web URLs)
|
||||
elif len(collection_name) == 63 and all(
|
||||
c in "0123456789abcdef" for c in collection_name
|
||||
):
|
||||
return self.HASH_BASED_COLLECTION, tenant_id
|
||||
|
||||
else:
|
||||
return self.KNOWLEDGE_COLLECTION, tenant_id
|
||||
|
||||
def _extract_error_message(self, exception):
|
||||
"""
|
||||
Extract error message from either HTTP or gRPC exceptions
|
||||
|
||||
Returns:
|
||||
tuple: (status_code, error_message)
|
||||
"""
|
||||
# Check if it's an HTTP exception
|
||||
if isinstance(exception, UnexpectedResponse):
|
||||
try:
|
||||
error_data = exception.structured()
|
||||
error_msg = error_data.get("status", {}).get("error", "")
|
||||
return exception.status_code, error_msg
|
||||
except Exception as inner_e:
|
||||
log.error(f"Failed to parse HTTP error: {inner_e}")
|
||||
return exception.status_code, str(exception)
|
||||
|
||||
# Check if it's a gRPC exception
|
||||
elif isinstance(exception, grpc.RpcError):
|
||||
# Extract status code from gRPC error
|
||||
status_code = None
|
||||
if hasattr(exception, "code") and callable(exception.code):
|
||||
status_code = exception.code().value[0]
|
||||
|
||||
# Extract error message
|
||||
error_msg = str(exception)
|
||||
if "details =" in error_msg:
|
||||
# Parse the details line which contains the actual error message
|
||||
try:
|
||||
details_line = [
|
||||
line.strip()
|
||||
for line in error_msg.split("\n")
|
||||
if "details =" in line
|
||||
][0]
|
||||
error_msg = details_line.split("details =")[1].strip(' "')
|
||||
except (IndexError, AttributeError):
|
||||
# Fall back to full message if parsing fails
|
||||
pass
|
||||
|
||||
return status_code, error_msg
|
||||
|
||||
# For any other type of exception
|
||||
return None, str(exception)
|
||||
|
||||
def _is_collection_not_found_error(self, exception):
|
||||
"""
|
||||
Check if the exception is due to collection not found, supporting both HTTP and gRPC
|
||||
"""
|
||||
status_code, error_msg = self._extract_error_message(exception)
|
||||
|
||||
# HTTP error (404)
|
||||
if (
|
||||
status_code == 404
|
||||
and "Collection" in error_msg
|
||||
and "doesn't exist" in error_msg
|
||||
):
|
||||
return True
|
||||
|
||||
# gRPC error (NOT_FOUND status)
|
||||
if (
|
||||
isinstance(exception, grpc.RpcError)
|
||||
and exception.code() == grpc.StatusCode.NOT_FOUND
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_dimension_mismatch_error(self, exception):
|
||||
"""
|
||||
Check if the exception is due to dimension mismatch, supporting both HTTP and gRPC
|
||||
"""
|
||||
status_code, error_msg = self._extract_error_message(exception)
|
||||
|
||||
# Common patterns in both HTTP and gRPC
|
||||
return (
|
||||
"Vector dimension error" in error_msg
|
||||
or "dimensions mismatch" in error_msg
|
||||
or "invalid vector size" in error_msg
|
||||
)
|
||||
|
||||
def _create_multi_tenant_collection_if_not_exists(
|
||||
self, mt_collection_name: str, dimension: int = 384
|
||||
):
|
||||
"""
|
||||
Creates a collection with multi-tenancy configuration if it doesn't exist.
|
||||
Default dimension is set to 384 which corresponds to 'sentence-transformers/all-MiniLM-L6-v2'.
|
||||
When creating collections dynamically (insert/upsert), the actual vector dimensions will be used.
|
||||
"""
|
||||
try:
|
||||
# Try to create the collection directly - will fail if it already exists
|
||||
self.client.create_collection(
|
||||
collection_name=mt_collection_name,
|
||||
vectors_config=models.VectorParams(
|
||||
size=dimension,
|
||||
distance=models.Distance.COSINE,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
hnsw_config=models.HnswConfigDiff(
|
||||
payload_m=16, # Enable per-tenant indexing
|
||||
m=0,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
)
|
||||
|
||||
# Create tenant ID payload index
|
||||
self.client.create_payload_index(
|
||||
collection_name=mt_collection_name,
|
||||
field_name="tenant_id",
|
||||
field_schema=models.KeywordIndexParams(
|
||||
type=models.KeywordIndexType.KEYWORD,
|
||||
is_tenant=True,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
wait=True,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
|
||||
)
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
# Check for the specific error indicating collection already exists
|
||||
status_code, error_msg = self._extract_error_message(e)
|
||||
|
||||
# HTTP status code 409 or gRPC ALREADY_EXISTS
|
||||
if (isinstance(e, UnexpectedResponse) and status_code == 409) or (
|
||||
isinstance(e, grpc.RpcError)
|
||||
and e.code() == grpc.StatusCode.ALREADY_EXISTS
|
||||
):
|
||||
if "already exists" in error_msg:
|
||||
log.debug(f"Collection {mt_collection_name} already exists")
|
||||
return
|
||||
# If it's not an already exists error, re-raise
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def _create_points(self, items: list[VectorItem], tenant_id: str):
|
||||
"""
|
||||
Create point structs from vector items with tenant ID.
|
||||
"""
|
||||
return [
|
||||
PointStruct(
|
||||
id=item["id"],
|
||||
vector=item["vector"],
|
||||
payload={
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
"tenant_id": tenant_id,
|
||||
},
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
"""
|
||||
Check if a logical collection exists by checking for any points with the tenant ID.
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Create tenant filter
|
||||
tenant_filter = models.FieldCondition(
|
||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
try:
|
||||
# Try directly querying - most of the time collection should exist
|
||||
response = self.client.query_points(
|
||||
collection_name=mt_collection,
|
||||
query_filter=models.Filter(must=[tenant_filter]),
|
||||
limit=1,
|
||||
)
|
||||
|
||||
# Collection exists with this tenant ID if there are points
|
||||
return len(response.points) > 0
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
if self._is_collection_not_found_error(e):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist")
|
||||
return False
|
||||
else:
|
||||
# For other API errors, log and return False
|
||||
_, error_msg = self._extract_error_message(e)
|
||||
log.warning(f"Unexpected Qdrant error: {error_msg}")
|
||||
return False
|
||||
except Exception as e:
|
||||
# For any other errors, log and return False
|
||||
log.debug(f"Error checking collection {mt_collection}: {e}")
|
||||
return False
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[list[str]] = None,
|
||||
filter: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Delete vectors by ID or filter from a collection with tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Create tenant filter
|
||||
tenant_filter = models.FieldCondition(
|
||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
must_conditions = [tenant_filter]
|
||||
should_conditions = []
|
||||
|
||||
if ids:
|
||||
for id_value in ids:
|
||||
should_conditions.append(
|
||||
models.FieldCondition(
|
||||
key="metadata.id",
|
||||
match=models.MatchValue(value=id_value),
|
||||
),
|
||||
)
|
||||
elif filter:
|
||||
for key, value in filter.items():
|
||||
must_conditions.append(
|
||||
models.FieldCondition(
|
||||
key=f"metadata.{key}",
|
||||
match=models.MatchValue(value=value),
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Try to delete directly - most of the time collection should exist
|
||||
update_result = self.client.delete(
|
||||
collection_name=mt_collection,
|
||||
points_selector=models.FilterSelector(
|
||||
filter=models.Filter(must=must_conditions, should=should_conditions)
|
||||
),
|
||||
)
|
||||
|
||||
return update_result
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
if self._is_collection_not_found_error(e):
|
||||
log.debug(
|
||||
f"Collection {mt_collection} doesn't exist, nothing to delete"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# For other API errors, log and re-raise
|
||||
_, error_msg = self._extract_error_message(e)
|
||||
log.warning(f"Unexpected Qdrant error: {error_msg}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# For non-Qdrant exceptions, re-raise
|
||||
raise
|
||||
|
||||
def search(
|
||||
self, collection_name: str, vectors: list[list[float | int]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
"""
|
||||
Search for the nearest neighbor items based on the vectors with tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Get the vector dimension from the query vector
|
||||
dimension = len(vectors[0]) if vectors and len(vectors) > 0 else None
|
||||
|
||||
try:
|
||||
# Try the search operation directly - most of the time collection should exist
|
||||
|
||||
# Create tenant filter
|
||||
tenant_filter = models.FieldCondition(
|
||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
# Ensure vector dimensions match the collection
|
||||
collection_dim = self.client.get_collection(
|
||||
mt_collection
|
||||
).config.params.vectors.size
|
||||
|
||||
if collection_dim != dimension:
|
||||
if collection_dim < dimension:
|
||||
vectors = [vector[:collection_dim] for vector in vectors]
|
||||
else:
|
||||
vectors = [
|
||||
vector + [0] * (collection_dim - dimension)
|
||||
for vector in vectors
|
||||
]
|
||||
|
||||
# Search with tenant filter
|
||||
prefetch_query = models.Prefetch(
|
||||
filter=models.Filter(must=[tenant_filter]),
|
||||
limit=NO_LIMIT,
|
||||
)
|
||||
query_response = self.client.query_points(
|
||||
collection_name=mt_collection,
|
||||
query=vectors[0],
|
||||
prefetch=prefetch_query,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
get_result = self._result_to_get_result(query_response.points)
|
||||
return SearchResult(
|
||||
ids=get_result.ids,
|
||||
documents=get_result.documents,
|
||||
metadatas=get_result.metadatas,
|
||||
# qdrant distance is [-1, 1], normalize to [0, 1]
|
||||
distances=[
|
||||
[(point.score + 1.0) / 2.0 for point in query_response.points]
|
||||
],
|
||||
)
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
if self._is_collection_not_found_error(e):
|
||||
log.debug(
|
||||
f"Collection {mt_collection} doesn't exist, search returns None"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# For other API errors, log and re-raise
|
||||
_, error_msg = self._extract_error_message(e)
|
||||
log.warning(f"Unexpected Qdrant error during search: {error_msg}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# For non-Qdrant exceptions, log and return None
|
||||
log.exception(f"Error searching collection '{collection_name}': {e}")
|
||||
return None
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
||||
"""
|
||||
Query points with filters and tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Set default limit if not provided
|
||||
if limit is None:
|
||||
limit = NO_LIMIT
|
||||
|
||||
# Create tenant filter
|
||||
tenant_filter = models.FieldCondition(
|
||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
# Create metadata filters
|
||||
field_conditions = []
|
||||
for key, value in filter.items():
|
||||
field_conditions.append(
|
||||
models.FieldCondition(
|
||||
key=f"metadata.{key}", match=models.MatchValue(value=value)
|
||||
)
|
||||
)
|
||||
|
||||
# Combine tenant filter with metadata filters
|
||||
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
||||
|
||||
try:
|
||||
# Try the query directly - most of the time collection should exist
|
||||
points = self.client.query_points(
|
||||
collection_name=mt_collection,
|
||||
query_filter=combined_filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return self._result_to_get_result(points.points)
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
if self._is_collection_not_found_error(e):
|
||||
log.debug(
|
||||
f"Collection {mt_collection} doesn't exist, query returns None"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# For other API errors, log and re-raise
|
||||
_, error_msg = self._extract_error_message(e)
|
||||
log.warning(f"Unexpected Qdrant error during query: {error_msg}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# For non-Qdrant exceptions, log and re-raise
|
||||
log.exception(f"Error querying collection '{collection_name}': {e}")
|
||||
return None
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
"""
|
||||
Get all items in a collection with tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Create tenant filter
|
||||
tenant_filter = models.FieldCondition(
|
||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
try:
|
||||
# Try to get points directly - most of the time collection should exist
|
||||
points = self.client.query_points(
|
||||
collection_name=mt_collection,
|
||||
query_filter=models.Filter(must=[tenant_filter]),
|
||||
limit=NO_LIMIT,
|
||||
)
|
||||
|
||||
return self._result_to_get_result(points.points)
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
if self._is_collection_not_found_error(e):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
||||
return None
|
||||
else:
|
||||
# For other API errors, log and re-raise
|
||||
_, error_msg = self._extract_error_message(e)
|
||||
log.warning(f"Unexpected Qdrant error during get: {error_msg}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# For non-Qdrant exceptions, log and return None
|
||||
log.exception(f"Error getting collection '{collection_name}': {e}")
|
||||
return None
|
||||
|
||||
def _handle_operation_with_error_retry(
|
||||
self, operation_name, mt_collection, points, dimension
|
||||
):
|
||||
"""
|
||||
Private helper to handle common error cases for insert and upsert operations.
|
||||
|
||||
Args:
|
||||
operation_name: 'insert' or 'upsert'
|
||||
mt_collection: The multi-tenant collection name
|
||||
points: The vector points to insert/upsert
|
||||
dimension: The dimension of the vectors
|
||||
|
||||
Returns:
|
||||
The operation result (for upsert) or None (for insert)
|
||||
"""
|
||||
try:
|
||||
if operation_name == "insert":
|
||||
self.client.upload_points(mt_collection, points)
|
||||
return None
|
||||
else: # upsert
|
||||
return self.client.upsert(mt_collection, points)
|
||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
||||
# Handle collection not found
|
||||
if self._is_collection_not_found_error(e):
|
||||
log.info(
|
||||
f"Collection {mt_collection} doesn't exist. Creating it with dimension {dimension}."
|
||||
)
|
||||
# Create collection with correct dimensions from our vectors
|
||||
self._create_multi_tenant_collection_if_not_exists(
|
||||
mt_collection_name=mt_collection, dimension=dimension
|
||||
)
|
||||
# Try operation again - no need for dimension adjustment since we just created with correct dimensions
|
||||
if operation_name == "insert":
|
||||
self.client.upload_points(mt_collection, points)
|
||||
return None
|
||||
else: # upsert
|
||||
return self.client.upsert(mt_collection, points)
|
||||
|
||||
# Handle dimension mismatch
|
||||
elif self._is_dimension_mismatch_error(e):
|
||||
# For dimension errors, the collection must exist, so get its configuration
|
||||
mt_collection_info = self.client.get_collection(mt_collection)
|
||||
existing_size = mt_collection_info.config.params.vectors.size
|
||||
|
||||
log.info(
|
||||
f"Dimension mismatch: Collection {mt_collection} expects {existing_size}, got {dimension}"
|
||||
)
|
||||
|
||||
if existing_size < dimension:
|
||||
# Truncate vectors to fit
|
||||
log.info(
|
||||
f"Truncating vectors from {dimension} to {existing_size} dimensions"
|
||||
)
|
||||
points = [
|
||||
PointStruct(
|
||||
id=point.id,
|
||||
vector=point.vector[:existing_size],
|
||||
payload=point.payload,
|
||||
)
|
||||
for point in points
|
||||
]
|
||||
elif existing_size > dimension:
|
||||
# Pad vectors with zeros
|
||||
log.info(
|
||||
f"Padding vectors from {dimension} to {existing_size} dimensions with zeros"
|
||||
)
|
||||
points = [
|
||||
PointStruct(
|
||||
id=point.id,
|
||||
vector=point.vector
|
||||
+ [0] * (existing_size - len(point.vector)),
|
||||
payload=point.payload,
|
||||
)
|
||||
for point in points
|
||||
]
|
||||
# Try operation again with adjusted dimensions
|
||||
if operation_name == "insert":
|
||||
self.client.upload_points(mt_collection, points)
|
||||
return None
|
||||
else: # upsert
|
||||
return self.client.upsert(mt_collection, points)
|
||||
else:
|
||||
# Not a known error we can handle, log and re-raise
|
||||
_, error_msg = self._extract_error_message(e)
|
||||
log.warning(f"Unhandled Qdrant error: {error_msg}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# For non-Qdrant exceptions, re-raise
|
||||
raise
|
||||
|
||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
||||
"""
|
||||
Insert items with tenant ID.
|
||||
"""
|
||||
if not self.client or not items:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Get dimensions from the actual vectors
|
||||
dimension = len(items[0]["vector"]) if items else None
|
||||
|
||||
# Create points with tenant ID
|
||||
points = self._create_points(items, tenant_id)
|
||||
|
||||
# Handle the operation with error retry
|
||||
return self._handle_operation_with_error_retry(
|
||||
"insert", mt_collection, points, dimension
|
||||
)
|
||||
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
"""
|
||||
Upsert items with tenant ID.
|
||||
"""
|
||||
if not self.client or not items:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
# Get dimensions from the actual vectors
|
||||
dimension = len(items[0]["vector"]) if items else None
|
||||
|
||||
# Create points with tenant ID
|
||||
points = self._create_points(items, tenant_id)
|
||||
|
||||
# Handle the operation with error retry
|
||||
return self._handle_operation_with_error_retry(
|
||||
"upsert", mt_collection, points, dimension
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the database by deleting all collections.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
collection_names = self.client.get_collections().collections
|
||||
for collection_name in collection_names:
|
||||
if collection_name.name.startswith(self.collection_prefix):
|
||||
self.client.delete_collection(collection_name=collection_name.name)
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
"""
|
||||
Delete a collection.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
# Map to multi-tenant collection and tenant ID
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
|
||||
tenant_filter = models.FieldCondition(
|
||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
field_conditions = [tenant_filter]
|
||||
|
||||
update_result = self.client.delete(
|
||||
collection_name=mt_collection,
|
||||
points_selector=models.FilterSelector(
|
||||
filter=models.Filter(must=field_conditions)
|
||||
),
|
||||
)
|
||||
|
||||
if self.client.get_collection(mt_collection).points_count == 0:
|
||||
self.client.delete_collection(mt_collection)
|
||||
|
||||
return update_result
|
||||
@@ -0,0 +1,55 @@
|
||||
from open_webui.retrieval.vector.main import VectorDBBase
|
||||
from open_webui.retrieval.vector.type import VectorType
|
||||
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
|
||||
|
||||
|
||||
class Vector:
|
||||
|
||||
@staticmethod
|
||||
def get_vector(vector_type: str) -> VectorDBBase:
|
||||
"""
|
||||
get vector db instance by vector type
|
||||
"""
|
||||
match vector_type:
|
||||
case VectorType.MILVUS:
|
||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||
|
||||
return MilvusClient()
|
||||
case VectorType.QDRANT:
|
||||
if ENABLE_QDRANT_MULTITENANCY_MODE:
|
||||
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
|
||||
QdrantClient,
|
||||
)
|
||||
|
||||
return QdrantClient()
|
||||
else:
|
||||
from open_webui.retrieval.vector.dbs.qdrant import QdrantClient
|
||||
|
||||
return QdrantClient()
|
||||
case VectorType.PINECONE:
|
||||
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
|
||||
|
||||
return PineconeClient()
|
||||
case VectorType.OPENSEARCH:
|
||||
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
|
||||
|
||||
return OpenSearchClient()
|
||||
case VectorType.PGVECTOR:
|
||||
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
|
||||
|
||||
return PgvectorClient()
|
||||
case VectorType.ELASTICSEARCH:
|
||||
from open_webui.retrieval.vector.dbs.elasticsearch import (
|
||||
ElasticsearchClient,
|
||||
)
|
||||
|
||||
return ElasticsearchClient()
|
||||
case VectorType.CHROMA:
|
||||
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
|
||||
|
||||
return ChromaClient()
|
||||
case _:
|
||||
raise ValueError(f"Unsupported vector type: {vector_type}")
|
||||
|
||||
|
||||
VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB)
|
||||
@@ -0,0 +1,11 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class VectorType(StrEnum):
|
||||
MILVUS = "milvus"
|
||||
QDRANT = "qdrant"
|
||||
CHROMA = "chroma"
|
||||
PINECONE = "pinecone"
|
||||
ELASTICSEARCH = "elasticsearch"
|
||||
OPENSEARCH = "opensearch"
|
||||
PGVECTOR = "pgvector"
|
||||
@@ -1,10 +1,20 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
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
|
||||
|
||||
MODELS = Literal[
|
||||
"sonar",
|
||||
"sonar-pro",
|
||||
"sonar-reasoning",
|
||||
"sonar-reasoning-pro",
|
||||
"sonar-deep-research",
|
||||
]
|
||||
SEARCH_CONTEXT_USAGE_LEVELS = Literal["low", "medium", "high"]
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
@@ -14,6 +24,8 @@ def search_perplexity(
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
model: MODELS = "sonar",
|
||||
search_context_usage: SEARCH_CONTEXT_USAGE_LEVELS = "medium",
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
||||
|
||||
@@ -21,6 +33,9 @@ def search_perplexity(
|
||||
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
|
||||
model (str): The Perplexity model to use (sonar, sonar-pro)
|
||||
search_context_usage (str): Search context usage level (low, medium, high)
|
||||
|
||||
"""
|
||||
|
||||
@@ -33,7 +48,7 @@ def search_perplexity(
|
||||
|
||||
# Create payload for the API call
|
||||
payload = {
|
||||
"model": "sonar",
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -43,6 +58,9 @@ def search_perplexity(
|
||||
],
|
||||
"temperature": 0.2, # Lower temperature for more factual responses
|
||||
"stream": False,
|
||||
"web_search_options": {
|
||||
"search_context_usage": search_context_usage,
|
||||
},
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -42,7 +42,9 @@ def search_searchapi(
|
||||
results = get_filtered_results(results, filter_list)
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["link"], title=result["title"], snippet=result["snippet"]
|
||||
link=result["link"],
|
||||
title=result.get("title"),
|
||||
snippet=result.get("snippet"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
@@ -42,7 +42,9 @@ def search_serpapi(
|
||||
results = get_filtered_results(results, filter_list)
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["link"], title=result["title"], snippet=result["snippet"]
|
||||
link=result["link"],
|
||||
title=result.get("title"),
|
||||
snippet=result.get("snippet"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ from langchain_community.document_loaders.firecrawl import FireCrawlLoader
|
||||
from langchain_community.document_loaders.base import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
||||
from open_webui.retrieval.loaders.external import ExternalLoader
|
||||
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.config import (
|
||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||
@@ -39,7 +39,7 @@ from open_webui.config import (
|
||||
EXTERNAL_WEB_LOADER_URL,
|
||||
EXTERNAL_WEB_LOADER_API_KEY,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
@@ -515,7 +515,8 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
kwargs["ssl"] = False
|
||||
|
||||
async with session.get(
|
||||
url, **(self.requests_kwargs | kwargs)
|
||||
url,
|
||||
**(self.requests_kwargs | kwargs),
|
||||
) as response:
|
||||
if self.raise_for_status:
|
||||
response.raise_for_status()
|
||||
@@ -628,7 +629,7 @@ def get_web_loader(
|
||||
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
|
||||
|
||||
if WEB_LOADER_ENGINE.value == "external":
|
||||
WebLoaderClass = ExternalLoader
|
||||
WebLoaderClass = ExternalWebLoader
|
||||
web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
|
||||
web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
from fnmatch import fnmatch
|
||||
import aiohttp
|
||||
import aiofiles
|
||||
import requests
|
||||
@@ -17,6 +20,7 @@ from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
@@ -38,6 +42,7 @@ from open_webui.config import (
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
ENV,
|
||||
SRC_LOG_LEVELS,
|
||||
@@ -49,7 +54,7 @@ from open_webui.env import (
|
||||
router = APIRouter()
|
||||
|
||||
# Constants
|
||||
MAX_FILE_SIZE_MB = 25
|
||||
MAX_FILE_SIZE_MB = 20
|
||||
MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
||||
AZURE_MAX_FILE_SIZE_MB = 200
|
||||
AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
||||
@@ -71,31 +76,47 @@ from pydub import AudioSegment
|
||||
from pydub.utils import mediainfo
|
||||
|
||||
|
||||
def get_audio_format(file_path):
|
||||
"""Check if the given file needs to be converted to a different format."""
|
||||
def is_audio_conversion_required(file_path):
|
||||
"""
|
||||
Check if the given audio file needs conversion to mp3.
|
||||
"""
|
||||
SUPPORTED_FORMATS = {"flac", "m4a", "mp3", "mp4", "mpeg", "wav", "webm"}
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
log.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
info = mediainfo(file_path)
|
||||
if (
|
||||
info.get("codec_name") == "aac"
|
||||
and info.get("codec_type") == "audio"
|
||||
and info.get("codec_tag_string") == "mp4a"
|
||||
):
|
||||
return "mp4"
|
||||
elif info.get("format_name") == "ogg":
|
||||
return "ogg"
|
||||
elif info.get("format_name") == "matroska,webm":
|
||||
return "webm"
|
||||
return None
|
||||
try:
|
||||
info = mediainfo(file_path)
|
||||
codec_name = info.get("codec_name", "").lower()
|
||||
codec_type = info.get("codec_type", "").lower()
|
||||
codec_tag_string = info.get("codec_tag_string", "").lower()
|
||||
|
||||
if codec_name == "aac" and codec_type == "audio" and codec_tag_string == "mp4a":
|
||||
# File is AAC/mp4a audio, recommend mp3 conversion
|
||||
return True
|
||||
|
||||
# If the codec name is in the supported formats
|
||||
if codec_name in SUPPORTED_FORMATS:
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Error getting audio format: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def convert_audio_to_wav(file_path, output_path, conversion_type):
|
||||
"""Convert MP4/OGG audio file to WAV format."""
|
||||
audio = AudioSegment.from_file(file_path, format=conversion_type)
|
||||
audio.export(output_path, format="wav")
|
||||
log.info(f"Converted {file_path} to {output_path}")
|
||||
def convert_audio_to_mp3(file_path):
|
||||
"""Convert audio file to mp3 format."""
|
||||
try:
|
||||
output_path = os.path.splitext(file_path)[0] + ".mp3"
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
audio.export(output_path, format="mp3")
|
||||
log.info(f"Converted {file_path} to {output_path}")
|
||||
return output_path
|
||||
except Exception as e:
|
||||
log.error(f"Error converting audio file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def set_faster_whisper_model(model: str, auto_update: bool = False):
|
||||
@@ -138,6 +159,7 @@ class TTSConfigForm(BaseModel):
|
||||
VOICE: str
|
||||
SPLIT_ON: str
|
||||
AZURE_SPEECH_REGION: str
|
||||
AZURE_SPEECH_BASE_URL: str
|
||||
AZURE_SPEECH_OUTPUT_FORMAT: str
|
||||
|
||||
|
||||
@@ -146,6 +168,7 @@ class STTConfigForm(BaseModel):
|
||||
OPENAI_API_KEY: str
|
||||
ENGINE: str
|
||||
MODEL: str
|
||||
SUPPORTED_CONTENT_TYPES: list[str] = []
|
||||
WHISPER_MODEL: str
|
||||
DEEPGRAM_API_KEY: str
|
||||
AZURE_API_KEY: str
|
||||
@@ -172,6 +195,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
||||
"VOICE": request.app.state.config.TTS_VOICE,
|
||||
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
|
||||
"AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION,
|
||||
"AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
|
||||
"AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
},
|
||||
"stt": {
|
||||
@@ -179,6 +203,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
||||
"OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY,
|
||||
"ENGINE": request.app.state.config.STT_ENGINE,
|
||||
"MODEL": request.app.state.config.STT_MODEL,
|
||||
"SUPPORTED_CONTENT_TYPES": request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
|
||||
"WHISPER_MODEL": request.app.state.config.WHISPER_MODEL,
|
||||
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
|
||||
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||
@@ -202,6 +227,9 @@ async def update_audio_config(
|
||||
request.app.state.config.TTS_VOICE = form_data.tts.VOICE
|
||||
request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON
|
||||
request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION
|
||||
request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = (
|
||||
form_data.tts.AZURE_SPEECH_BASE_URL
|
||||
)
|
||||
request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = (
|
||||
form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT
|
||||
)
|
||||
@@ -210,6 +238,10 @@ async def update_audio_config(
|
||||
request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY
|
||||
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
|
||||
request.app.state.config.STT_MODEL = form_data.stt.MODEL
|
||||
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = (
|
||||
form_data.stt.SUPPORTED_CONTENT_TYPES
|
||||
)
|
||||
|
||||
request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
|
||||
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
|
||||
request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY
|
||||
@@ -224,6 +256,8 @@ async def update_audio_config(
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||
form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
|
||||
)
|
||||
else:
|
||||
request.app.state.faster_whisper_model = None
|
||||
|
||||
return {
|
||||
"tts": {
|
||||
@@ -235,6 +269,7 @@ async def update_audio_config(
|
||||
"VOICE": request.app.state.config.TTS_VOICE,
|
||||
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
|
||||
"AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION,
|
||||
"AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
|
||||
"AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
},
|
||||
"stt": {
|
||||
@@ -242,6 +277,7 @@ async def update_audio_config(
|
||||
"OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY,
|
||||
"ENGINE": request.app.state.config.STT_ENGINE,
|
||||
"MODEL": request.app.state.config.STT_MODEL,
|
||||
"SUPPORTED_CONTENT_TYPES": request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
|
||||
"WHISPER_MODEL": request.app.state.config.WHISPER_MODEL,
|
||||
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
|
||||
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||
@@ -316,6 +352,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
else {}
|
||||
),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -371,6 +408,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
"Content-Type": "application/json",
|
||||
"xi-api-key": request.app.state.config.TTS_API_KEY,
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -406,7 +444,8 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||
|
||||
region = request.app.state.config.TTS_AZURE_SPEECH_REGION
|
||||
region = request.app.state.config.TTS_AZURE_SPEECH_REGION or "eastus"
|
||||
base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
|
||||
language = request.app.state.config.TTS_VOICE
|
||||
locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:1])
|
||||
output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT
|
||||
@@ -420,13 +459,15 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
async with session.post(
|
||||
f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1",
|
||||
(base_url or f"https://{region}.tts.speech.microsoft.com")
|
||||
+ "/cognitiveservices/v1",
|
||||
headers={
|
||||
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY,
|
||||
"Content-Type": "application/ssml+xml",
|
||||
"X-Microsoft-OutputFormat": output_format,
|
||||
},
|
||||
data=data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -495,12 +536,13 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
return FileResponse(file_path)
|
||||
|
||||
|
||||
def transcribe(request: Request, file_path):
|
||||
log.info(f"transcribe: {file_path}")
|
||||
def transcription_handler(request, file_path, metadata):
|
||||
filename = os.path.basename(file_path)
|
||||
file_dir = os.path.dirname(file_path)
|
||||
id = filename.split(".")[0]
|
||||
|
||||
metadata = metadata or {}
|
||||
|
||||
if request.app.state.config.STT_ENGINE == "":
|
||||
if request.app.state.faster_whisper_model is None:
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||
@@ -512,7 +554,7 @@ def transcribe(request: Request, file_path):
|
||||
file_path,
|
||||
beam_size=5,
|
||||
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
|
||||
language=WHISPER_LANGUAGE,
|
||||
language=metadata.get("language") or WHISPER_LANGUAGE,
|
||||
)
|
||||
log.info(
|
||||
"Detected language '%s' with probability %f"
|
||||
@@ -530,16 +572,6 @@ def transcribe(request: Request, file_path):
|
||||
log.debug(data)
|
||||
return data
|
||||
elif request.app.state.config.STT_ENGINE == "openai":
|
||||
audio_format = get_audio_format(file_path)
|
||||
if audio_format:
|
||||
os.rename(file_path, file_path.replace(".wav", f".{audio_format}"))
|
||||
# Convert unsupported audio file to WAV format
|
||||
convert_audio_to_wav(
|
||||
file_path.replace(".wav", f".{audio_format}"),
|
||||
file_path,
|
||||
audio_format,
|
||||
)
|
||||
|
||||
r = None
|
||||
try:
|
||||
r = requests.post(
|
||||
@@ -548,7 +580,14 @@ def transcribe(request: Request, file_path):
|
||||
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
||||
},
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data={"model": request.app.state.config.STT_MODEL},
|
||||
data={
|
||||
"model": request.app.state.config.STT_MODEL,
|
||||
**(
|
||||
{"language": metadata.get("language")}
|
||||
if metadata.get("language")
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
@@ -598,7 +637,7 @@ def transcribe(request: Request, file_path):
|
||||
|
||||
# Make request to Deepgram API
|
||||
r = requests.post(
|
||||
"https://api.deepgram.com/v1/listen",
|
||||
"https://api.deepgram.com/v1/listen?smart_format=true",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
@@ -651,10 +690,10 @@ def transcribe(request: Request, file_path):
|
||||
)
|
||||
|
||||
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
|
||||
region = request.app.state.config.AUDIO_STT_AZURE_REGION
|
||||
region = request.app.state.config.AUDIO_STT_AZURE_REGION or "eastus"
|
||||
locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES
|
||||
base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
|
||||
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS
|
||||
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3
|
||||
|
||||
# IF NO LOCALES, USE DEFAULTS
|
||||
if len(locales) < 2:
|
||||
@@ -681,12 +720,6 @@ def transcribe(request: Request, file_path):
|
||||
detail="Azure API key is required for Azure STT",
|
||||
)
|
||||
|
||||
if not base_url and not region:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Azure region or base url is required for Azure STT",
|
||||
)
|
||||
|
||||
r = None
|
||||
try:
|
||||
# Prepare the request
|
||||
@@ -702,9 +735,8 @@ def transcribe(request: Request, file_path):
|
||||
}
|
||||
|
||||
url = (
|
||||
base_url
|
||||
or f"https://{region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
|
||||
)
|
||||
base_url or f"https://{region}.api.cognitive.microsoft.com"
|
||||
) + "/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
|
||||
|
||||
# Use context manager to ensure file is properly closed
|
||||
with open(file_path, "rb") as audio_file:
|
||||
@@ -763,35 +795,139 @@ def transcribe(request: Request, file_path):
|
||||
)
|
||||
|
||||
|
||||
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None):
|
||||
log.info(f"transcribe: {file_path} {metadata}")
|
||||
|
||||
if is_audio_conversion_required(file_path):
|
||||
file_path = convert_audio_to_mp3(file_path)
|
||||
|
||||
try:
|
||||
file_path = compress_audio(file_path)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
# Always produce a list of chunk paths (could be one entry if small)
|
||||
try:
|
||||
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
|
||||
print(f"Chunk paths: {chunk_paths}")
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
results = []
|
||||
try:
|
||||
with ThreadPoolExecutor() as executor:
|
||||
# Submit tasks for each chunk_path
|
||||
futures = [
|
||||
executor.submit(transcription_handler, request, chunk_path, metadata)
|
||||
for chunk_path in chunk_paths
|
||||
]
|
||||
# Gather results as they complete
|
||||
for future in futures:
|
||||
try:
|
||||
results.append(future.result())
|
||||
except Exception as transcribe_exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error transcribing chunk: {transcribe_exc}",
|
||||
)
|
||||
finally:
|
||||
# Clean up only the temporary chunks, never the original file
|
||||
for chunk_path in chunk_paths:
|
||||
if chunk_path != file_path and os.path.isfile(chunk_path):
|
||||
try:
|
||||
os.remove(chunk_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"text": " ".join([result["text"] for result in results]),
|
||||
}
|
||||
|
||||
|
||||
def compress_audio(file_path):
|
||||
if os.path.getsize(file_path) > MAX_FILE_SIZE:
|
||||
id = os.path.splitext(os.path.basename(file_path))[
|
||||
0
|
||||
] # Handles names with multiple dots
|
||||
file_dir = os.path.dirname(file_path)
|
||||
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
audio = audio.set_frame_rate(16000).set_channels(1) # Compress audio
|
||||
compressed_path = f"{file_dir}/{id}_compressed.opus"
|
||||
audio.export(compressed_path, format="opus", bitrate="32k")
|
||||
log.debug(f"Compressed audio to {compressed_path}")
|
||||
|
||||
if (
|
||||
os.path.getsize(compressed_path) > MAX_FILE_SIZE
|
||||
): # Still larger than MAX_FILE_SIZE after compression
|
||||
raise Exception(ERROR_MESSAGES.FILE_TOO_LARGE(size=f"{MAX_FILE_SIZE_MB}MB"))
|
||||
compressed_path = os.path.join(file_dir, f"{id}_compressed.mp3")
|
||||
audio.export(compressed_path, format="mp3", bitrate="32k")
|
||||
# log.debug(f"Compressed audio to {compressed_path}") # Uncomment if log is defined
|
||||
|
||||
return compressed_path
|
||||
else:
|
||||
return file_path
|
||||
|
||||
|
||||
def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"):
|
||||
"""
|
||||
Splits audio into chunks not exceeding max_bytes.
|
||||
Returns a list of chunk file paths. If audio fits, returns list with original path.
|
||||
"""
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size <= max_bytes:
|
||||
return [file_path] # Nothing to split
|
||||
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
duration_ms = len(audio)
|
||||
orig_size = file_size
|
||||
|
||||
approx_chunk_ms = max(int(duration_ms * (max_bytes / orig_size)) - 1000, 1000)
|
||||
chunks = []
|
||||
start = 0
|
||||
i = 0
|
||||
|
||||
base, _ = os.path.splitext(file_path)
|
||||
|
||||
while start < duration_ms:
|
||||
end = min(start + approx_chunk_ms, duration_ms)
|
||||
chunk = audio[start:end]
|
||||
chunk_path = f"{base}_chunk_{i}.{format}"
|
||||
chunk.export(chunk_path, format=format, bitrate=bitrate)
|
||||
|
||||
# Reduce chunk duration if still too large
|
||||
while os.path.getsize(chunk_path) > max_bytes and (end - start) > 5000:
|
||||
end = start + ((end - start) // 2)
|
||||
chunk = audio[start:end]
|
||||
chunk.export(chunk_path, format=format, bitrate=bitrate)
|
||||
|
||||
if os.path.getsize(chunk_path) > max_bytes:
|
||||
os.remove(chunk_path)
|
||||
raise Exception("Audio chunk cannot be reduced below max file size.")
|
||||
|
||||
chunks.append(chunk_path)
|
||||
start = end
|
||||
i += 1
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
@router.post("/transcriptions")
|
||||
def transcription(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
language: Optional[str] = Form(None),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
log.info(f"file.content_type: {file.content_type}")
|
||||
|
||||
supported_filetypes = ("audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a")
|
||||
supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [
|
||||
"audio/*",
|
||||
"video/webm",
|
||||
]
|
||||
|
||||
if not file.content_type.startswith(supported_filetypes):
|
||||
if not any(
|
||||
fnmatch(file.content_type, content_type)
|
||||
for content_type in supported_content_types
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
|
||||
@@ -812,19 +948,18 @@ def transcription(
|
||||
f.write(contents)
|
||||
|
||||
try:
|
||||
try:
|
||||
file_path = compress_audio(file_path)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
metadata = None
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
if language:
|
||||
metadata = {"language": language}
|
||||
|
||||
result = transcribe(request, file_path, metadata)
|
||||
|
||||
return {
|
||||
**result,
|
||||
"filename": os.path.basename(file_path),
|
||||
}
|
||||
|
||||
data = transcribe(request, file_path)
|
||||
file_path = file_path.split("/")[-1]
|
||||
return {**data, "filename": file_path}
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
@@ -933,7 +1068,10 @@ def get_available_voices(request) -> dict:
|
||||
elif request.app.state.config.TTS_ENGINE == "azure":
|
||||
try:
|
||||
region = request.app.state.config.TTS_AZURE_SPEECH_REGION
|
||||
url = f"https://{region}.tts.speech.microsoft.com/cognitiveservices/voices/list"
|
||||
base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
|
||||
url = (
|
||||
base_url or f"https://{region}.tts.speech.microsoft.com"
|
||||
) + "/cognitiveservices/voices/list"
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY
|
||||
}
|
||||
|
||||
@@ -19,19 +19,21 @@ from open_webui.models.auths import (
|
||||
UserResponse,
|
||||
)
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import (
|
||||
WEBUI_AUTH,
|
||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
||||
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
||||
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
SRC_LOG_LEVELS,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
||||
from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -51,11 +53,10 @@ from open_webui.utils.access_control import get_permissions
|
||||
|
||||
from typing import Optional, List
|
||||
|
||||
from ssl import CERT_REQUIRED, PROTOCOL_TLS
|
||||
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
|
||||
|
||||
if ENABLE_LDAP.value:
|
||||
from ldap3 import Server, Connection, NONE, Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3 import Server, Connection, NONE, Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -82,28 +83,31 @@ async def get_session_user(
|
||||
token = auth_token.credentials
|
||||
data = decode_token(token)
|
||||
|
||||
expires_at = data.get("exp")
|
||||
expires_at = None
|
||||
|
||||
if (expires_at is not None) and int(time.time()) > expires_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
if data:
|
||||
expires_at = data.get("exp")
|
||||
|
||||
if (expires_at is not None) and int(time.time()) > expires_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=(
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
),
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=(
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
),
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
@@ -183,6 +187,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
|
||||
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
|
||||
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
|
||||
LDAP_VALIDATE_CERT = (
|
||||
CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE
|
||||
)
|
||||
LDAP_CIPHERS = (
|
||||
request.app.state.config.LDAP_CIPHERS
|
||||
if request.app.state.config.LDAP_CIPHERS
|
||||
@@ -194,7 +201,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
|
||||
try:
|
||||
tls = Tls(
|
||||
validate=CERT_REQUIRED,
|
||||
validate=LDAP_VALIDATE_CERT,
|
||||
version=PROTOCOL_TLS,
|
||||
ca_certs_file=LDAP_CA_CERT_FILE,
|
||||
ciphers=LDAP_CIPHERS,
|
||||
@@ -221,17 +228,33 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
if not connection_app.bind():
|
||||
raise HTTPException(400, detail="Application account bind failed")
|
||||
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT = (
|
||||
request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT
|
||||
)
|
||||
ENABLE_LDAP_GROUP_CREATION = request.app.state.config.ENABLE_LDAP_GROUP_CREATION
|
||||
LDAP_ATTRIBUTE_FOR_GROUPS = request.app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS
|
||||
|
||||
search_attributes = [
|
||||
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
|
||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
|
||||
"cn",
|
||||
]
|
||||
|
||||
if ENABLE_LDAP_GROUP_MANAGEMENT:
|
||||
search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}")
|
||||
log.info(
|
||||
f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes"
|
||||
)
|
||||
|
||||
log.info(f"LDAP search attributes: {search_attributes}")
|
||||
|
||||
search_success = connection_app.search(
|
||||
search_base=LDAP_SEARCH_BASE,
|
||||
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
||||
attributes=[
|
||||
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
|
||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
|
||||
"cn",
|
||||
],
|
||||
attributes=search_attributes,
|
||||
)
|
||||
|
||||
if not search_success:
|
||||
if not search_success or not connection_app.entries:
|
||||
raise HTTPException(400, detail="User not found in the LDAP server")
|
||||
|
||||
entry = connection_app.entries[0]
|
||||
@@ -251,6 +274,69 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
cn = str(entry["cn"])
|
||||
user_dn = entry.entry_dn
|
||||
|
||||
user_groups = []
|
||||
if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
|
||||
group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
|
||||
log.info(f"LDAP raw group DNs for user {username}: {group_dns}")
|
||||
|
||||
if group_dns:
|
||||
log.info(f"LDAP group_dns original: {group_dns}")
|
||||
log.info(f"LDAP group_dns type: {type(group_dns)}")
|
||||
log.info(f"LDAP group_dns length: {len(group_dns)}")
|
||||
|
||||
if hasattr(group_dns, "value"):
|
||||
group_dns = group_dns.value
|
||||
log.info(f"Extracted .value property: {group_dns}")
|
||||
elif hasattr(group_dns, "__iter__") and not isinstance(
|
||||
group_dns, (str, bytes)
|
||||
):
|
||||
group_dns = list(group_dns)
|
||||
log.info(f"Converted to list: {group_dns}")
|
||||
|
||||
if isinstance(group_dns, list):
|
||||
group_dns = [str(item) for item in group_dns]
|
||||
else:
|
||||
group_dns = [str(group_dns)]
|
||||
|
||||
log.info(
|
||||
f"LDAP group_dns after processing - type: {type(group_dns)}, length: {len(group_dns)}"
|
||||
)
|
||||
|
||||
for group_idx, group_dn in enumerate(group_dns):
|
||||
group_dn = str(group_dn)
|
||||
log.info(f"Processing group DN #{group_idx + 1}: {group_dn}")
|
||||
|
||||
try:
|
||||
group_cn = None
|
||||
|
||||
for item in group_dn.split(","):
|
||||
item = item.strip()
|
||||
if item.upper().startswith("CN="):
|
||||
group_cn = item[3:]
|
||||
break
|
||||
|
||||
if group_cn:
|
||||
user_groups.append(group_cn)
|
||||
|
||||
else:
|
||||
log.warning(
|
||||
f"Could not extract CN from group DN: {group_dn}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"Failed to extract group name from DN {group_dn}: {e}"
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"LDAP groups for user {username}: {user_groups} (total: {len(user_groups)})"
|
||||
)
|
||||
else:
|
||||
log.info(f"No groups found for user {username}")
|
||||
elif ENABLE_LDAP_GROUP_MANAGEMENT:
|
||||
log.warning(
|
||||
f"LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry"
|
||||
)
|
||||
|
||||
if username == form_data.user.lower():
|
||||
connection_user = Connection(
|
||||
server,
|
||||
@@ -293,7 +379,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
500, detail="Internal error occurred during LDAP user creation."
|
||||
)
|
||||
|
||||
user = Auths.authenticate_user_by_trusted_header(email)
|
||||
user = Auths.authenticate_user_by_email(email)
|
||||
|
||||
if user:
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
@@ -326,6 +412,22 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
user.id, request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
if (
|
||||
user.role != "admin"
|
||||
and ENABLE_LDAP_GROUP_MANAGEMENT
|
||||
and user_groups
|
||||
):
|
||||
if ENABLE_LDAP_GROUP_CREATION:
|
||||
Groups.create_groups_by_group_names(user.id, user_groups)
|
||||
|
||||
try:
|
||||
Groups.sync_groups_by_group_names(user.id, user_groups)
|
||||
log.info(
|
||||
f"Successfully synced groups for user {user.id}: {user_groups}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to sync groups for user {user.id}: {e}")
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
@@ -357,21 +459,29 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
|
||||
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
|
||||
|
||||
trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
|
||||
trusted_name = trusted_email
|
||||
email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
|
||||
name = email
|
||||
|
||||
if WEBUI_AUTH_TRUSTED_NAME_HEADER:
|
||||
trusted_name = request.headers.get(
|
||||
WEBUI_AUTH_TRUSTED_NAME_HEADER, trusted_email
|
||||
)
|
||||
if not Users.get_user_by_email(trusted_email.lower()):
|
||||
name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
|
||||
|
||||
if not Users.get_user_by_email(email.lower()):
|
||||
await signup(
|
||||
request,
|
||||
response,
|
||||
SignupForm(
|
||||
email=trusted_email, password=str(uuid.uuid4()), name=trusted_name
|
||||
),
|
||||
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
|
||||
)
|
||||
user = Auths.authenticate_user_by_trusted_header(trusted_email)
|
||||
|
||||
user = Auths.authenticate_user_by_email(email)
|
||||
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin":
|
||||
group_names = request.headers.get(
|
||||
WEBUI_AUTH_TRUSTED_GROUPS_HEADER, ""
|
||||
).split(",")
|
||||
group_names = [name.strip() for name in group_names if name.strip()]
|
||||
|
||||
if group_names:
|
||||
Groups.sync_groups_by_group_names(user.id, group_names)
|
||||
|
||||
elif WEBUI_AUTH == False:
|
||||
admin_email = "admin@localhost"
|
||||
admin_password = "admin"
|
||||
@@ -475,10 +585,6 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
|
||||
)
|
||||
|
||||
if user_count == 0:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
|
||||
if len(form_data.password.encode("utf-8")) > 72:
|
||||
raise HTTPException(
|
||||
@@ -538,6 +644,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
user.id, request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
if user_count == 0:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
@@ -571,9 +681,14 @@ async def signout(request: Request, response: Response):
|
||||
logout_url = openid_data.get("end_session_endpoint")
|
||||
if logout_url:
|
||||
response.delete_cookie("oauth_id_token")
|
||||
return RedirectResponse(
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": True,
|
||||
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}",
|
||||
},
|
||||
headers=response.headers,
|
||||
url=f"{logout_url}?id_token_hint={oauth_id_token}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -588,12 +703,18 @@ async def signout(request: Request, response: Response):
|
||||
)
|
||||
|
||||
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
|
||||
return RedirectResponse(
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": True,
|
||||
"redirect_url": WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
},
|
||||
headers=response.headers,
|
||||
url=WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
)
|
||||
|
||||
return {"status": True}
|
||||
return JSONResponse(
|
||||
status_code=200, content={"status": True}, headers=response.headers
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -693,6 +814,9 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
|
||||
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
|
||||
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
|
||||
}
|
||||
|
||||
|
||||
@@ -710,6 +834,9 @@ class AdminConfig(BaseModel):
|
||||
ENABLE_CHANNELS: bool
|
||||
ENABLE_NOTES: bool
|
||||
ENABLE_USER_WEBHOOKS: bool
|
||||
PENDING_USER_OVERLAY_TITLE: Optional[str] = None
|
||||
PENDING_USER_OVERLAY_CONTENT: Optional[str] = None
|
||||
RESPONSE_WATERMARK: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/admin/config")
|
||||
@@ -747,6 +874,15 @@ async def update_admin_config(
|
||||
|
||||
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
|
||||
|
||||
request.app.state.config.PENDING_USER_OVERLAY_TITLE = (
|
||||
form_data.PENDING_USER_OVERLAY_TITLE
|
||||
)
|
||||
request.app.state.config.PENDING_USER_OVERLAY_CONTENT = (
|
||||
form_data.PENDING_USER_OVERLAY_CONTENT
|
||||
)
|
||||
|
||||
request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK
|
||||
|
||||
return {
|
||||
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
|
||||
"WEBUI_URL": request.app.state.config.WEBUI_URL,
|
||||
@@ -761,6 +897,9 @@ async def update_admin_config(
|
||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
|
||||
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
|
||||
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
|
||||
}
|
||||
|
||||
|
||||
@@ -776,6 +915,7 @@ class LdapServerConfig(BaseModel):
|
||||
search_filters: str = ""
|
||||
use_tls: bool = True
|
||||
certificate_path: Optional[str] = None
|
||||
validate_cert: bool = True
|
||||
ciphers: Optional[str] = "ALL"
|
||||
|
||||
|
||||
@@ -793,6 +933,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
|
||||
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
|
||||
"use_tls": request.app.state.config.LDAP_USE_TLS,
|
||||
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
|
||||
"validate_cert": request.app.state.config.LDAP_VALIDATE_CERT,
|
||||
"ciphers": request.app.state.config.LDAP_CIPHERS,
|
||||
}
|
||||
|
||||
@@ -828,6 +969,7 @@ async def update_ldap_server(
|
||||
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
|
||||
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
|
||||
request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path
|
||||
request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert
|
||||
request.app.state.config.LDAP_CIPHERS = form_data.ciphers
|
||||
|
||||
return {
|
||||
@@ -842,6 +984,7 @@ async def update_ldap_server(
|
||||
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
|
||||
"use_tls": request.app.state.config.LDAP_USE_TLS,
|
||||
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
|
||||
"validate_cert": request.app.state.config.LDAP_VALIDATE_CERT,
|
||||
"ciphers": request.app.state.config.LDAP_CIPHERS,
|
||||
}
|
||||
|
||||
|
||||
@@ -76,17 +76,34 @@ async def delete_all_user_chats(request: Request, user=Depends(get_verified_user
|
||||
@router.get("/list/user/{user_id}", response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_user_id(
|
||||
user_id: str,
|
||||
page: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
user=Depends(get_admin_user),
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
):
|
||||
if not ENABLE_ADMIN_CHAT_ACCESS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if page is None:
|
||||
page = 1
|
||||
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
return Chats.get_chat_list_by_user_id(
|
||||
user_id, include_archived=True, skip=skip, limit=limit
|
||||
user_id, include_archived=True, filter=filter, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@@ -194,10 +211,10 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/pinned", response_model=list[ChatResponse])
|
||||
@router.get("/pinned", response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_pinned_chats(user=Depends(get_verified_user)):
|
||||
return [
|
||||
ChatResponse(**chat.model_dump())
|
||||
ChatTitleIdResponse(**chat.model_dump())
|
||||
for chat in Chats.get_pinned_chats_by_user_id(user.id)
|
||||
]
|
||||
|
||||
@@ -267,9 +284,37 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user)):
|
||||
|
||||
@router.get("/archived", response_model=list[ChatTitleIdResponse])
|
||||
async def get_archived_session_user_chat_list(
|
||||
user=Depends(get_verified_user), skip: int = 0, limit: int = 50
|
||||
page: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return Chats.get_archived_chat_list_by_user_id(user.id, skip, limit)
|
||||
if page is None:
|
||||
page = 1
|
||||
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
chat_list = [
|
||||
ChatTitleIdResponse(**chat.model_dump())
|
||||
for chat in Chats.get_archived_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
]
|
||||
|
||||
return chat_list
|
||||
|
||||
|
||||
############################
|
||||
|
||||
@@ -74,13 +74,17 @@ class FeedbackUserResponse(FeedbackResponse):
|
||||
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
|
||||
async def get_all_feedbacks(user=Depends(get_admin_user)):
|
||||
feedbacks = Feedbacks.get_all_feedbacks()
|
||||
return [
|
||||
FeedbackUserResponse(
|
||||
**feedback.model_dump(),
|
||||
user=UserResponse(**Users.get_user_by_id(feedback.user_id).model_dump()),
|
||||
|
||||
feedback_list = []
|
||||
for feedback in feedbacks:
|
||||
user = Users.get_user_by_id(feedback.user_id)
|
||||
feedback_list.append(
|
||||
FeedbackUserResponse(
|
||||
**feedback.model_dump(),
|
||||
user=UserResponse(**user.model_dump()) if user else None,
|
||||
)
|
||||
)
|
||||
for feedback in feedbacks
|
||||
]
|
||||
return feedback_list
|
||||
|
||||
|
||||
@router.delete("/feedbacks/all")
|
||||
@@ -92,12 +96,7 @@ async def delete_all_feedbacks(user=Depends(get_admin_user)):
|
||||
@router.get("/feedbacks/all/export", response_model=list[FeedbackModel])
|
||||
async def get_all_feedbacks(user=Depends(get_admin_user)):
|
||||
feedbacks = Feedbacks.get_all_feedbacks()
|
||||
return [
|
||||
FeedbackModel(
|
||||
**feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id)
|
||||
)
|
||||
for feedback in feedbacks
|
||||
]
|
||||
return feedbacks
|
||||
|
||||
|
||||
@router.get("/feedbacks/user", response_model=list[FeedbackUserResponse])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -10,6 +11,7 @@ from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
@@ -84,17 +86,44 @@ def has_access_to_file(
|
||||
def upload_file(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(get_verified_user),
|
||||
file_metadata: dict = None,
|
||||
metadata: Optional[dict | str] = Form(None),
|
||||
process: bool = Query(True),
|
||||
internal: bool = False,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
log.info(f"file.content_type: {file.content_type}")
|
||||
|
||||
file_metadata = file_metadata if file_metadata else {}
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
metadata = json.loads(metadata)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Invalid metadata format"),
|
||||
)
|
||||
file_metadata = metadata if metadata else {}
|
||||
|
||||
try:
|
||||
unsanitized_filename = file.filename
|
||||
filename = os.path.basename(unsanitized_filename)
|
||||
|
||||
file_extension = os.path.splitext(filename)[1]
|
||||
# Remove the leading dot from the file extension
|
||||
file_extension = file_extension[1:] if file_extension else ""
|
||||
|
||||
if (not internal) and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
|
||||
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
|
||||
]
|
||||
|
||||
if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(
|
||||
f"File type {file_extension} is not allowed"
|
||||
),
|
||||
)
|
||||
|
||||
# replace filename with uuid
|
||||
id = str(uuid.uuid4())
|
||||
name = filename
|
||||
@@ -125,33 +154,35 @@ def upload_file(
|
||||
)
|
||||
if process:
|
||||
try:
|
||||
|
||||
if file.content_type.startswith(
|
||||
(
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
if file.content_type:
|
||||
stt_supported_content_types = (
|
||||
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES
|
||||
or [
|
||||
"audio/*",
|
||||
"video/webm",
|
||||
]
|
||||
)
|
||||
):
|
||||
file_path = Storage.get_file(file_path)
|
||||
result = transcribe(request, file_path)
|
||||
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=id, content=result.get("text", "")),
|
||||
user=user,
|
||||
if any(
|
||||
fnmatch(file.content_type, content_type)
|
||||
for content_type in stt_supported_content_types
|
||||
):
|
||||
file_path = Storage.get_file(file_path)
|
||||
result = transcribe(request, file_path, file_metadata)
|
||||
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=id, content=result.get("text", "")),
|
||||
user=user,
|
||||
)
|
||||
elif (not file.content_type.startswith(("image/", "video/"))) or (
|
||||
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
|
||||
):
|
||||
process_file(request, ProcessFileForm(file_id=id), user=user)
|
||||
else:
|
||||
log.info(
|
||||
f"File type {file.content_type} is not provided, but trying to process anyway"
|
||||
)
|
||||
elif file.content_type not in [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"video/mp4",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
]:
|
||||
process_file(request, ProcessFileForm(file_id=id), user=user)
|
||||
|
||||
file_item = Files.get_file_by_id(id=id)
|
||||
@@ -177,7 +208,7 @@ def upload_file(
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
import logging
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -9,12 +12,18 @@ from open_webui.models.functions import (
|
||||
FunctionResponse,
|
||||
Functions,
|
||||
)
|
||||
from open_webui.utils.plugin import load_function_module_by_id, replace_imports
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
replace_imports,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
@@ -42,6 +51,97 @@ async def get_functions(user=Depends(get_admin_user)):
|
||||
return Functions.get_functions()
|
||||
|
||||
|
||||
############################
|
||||
# LoadFunctionFromLink
|
||||
############################
|
||||
|
||||
|
||||
class LoadUrlForm(BaseModel):
|
||||
url: HttpUrl
|
||||
|
||||
|
||||
def github_url_to_raw_url(url: str) -> str:
|
||||
# Handle 'tree' (folder) URLs (add main.py at the end)
|
||||
m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url)
|
||||
if m1:
|
||||
org, repo, branch, path = m1.groups()
|
||||
return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py"
|
||||
|
||||
# Handle 'blob' (file) URLs
|
||||
m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url)
|
||||
if m2:
|
||||
org, repo, branch, path = m2.groups()
|
||||
return (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}"
|
||||
)
|
||||
|
||||
# No match; return as-is
|
||||
return url
|
||||
|
||||
|
||||
@router.post("/load/url", response_model=Optional[dict])
|
||||
async def load_function_from_url(
|
||||
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
|
||||
):
|
||||
# NOTE: This is NOT a SSRF vulnerability:
|
||||
# This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use,
|
||||
# and does NOT accept untrusted user input. Access is enforced by authentication.
|
||||
|
||||
url = str(form_data.url)
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Please enter a valid URL")
|
||||
|
||||
url = github_url_to_raw_url(url)
|
||||
url_parts = url.rstrip("/").split("/")
|
||||
|
||||
file_name = url_parts[-1]
|
||||
function_name = (
|
||||
file_name[:-3]
|
||||
if (
|
||||
file_name.endswith(".py")
|
||||
and (not file_name.startswith(("main.py", "index.py", "__init__.py")))
|
||||
)
|
||||
else url_parts[-2] if len(url_parts) > 1 else "function"
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, headers={"Content-Type": "application/json"}
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise HTTPException(
|
||||
status_code=resp.status, detail="Failed to fetch the function"
|
||||
)
|
||||
data = await resp.text()
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No data received from the URL"
|
||||
)
|
||||
return {
|
||||
"name": function_name,
|
||||
"content": data,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error importing function: {e}")
|
||||
|
||||
|
||||
############################
|
||||
# SyncFunctions
|
||||
############################
|
||||
|
||||
|
||||
class SyncFunctionsForm(FunctionForm):
|
||||
functions: list[FunctionModel] = []
|
||||
|
||||
|
||||
@router.post("/sync", response_model=Optional[FunctionModel])
|
||||
async def sync_functions(
|
||||
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
|
||||
):
|
||||
return Functions.sync_functions(user.id, form_data.functions)
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewFunction
|
||||
############################
|
||||
@@ -262,11 +362,9 @@ async def get_function_valves_spec_by_id(
|
||||
):
|
||||
function = Functions.get_function_by_id(id)
|
||||
if function:
|
||||
if id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[id]
|
||||
else:
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(id)
|
||||
request.app.state.FUNCTIONS[id] = function_module
|
||||
function_module, function_type, frontmatter = get_function_module_from_cache(
|
||||
request, id
|
||||
)
|
||||
|
||||
if hasattr(function_module, "Valves"):
|
||||
Valves = function_module.Valves
|
||||
@@ -290,11 +388,9 @@ async def update_function_valves_by_id(
|
||||
):
|
||||
function = Functions.get_function_by_id(id)
|
||||
if function:
|
||||
if id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[id]
|
||||
else:
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(id)
|
||||
request.app.state.FUNCTIONS[id] = function_module
|
||||
function_module, function_type, frontmatter = get_function_module_from_cache(
|
||||
request, id
|
||||
)
|
||||
|
||||
if hasattr(function_module, "Valves"):
|
||||
Valves = function_module.Valves
|
||||
@@ -353,11 +449,9 @@ async def get_function_user_valves_spec_by_id(
|
||||
):
|
||||
function = Functions.get_function_by_id(id)
|
||||
if function:
|
||||
if id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[id]
|
||||
else:
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(id)
|
||||
request.app.state.FUNCTIONS[id] = function_module
|
||||
function_module, function_type, frontmatter = get_function_module_from_cache(
|
||||
request, id
|
||||
)
|
||||
|
||||
if hasattr(function_module, "UserValves"):
|
||||
UserValves = function_module.UserValves
|
||||
@@ -377,11 +471,9 @@ async def update_function_user_valves_by_id(
|
||||
function = Functions.get_function_by_id(id)
|
||||
|
||||
if function:
|
||||
if id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[id]
|
||||
else:
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(id)
|
||||
request.app.state.FUNCTIONS[id] = function_module
|
||||
function_module, function_type, frontmatter = get_function_module_from_cache(
|
||||
request, id
|
||||
)
|
||||
|
||||
if hasattr(function_module, "UserValves"):
|
||||
UserValves = function_module.UserValves
|
||||
|
||||
@@ -333,10 +333,11 @@ def get_models(request: Request, user=Depends(get_verified_user)):
|
||||
return [
|
||||
{"id": "dall-e-2", "name": "DALL·E 2"},
|
||||
{"id": "dall-e-3", "name": "DALL·E 3"},
|
||||
{"id": "gpt-image-1", "name": "GPT-IMAGE 1"},
|
||||
]
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
|
||||
return [
|
||||
{"id": "imagen-3-0-generate-002", "name": "imagen-3.0 generate-002"},
|
||||
{"id": "imagen-3.0-generate-002", "name": "imagen-3.0 generate-002"},
|
||||
]
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
|
||||
# TODO - get models from comfyui
|
||||
@@ -419,7 +420,7 @@ def load_b64_image_data(b64_str):
|
||||
try:
|
||||
if "," in b64_str:
|
||||
header, encoded = b64_str.split(",", 1)
|
||||
mime_type = header.split(";")[0]
|
||||
mime_type = header.split(";")[0].lstrip("data:")
|
||||
img_data = base64.b64decode(encoded)
|
||||
else:
|
||||
mime_type = "image/png"
|
||||
@@ -427,7 +428,7 @@ def load_b64_image_data(b64_str):
|
||||
return img_data, mime_type
|
||||
except Exception as e:
|
||||
log.exception(f"Error loading image data: {e}")
|
||||
return None
|
||||
return None, None
|
||||
|
||||
|
||||
def load_url_image_data(url, headers=None):
|
||||
@@ -450,7 +451,7 @@ def load_url_image_data(url, headers=None):
|
||||
return None
|
||||
|
||||
|
||||
def upload_image(request, image_metadata, image_data, content_type, user):
|
||||
def upload_image(request, image_data, content_type, metadata, user):
|
||||
image_format = mimetypes.guess_extension(content_type)
|
||||
file = UploadFile(
|
||||
file=io.BytesIO(image_data),
|
||||
@@ -459,7 +460,7 @@ def upload_image(request, image_metadata, image_data, content_type, user):
|
||||
"content-type": content_type,
|
||||
},
|
||||
)
|
||||
file_item = upload_file(request, file, user, file_metadata=image_metadata)
|
||||
file_item = upload_file(request, file, metadata=metadata, internal=True, user=user)
|
||||
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
|
||||
return url
|
||||
|
||||
@@ -501,9 +502,9 @@ async def image_generations(
|
||||
else request.app.state.config.IMAGE_SIZE
|
||||
),
|
||||
**(
|
||||
{"response_format": "b64_json"}
|
||||
{}
|
||||
if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
else {}
|
||||
else {"response_format": "b64_json"}
|
||||
),
|
||||
}
|
||||
|
||||
@@ -526,7 +527,7 @@ async def image_generations(
|
||||
else:
|
||||
image_data, content_type = load_b64_image_data(image["b64_json"])
|
||||
|
||||
url = upload_image(request, data, image_data, content_type, user)
|
||||
url = upload_image(request, image_data, content_type, data, user)
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
@@ -560,7 +561,7 @@ async def image_generations(
|
||||
image_data, content_type = load_b64_image_data(
|
||||
image["bytesBase64Encoded"]
|
||||
)
|
||||
url = upload_image(request, data, image_data, content_type, user)
|
||||
url = upload_image(request, image_data, content_type, data, user)
|
||||
images.append({"url": url})
|
||||
|
||||
return images
|
||||
@@ -611,9 +612,9 @@ async def image_generations(
|
||||
image_data, content_type = load_url_image_data(image["url"], headers)
|
||||
url = upload_image(
|
||||
request,
|
||||
form_data.model_dump(exclude_none=True),
|
||||
image_data,
|
||||
content_type,
|
||||
form_data.model_dump(exclude_none=True),
|
||||
user,
|
||||
)
|
||||
images.append({"url": url})
|
||||
@@ -623,7 +624,7 @@ async def image_generations(
|
||||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ""
|
||||
):
|
||||
if form_data.model:
|
||||
set_image_model(form_data.model)
|
||||
set_image_model(request, form_data.model)
|
||||
|
||||
data = {
|
||||
"prompt": form_data.prompt,
|
||||
@@ -664,9 +665,9 @@ async def image_generations(
|
||||
image_data, content_type = load_b64_image_data(image)
|
||||
url = upload_image(
|
||||
request,
|
||||
{**data, "info": res["info"]},
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, "info": res["info"]},
|
||||
user,
|
||||
)
|
||||
images.append({"url": url})
|
||||
|
||||
@@ -10,7 +10,7 @@ from open_webui.models.knowledge import (
|
||||
KnowledgeUserResponse,
|
||||
)
|
||||
from open_webui.models.files import Files, FileModel, FileMetadataResponse
|
||||
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.routers.retrieval import (
|
||||
process_file,
|
||||
ProcessFileForm,
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.models.memories import Memories, MemoryModel
|
||||
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
@@ -124,8 +124,9 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=note.access_control
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and (not has_access(user.id, type="read", access_control=note.access_control))
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -157,8 +158,9 @@ async def update_note_by_id(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="write", access_control=note.access_control
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not has_access(user.id, type="write", access_control=note.access_control)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -195,8 +197,9 @@ async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="write", access_control=note.access_control
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not has_access(user.id, type="write", access_control=note.access_control)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
||||
@@ -9,11 +9,15 @@ import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
import requests
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
from open_webui.env import (
|
||||
@@ -145,8 +149,23 @@ async def send_post_request(
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
if r.ok is False:
|
||||
try:
|
||||
res = await r.json()
|
||||
await cleanup_response(r, session)
|
||||
if "error" in res:
|
||||
raise HTTPException(status_code=r.status, detail=res["error"])
|
||||
except HTTPException as e:
|
||||
raise e # Re-raise HTTPException to be handled by FastAPI
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse error response: {e}")
|
||||
raise HTTPException(
|
||||
status_code=r.status,
|
||||
detail=f"Open WebUI: Server Connection Error",
|
||||
)
|
||||
|
||||
r.raise_for_status() # Raises an error for bad responses (4xx, 5xx)
|
||||
if stream:
|
||||
response_headers = dict(r.headers)
|
||||
|
||||
@@ -166,20 +185,14 @@ async def send_post_request(
|
||||
await cleanup_response(r, session)
|
||||
return res
|
||||
|
||||
except HTTPException as e:
|
||||
raise e # Re-raise HTTPException to be handled by FastAPI
|
||||
except Exception as e:
|
||||
detail = None
|
||||
|
||||
if r is not None:
|
||||
try:
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
detail = f"Ollama: {res.get('error', 'Unknown error')}"
|
||||
except Exception:
|
||||
detail = f"Ollama: {e}"
|
||||
detail = f"Ollama: {e}"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
detail=detail if e else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +313,22 @@ async def update_config(
|
||||
}
|
||||
|
||||
|
||||
def merge_ollama_models_lists(model_lists):
|
||||
merged_models = {}
|
||||
|
||||
for idx, model_list in enumerate(model_lists):
|
||||
if model_list is not None:
|
||||
for model in model_list:
|
||||
id = model["model"]
|
||||
if id not in merged_models:
|
||||
model["urls"] = [idx]
|
||||
merged_models[id] = model
|
||||
else:
|
||||
merged_models[id]["urls"].append(idx)
|
||||
|
||||
return list(merged_models.values())
|
||||
|
||||
|
||||
@cached(ttl=1)
|
||||
async def get_all_models(request: Request, user: UserModel = None):
|
||||
log.info("get_all_models()")
|
||||
@@ -340,6 +369,8 @@ async def get_all_models(request: Request, user: UserModel = None):
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
connection_type = api_config.get("connection_type", "local")
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
tags = api_config.get("tags", [])
|
||||
model_ids = api_config.get("model_ids", [])
|
||||
@@ -352,31 +383,18 @@ async def get_all_models(request: Request, user: UserModel = None):
|
||||
)
|
||||
)
|
||||
|
||||
if prefix_id:
|
||||
for model in response.get("models", []):
|
||||
for model in response.get("models", []):
|
||||
if prefix_id:
|
||||
model["model"] = f"{prefix_id}.{model['model']}"
|
||||
|
||||
if tags:
|
||||
for model in response.get("models", []):
|
||||
if tags:
|
||||
model["tags"] = tags
|
||||
|
||||
def merge_models_lists(model_lists):
|
||||
merged_models = {}
|
||||
|
||||
for idx, model_list in enumerate(model_lists):
|
||||
if model_list is not None:
|
||||
for model in model_list:
|
||||
id = model["model"]
|
||||
if id not in merged_models:
|
||||
model["urls"] = [idx]
|
||||
merged_models[id] = model
|
||||
else:
|
||||
merged_models[id]["urls"].append(idx)
|
||||
|
||||
return list(merged_models.values())
|
||||
if connection_type:
|
||||
model["connection_type"] = connection_type
|
||||
|
||||
models = {
|
||||
"models": merge_models_lists(
|
||||
"models": merge_ollama_models_lists(
|
||||
map(
|
||||
lambda response: response.get("models", []) if response else None,
|
||||
responses,
|
||||
@@ -384,6 +402,22 @@ async def get_all_models(request: Request, user: UserModel = None):
|
||||
)
|
||||
}
|
||||
|
||||
try:
|
||||
loaded_models = await get_ollama_loaded_models(request, user=user)
|
||||
expires_map = {
|
||||
m["name"]: m["expires_at"]
|
||||
for m in loaded_models["models"]
|
||||
if "expires_at" in m
|
||||
}
|
||||
|
||||
for m in models["models"]:
|
||||
if m["name"] in expires_map:
|
||||
# Parse ISO8601 datetime with offset, get unix timestamp as int
|
||||
dt = datetime.fromisoformat(expires_map[m["name"]])
|
||||
m["expires_at"] = int(dt.timestamp())
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to get loaded models: {e}")
|
||||
|
||||
else:
|
||||
models = {"models": []}
|
||||
|
||||
@@ -464,6 +498,68 @@ async def get_ollama_tags(
|
||||
return models
|
||||
|
||||
|
||||
@router.get("/api/ps")
|
||||
async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user)):
|
||||
"""
|
||||
List models that are currently loaded into Ollama memory, and which node they are loaded on.
|
||||
"""
|
||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||
request_tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
|
||||
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
|
||||
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
|
||||
):
|
||||
request_tasks.append(send_get_request(f"{url}/api/ps", user=user))
|
||||
else:
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
enable = api_config.get("enable", True)
|
||||
key = api_config.get("key", None)
|
||||
|
||||
if enable:
|
||||
request_tasks.append(
|
||||
send_get_request(f"{url}/api/ps", key, user=user)
|
||||
)
|
||||
else:
|
||||
request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
|
||||
|
||||
responses = await asyncio.gather(*request_tasks)
|
||||
|
||||
for idx, response in enumerate(responses):
|
||||
if response:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
|
||||
for model in response.get("models", []):
|
||||
if prefix_id:
|
||||
model["model"] = f"{prefix_id}.{model['model']}"
|
||||
|
||||
models = {
|
||||
"models": merge_ollama_models_lists(
|
||||
map(
|
||||
lambda response: response.get("models", []) if response else None,
|
||||
responses,
|
||||
)
|
||||
)
|
||||
}
|
||||
else:
|
||||
models = {"models": []}
|
||||
|
||||
return models
|
||||
|
||||
|
||||
@router.get("/api/version")
|
||||
@router.get("/api/version/{url_idx}")
|
||||
async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
|
||||
@@ -537,36 +633,74 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
|
||||
return {"version": False}
|
||||
|
||||
|
||||
@router.get("/api/ps")
|
||||
async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_user)):
|
||||
"""
|
||||
List models that are currently loaded into Ollama memory, and which node they are loaded on.
|
||||
"""
|
||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||
request_tasks = [
|
||||
send_get_request(
|
||||
f"{url}/api/ps",
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
).get("key", None),
|
||||
user=user,
|
||||
)
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
|
||||
]
|
||||
responses = await asyncio.gather(*request_tasks)
|
||||
|
||||
return dict(zip(request.app.state.config.OLLAMA_BASE_URLS, responses))
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
class ModelNameForm(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.post("/api/unload")
|
||||
async def unload_model(
|
||||
request: Request,
|
||||
form_data: ModelNameForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
model_name = form_data.name
|
||||
if not model_name:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Missing 'name' of model to unload."
|
||||
)
|
||||
|
||||
# Refresh/load models if needed, get mapping from name to URLs
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
# Canonicalize model name (if not supplied with version)
|
||||
if ":" not in model_name:
|
||||
model_name = f"{model_name}:latest"
|
||||
|
||||
if model_name not in models:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)
|
||||
)
|
||||
url_indices = models[model_name]["urls"]
|
||||
|
||||
# Send unload to ALL url_indices
|
||||
results = []
|
||||
errors = []
|
||||
for idx in url_indices:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
)
|
||||
key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
if prefix_id and model_name.startswith(f"{prefix_id}."):
|
||||
model_name = model_name[len(f"{prefix_id}.") :]
|
||||
|
||||
payload = {"model": model_name, "keep_alive": 0, "prompt": ""}
|
||||
|
||||
try:
|
||||
res = await send_post_request(
|
||||
url=f"{url}/api/generate",
|
||||
payload=json.dumps(payload),
|
||||
stream=False,
|
||||
key=key,
|
||||
user=user,
|
||||
)
|
||||
results.append({"url_idx": idx, "success": True, "response": res})
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to unload model on node {idx}: {e}")
|
||||
errors.append({"url_idx": idx, "success": False, "error": str(e)})
|
||||
|
||||
if len(errors) > 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to unload model on {len(errors)} nodes: {errors}",
|
||||
)
|
||||
|
||||
return {"status": True}
|
||||
|
||||
|
||||
@router.post("/api/pull")
|
||||
@router.post("/api/pull/{url_idx}")
|
||||
async def pull_model(
|
||||
@@ -883,6 +1017,10 @@ async def embed(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
@@ -966,6 +1104,10 @@ async def embeddings(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
@@ -1101,6 +1243,9 @@ class GenerateChatCompletionForm(BaseModel):
|
||||
stream: Optional[bool] = True
|
||||
keep_alive: Optional[Union[int, str]] = None
|
||||
tools: Optional[list[dict]] = None
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
)
|
||||
|
||||
|
||||
async def get_ollama_url(request: Request, model: str, url_idx: Optional[int] = None):
|
||||
@@ -1138,7 +1283,9 @@ async def generate_chat_completion(
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
payload = {**form_data.model_dump(exclude_none=True)}
|
||||
if isinstance(form_data, BaseModel):
|
||||
payload = {**form_data.model_dump(exclude_none=True)}
|
||||
|
||||
if "metadata" in payload:
|
||||
del payload["metadata"]
|
||||
|
||||
@@ -1152,13 +1299,10 @@ async def generate_chat_completion(
|
||||
params = model_info.params.model_dump()
|
||||
|
||||
if params:
|
||||
if payload.get("options") is None:
|
||||
payload["options"] = {}
|
||||
system = params.pop("system", None)
|
||||
|
||||
payload["options"] = apply_model_params_to_body_ollama(
|
||||
params, payload["options"]
|
||||
)
|
||||
payload = apply_model_system_prompt_to_body(params, payload, metadata, user)
|
||||
payload = apply_model_params_to_body_ollama(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
@@ -1191,7 +1335,7 @@ async def generate_chat_completion(
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
if prefix_id:
|
||||
payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
|
||||
# payload["keep_alive"] = -1 # keep alive forever
|
||||
|
||||
return await send_post_request(
|
||||
url=f"{url}/api/chat",
|
||||
payload=json.dumps(payload),
|
||||
@@ -1340,8 +1484,10 @@ async def generate_openai_chat_completion(
|
||||
params = model_info.params.model_dump()
|
||||
|
||||
if params:
|
||||
system = params.pop("system", None)
|
||||
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(params, payload, metadata, user)
|
||||
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if user.role == "user":
|
||||
@@ -1577,7 +1723,9 @@ async def upload_model(
|
||||
if url_idx is None:
|
||||
url_idx = 0
|
||||
ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
file_path = os.path.join(UPLOAD_DIR, file.filename)
|
||||
|
||||
filename = os.path.basename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_DIR, filename)
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# --- P1: save file locally ---
|
||||
@@ -1622,13 +1770,13 @@ async def upload_model(
|
||||
os.remove(file_path)
|
||||
|
||||
# Create model in ollama
|
||||
model_name, ext = os.path.splitext(file.filename)
|
||||
model_name, ext = os.path.splitext(filename)
|
||||
log.info(f"Created Model: {model_name}") # DEBUG
|
||||
|
||||
create_payload = {
|
||||
"model": model_name,
|
||||
# Reference the file by its original name => the uploaded blob's digest
|
||||
"files": {file.filename: f"sha256:{file_hash}"},
|
||||
"files": {filename: f"sha256:{file_hash}"},
|
||||
}
|
||||
log.info(f"Model Payload: {create_payload}") # DEBUG
|
||||
|
||||
@@ -1645,7 +1793,7 @@ async def upload_model(
|
||||
done_msg = {
|
||||
"done": True,
|
||||
"blob": f"sha256:{file_hash}",
|
||||
"name": file.filename,
|
||||
"name": filename,
|
||||
"model_created": model_name,
|
||||
}
|
||||
yield f"data: {json.dumps(done_msg)}\n\n"
|
||||
|
||||
@@ -353,21 +353,22 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
connection_type = api_config.get("connection_type", "external")
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
tags = api_config.get("tags", [])
|
||||
|
||||
if prefix_id:
|
||||
for model in (
|
||||
response if isinstance(response, list) else response.get("data", [])
|
||||
):
|
||||
for model in (
|
||||
response if isinstance(response, list) else response.get("data", [])
|
||||
):
|
||||
if prefix_id:
|
||||
model["id"] = f"{prefix_id}.{model['id']}"
|
||||
|
||||
if tags:
|
||||
for model in (
|
||||
response if isinstance(response, list) else response.get("data", [])
|
||||
):
|
||||
if tags:
|
||||
model["tags"] = tags
|
||||
|
||||
if connection_type:
|
||||
model["connection_type"] = connection_type
|
||||
|
||||
log.debug(f"get_all_models:responses() {responses}")
|
||||
return responses
|
||||
|
||||
@@ -415,6 +416,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
"name": model.get("name", model["id"]),
|
||||
"owned_by": "openai",
|
||||
"openai": model,
|
||||
"connection_type": model.get("connection_type", "external"),
|
||||
"urlIdx": idx,
|
||||
}
|
||||
for model in models
|
||||
@@ -461,60 +463,74 @@ async def get_models(
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[url_idx]
|
||||
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
r = None
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
raise Exception(error_detail)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
response_data = await r.json()
|
||||
if api_config.get("azure", False):
|
||||
models = {
|
||||
"data": api_config.get("model_ids", []) or [],
|
||||
"object": "list",
|
||||
}
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
# Check if we're calling OpenAI API based on the URL
|
||||
if "api.openai.com" in url:
|
||||
# Filter models according to the specified conditions
|
||||
response_data["data"] = [
|
||||
model
|
||||
for model in response_data.get("data", [])
|
||||
if not any(
|
||||
name in model["id"]
|
||||
for name in [
|
||||
"babbage",
|
||||
"dall-e",
|
||||
"davinci",
|
||||
"embedding",
|
||||
"tts",
|
||||
"whisper",
|
||||
]
|
||||
)
|
||||
]
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
raise Exception(error_detail)
|
||||
|
||||
models = response_data
|
||||
response_data = await r.json()
|
||||
|
||||
# Check if we're calling OpenAI API based on the URL
|
||||
if "api.openai.com" in url:
|
||||
# Filter models according to the specified conditions
|
||||
response_data["data"] = [
|
||||
model
|
||||
for model in response_data.get("data", [])
|
||||
if not any(
|
||||
name in model["id"]
|
||||
for name in [
|
||||
"babbage",
|
||||
"dall-e",
|
||||
"davinci",
|
||||
"embedding",
|
||||
"tts",
|
||||
"whisper",
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
models = response_data
|
||||
except aiohttp.ClientError as e:
|
||||
# ClientError covers all aiohttp requests issues
|
||||
log.exception(f"Client error: {str(e)}")
|
||||
@@ -536,6 +552,8 @@ class ConnectionVerificationForm(BaseModel):
|
||||
url: str
|
||||
key: str
|
||||
|
||||
config: Optional[dict] = None
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_connection(
|
||||
@@ -544,39 +562,64 @@ async def verify_connection(
|
||||
url = form_data.url
|
||||
key = form_data.key
|
||||
|
||||
api_config = form_data.config or {}
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
raise Exception(error_detail)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
response_data = await r.json()
|
||||
return response_data
|
||||
if api_config.get("azure", False):
|
||||
headers["api-key"] = key
|
||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
|
||||
async with session.get(
|
||||
url=f"{url}/openai/models?api-version={api_version}",
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
raise Exception(error_detail)
|
||||
|
||||
response_data = await r.json()
|
||||
return response_data
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
raise Exception(error_detail)
|
||||
|
||||
response_data = await r.json()
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
# ClientError covers all aiohttp requests issues
|
||||
@@ -590,6 +633,63 @@ async def verify_connection(
|
||||
raise HTTPException(status_code=500, detail=error_detail)
|
||||
|
||||
|
||||
def convert_to_azure_payload(
|
||||
url,
|
||||
payload: dict,
|
||||
):
|
||||
model = payload.get("model", "")
|
||||
|
||||
# Filter allowed parameters based on Azure OpenAI API
|
||||
allowed_params = {
|
||||
"messages",
|
||||
"temperature",
|
||||
"role",
|
||||
"content",
|
||||
"contentPart",
|
||||
"contentPartImage",
|
||||
"enhancements",
|
||||
"dataSources",
|
||||
"n",
|
||||
"stream",
|
||||
"stop",
|
||||
"max_tokens",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"logit_bias",
|
||||
"user",
|
||||
"function_call",
|
||||
"functions",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"top_p",
|
||||
"log_probs",
|
||||
"top_logprobs",
|
||||
"response_format",
|
||||
"seed",
|
||||
"max_completion_tokens",
|
||||
}
|
||||
|
||||
# Special handling for o-series models
|
||||
if model.startswith("o") and model.endswith("-mini"):
|
||||
# Convert max_tokens to max_completion_tokens for o-series models
|
||||
if "max_tokens" in payload:
|
||||
payload["max_completion_tokens"] = payload["max_tokens"]
|
||||
del payload["max_tokens"]
|
||||
|
||||
# Remove temperature if not 1 for o-series models
|
||||
if "temperature" in payload and payload["temperature"] != 1:
|
||||
log.debug(
|
||||
f"Removing temperature parameter for o-series model {model} as only default value (1) is supported"
|
||||
)
|
||||
del payload["temperature"]
|
||||
|
||||
# Filter out unsupported parameters
|
||||
payload = {k: v for k, v in payload.items() if k in allowed_params}
|
||||
|
||||
url = f"{url}/openai/deployments/{model}"
|
||||
return url, payload
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def generate_chat_completion(
|
||||
request: Request,
|
||||
@@ -615,8 +715,12 @@ async def generate_chat_completion(
|
||||
model_id = model_info.base_model_id
|
||||
|
||||
params = model_info.params.model_dump()
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(params, payload, metadata, user)
|
||||
|
||||
if params:
|
||||
system = params.pop("system", None)
|
||||
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
@@ -690,6 +794,38 @@ async def generate_chat_completion(
|
||||
convert_logit_bias_input_to_json(payload["logit_bias"])
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"HTTP-Referer": "https://openwebui.com/",
|
||||
"X-Title": "Open WebUI",
|
||||
}
|
||||
if "openrouter.ai" in url
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
if api_config.get("azure", False):
|
||||
request_url, payload = convert_to_azure_payload(url, payload)
|
||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
headers["api-key"] = key
|
||||
headers["api-version"] = api_version
|
||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||
else:
|
||||
request_url = f"{url}/chat/completions"
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
payload = json.dumps(payload)
|
||||
|
||||
r = None
|
||||
@@ -704,30 +840,9 @@ async def generate_chat_completion(
|
||||
|
||||
r = await session.request(
|
||||
method="POST",
|
||||
url=f"{url}/chat/completions",
|
||||
url=request_url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"HTTP-Referer": "https://openwebui.com/",
|
||||
"X-Title": "Open WebUI",
|
||||
}
|
||||
if "openrouter.ai" in url
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
},
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
@@ -772,27 +887,37 @@ async def generate_chat_completion(
|
||||
await session.close()
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
async def embeddings(request: Request, form_data: dict, user):
|
||||
"""
|
||||
Deprecated: proxy all requests to OpenAI API
|
||||
Calls the embeddings endpoint for OpenAI-compatible providers.
|
||||
|
||||
Args:
|
||||
request (Request): The FastAPI request context.
|
||||
form_data (dict): OpenAI-compatible embeddings payload.
|
||||
user (UserModel): The authenticated user.
|
||||
|
||||
Returns:
|
||||
dict: OpenAI-compatible embeddings response.
|
||||
"""
|
||||
|
||||
body = await request.body()
|
||||
|
||||
idx = 0
|
||||
# Prepare payload/body
|
||||
body = json.dumps(form_data)
|
||||
# Find correct backend url/key based on model
|
||||
await get_all_models(request, user=user)
|
||||
model_id = form_data.get("model")
|
||||
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]
|
||||
|
||||
r = None
|
||||
session = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
r = await session.request(
|
||||
method=request.method,
|
||||
url=f"{url}/{path}",
|
||||
method="POST",
|
||||
url=f"{url}/embeddings",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
@@ -804,10 +929,104 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
response_data = await r.json()
|
||||
return response_data
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
detail = None
|
||||
if r is not None:
|
||||
try:
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
|
||||
except Exception:
|
||||
detail = f"External: {e}"
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not streaming and session:
|
||||
if r:
|
||||
r.close()
|
||||
await session.close()
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
"""
|
||||
Deprecated: proxy all requests to OpenAI API
|
||||
"""
|
||||
|
||||
body = await request.body()
|
||||
|
||||
idx = 0
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
r = None
|
||||
session = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
if api_config.get("azure", False):
|
||||
headers["api-key"] = key
|
||||
headers["api-version"] = (
|
||||
api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
)
|
||||
|
||||
payload = json.loads(body)
|
||||
url, payload = convert_to_azure_payload(url, payload)
|
||||
body = json.dumps(payload).encode()
|
||||
|
||||
request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
request_url = f"{url}/{path}"
|
||||
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
r = await session.request(
|
||||
method=request.method,
|
||||
url=request_url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -18,7 +18,7 @@ from pydantic import BaseModel
|
||||
from starlette.responses import FileResponse
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
@@ -69,7 +69,10 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for filter in sorted_filters:
|
||||
urlIdx = filter.get("urlIdx")
|
||||
if urlIdx is None:
|
||||
|
||||
try:
|
||||
urlIdx = int(urlIdx)
|
||||
except:
|
||||
continue
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
@@ -89,6 +92,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
|
||||
f"{url}/{filter['id']}/filter/inlet",
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
payload = await response.json()
|
||||
response.raise_for_status()
|
||||
@@ -118,7 +122,10 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for filter in sorted_filters:
|
||||
urlIdx = filter.get("urlIdx")
|
||||
if urlIdx is None:
|
||||
|
||||
try:
|
||||
urlIdx = int(urlIdx)
|
||||
except:
|
||||
continue
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
@@ -138,6 +145,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
|
||||
f"{url}/{filter['id']}/filter/outlet",
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
payload = await response.json()
|
||||
response.raise_for_status()
|
||||
@@ -197,8 +205,10 @@ async def upload_pipeline(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}")
|
||||
filename = os.path.basename(file.filename)
|
||||
|
||||
# Check if the uploaded file is a python file
|
||||
if not (file.filename and file.filename.endswith(".py")):
|
||||
if not (filename and filename.endswith(".py")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only Python (.py) files are allowed.",
|
||||
@@ -206,7 +216,7 @@ async def upload_pipeline(
|
||||
|
||||
upload_folder = f"{CACHE_DIR}/pipelines"
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join(upload_folder, file.filename)
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
|
||||
r = None
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,8 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -34,7 +36,7 @@ from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.storage.provider import Storage
|
||||
|
||||
|
||||
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
|
||||
# Document loaders
|
||||
from open_webui.retrieval.loaders.main import Loader
|
||||
@@ -135,7 +137,10 @@ def get_ef(
|
||||
|
||||
|
||||
def get_rf(
|
||||
engine: str = "",
|
||||
reranking_model: Optional[str] = None,
|
||||
external_reranker_url: str = "",
|
||||
external_reranker_api_key: str = "",
|
||||
auto_update: bool = False,
|
||||
):
|
||||
rf = None
|
||||
@@ -153,19 +158,33 @@ def get_rf(
|
||||
log.error(f"ColBERT: {e}")
|
||||
raise Exception(ERROR_MESSAGES.DEFAULT(e))
|
||||
else:
|
||||
import sentence_transformers
|
||||
if engine == "external":
|
||||
try:
|
||||
from open_webui.retrieval.models.external import ExternalReranker
|
||||
|
||||
rf = ExternalReranker(
|
||||
url=external_reranker_url,
|
||||
api_key=external_reranker_api_key,
|
||||
model=reranking_model,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"ExternalReranking: {e}")
|
||||
raise Exception(ERROR_MESSAGES.DEFAULT(e))
|
||||
else:
|
||||
import sentence_transformers
|
||||
|
||||
try:
|
||||
rf = sentence_transformers.CrossEncoder(
|
||||
get_model_path(reranking_model, auto_update),
|
||||
device=DEVICE_TYPE,
|
||||
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
|
||||
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
|
||||
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"CrossEncoder: {e}")
|
||||
raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error"))
|
||||
|
||||
try:
|
||||
rf = sentence_transformers.CrossEncoder(
|
||||
get_model_path(reranking_model, auto_update),
|
||||
device=DEVICE_TYPE,
|
||||
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
|
||||
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
|
||||
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"CrossEncoder: {e}")
|
||||
raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error"))
|
||||
return rf
|
||||
|
||||
|
||||
@@ -188,7 +207,7 @@ class ProcessUrlForm(CollectionNameForm):
|
||||
|
||||
|
||||
class SearchForm(BaseModel):
|
||||
query: str
|
||||
queries: List[str]
|
||||
|
||||
|
||||
@router.get("/")
|
||||
@@ -220,14 +239,11 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
|
||||
"url": request.app.state.config.RAG_OLLAMA_BASE_URL,
|
||||
"key": request.app.state.config.RAG_OLLAMA_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reranking")
|
||||
async def get_reraanking_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
"status": True,
|
||||
"reranking_model": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
"azure_openai_config": {
|
||||
"url": request.app.state.config.RAG_AZURE_OPENAI_BASE_URL,
|
||||
"key": request.app.state.config.RAG_AZURE_OPENAI_API_KEY,
|
||||
"version": request.app.state.config.RAG_AZURE_OPENAI_API_VERSION,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -241,9 +257,16 @@ class OllamaConfigForm(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class AzureOpenAIConfigForm(BaseModel):
|
||||
url: str
|
||||
key: str
|
||||
version: str
|
||||
|
||||
|
||||
class EmbeddingModelUpdateForm(BaseModel):
|
||||
openai_config: Optional[OpenAIConfigForm] = None
|
||||
ollama_config: Optional[OllamaConfigForm] = None
|
||||
azure_openai_config: Optional[AzureOpenAIConfigForm] = None
|
||||
embedding_engine: str
|
||||
embedding_model: str
|
||||
embedding_batch_size: Optional[int] = 1
|
||||
@@ -260,7 +283,11 @@ async def update_embedding_config(
|
||||
request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine
|
||||
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model
|
||||
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]:
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE in [
|
||||
"ollama",
|
||||
"openai",
|
||||
"azure_openai",
|
||||
]:
|
||||
if form_data.openai_config is not None:
|
||||
request.app.state.config.RAG_OPENAI_API_BASE_URL = (
|
||||
form_data.openai_config.url
|
||||
@@ -277,6 +304,17 @@ async def update_embedding_config(
|
||||
form_data.ollama_config.key
|
||||
)
|
||||
|
||||
if form_data.azure_openai_config is not None:
|
||||
request.app.state.config.RAG_AZURE_OPENAI_BASE_URL = (
|
||||
form_data.azure_openai_config.url
|
||||
)
|
||||
request.app.state.config.RAG_AZURE_OPENAI_API_KEY = (
|
||||
form_data.azure_openai_config.key
|
||||
)
|
||||
request.app.state.config.RAG_AZURE_OPENAI_API_VERSION = (
|
||||
form_data.azure_openai_config.version
|
||||
)
|
||||
|
||||
request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = (
|
||||
form_data.embedding_batch_size
|
||||
)
|
||||
@@ -293,14 +331,27 @@ async def update_embedding_config(
|
||||
(
|
||||
request.app.state.config.RAG_OPENAI_API_BASE_URL
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||
else request.app.state.config.RAG_OLLAMA_BASE_URL
|
||||
else (
|
||||
request.app.state.config.RAG_OLLAMA_BASE_URL
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama"
|
||||
else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL
|
||||
)
|
||||
),
|
||||
(
|
||||
request.app.state.config.RAG_OPENAI_API_KEY
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||
else request.app.state.config.RAG_OLLAMA_API_KEY
|
||||
else (
|
||||
request.app.state.config.RAG_OLLAMA_API_KEY
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama"
|
||||
else request.app.state.config.RAG_AZURE_OPENAI_API_KEY
|
||||
)
|
||||
),
|
||||
request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
azure_api_version=(
|
||||
request.app.state.config.RAG_AZURE_OPENAI_API_VERSION
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -316,6 +367,11 @@ async def update_embedding_config(
|
||||
"url": request.app.state.config.RAG_OLLAMA_BASE_URL,
|
||||
"key": request.app.state.config.RAG_OLLAMA_API_KEY,
|
||||
},
|
||||
"azure_openai_config": {
|
||||
"url": request.app.state.config.RAG_AZURE_OPENAI_BASE_URL,
|
||||
"key": request.app.state.config.RAG_AZURE_OPENAI_API_KEY,
|
||||
"version": request.app.state.config.RAG_AZURE_OPENAI_API_VERSION,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
log.exception(f"Problem updating embedding model: {e}")
|
||||
@@ -325,41 +381,6 @@ async def update_embedding_config(
|
||||
)
|
||||
|
||||
|
||||
class RerankingModelUpdateForm(BaseModel):
|
||||
reranking_model: str
|
||||
|
||||
|
||||
@router.post("/reranking/update")
|
||||
async def update_reranking_config(
|
||||
request: Request, form_data: RerankingModelUpdateForm, user=Depends(get_admin_user)
|
||||
):
|
||||
log.info(
|
||||
f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.reranking_model}"
|
||||
)
|
||||
try:
|
||||
request.app.state.config.RAG_RERANKING_MODEL = form_data.reranking_model
|
||||
|
||||
try:
|
||||
request.app.state.rf = get_rf(
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
True,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error loading reranking model: {e}")
|
||||
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"reranking_model": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
}
|
||||
except Exception as e:
|
||||
log.exception(f"Problem updating reranking model: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
@@ -373,16 +394,37 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
"TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER,
|
||||
"RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
"HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||
# Content extraction settings
|
||||
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
|
||||
"DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
"DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
"DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
"DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
"DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
"DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
"EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
|
||||
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
|
||||
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
"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,
|
||||
# Reranking settings
|
||||
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
"RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
"RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
# Chunking settings
|
||||
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
|
||||
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
|
||||
@@ -390,6 +432,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
# File upload settings
|
||||
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
|
||||
"FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT,
|
||||
"FILE_IMAGE_COMPRESSION_WIDTH": request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH,
|
||||
"FILE_IMAGE_COMPRESSION_HEIGHT": request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT,
|
||||
"ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS,
|
||||
# Integration settings
|
||||
"ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
"ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
|
||||
@@ -402,6 +447,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
@@ -426,6 +472,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
|
||||
"EXA_API_KEY": request.app.state.config.EXA_API_KEY,
|
||||
"PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY,
|
||||
"PERPLEXITY_MODEL": request.app.state.config.PERPLEXITY_MODEL,
|
||||
"PERPLEXITY_SEARCH_CONTEXT_USAGE": request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE,
|
||||
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
|
||||
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
|
||||
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
|
||||
@@ -454,6 +502,7 @@ class WebConfig(BaseModel):
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
|
||||
SEARXNG_QUERY_URL: Optional[str] = None
|
||||
YACY_QUERY_URL: Optional[str] = None
|
||||
YACY_USERNAME: Optional[str] = None
|
||||
@@ -478,6 +527,8 @@ class WebConfig(BaseModel):
|
||||
BING_SEARCH_V7_SUBSCRIPTION_KEY: Optional[str] = None
|
||||
EXA_API_KEY: Optional[str] = None
|
||||
PERPLEXITY_API_KEY: Optional[str] = None
|
||||
PERPLEXITY_MODEL: Optional[str] = None
|
||||
PERPLEXITY_SEARCH_CONTEXT_USAGE: Optional[str] = None
|
||||
SOUGOU_API_SID: Optional[str] = None
|
||||
SOUGOU_API_SK: Optional[str] = None
|
||||
WEB_LOADER_ENGINE: Optional[str] = None
|
||||
@@ -507,18 +558,41 @@ class ConfigForm(BaseModel):
|
||||
ENABLE_RAG_HYBRID_SEARCH: Optional[bool] = None
|
||||
TOP_K_RERANKER: Optional[int] = None
|
||||
RELEVANCE_THRESHOLD: Optional[float] = None
|
||||
HYBRID_BM25_WEIGHT: Optional[float] = None
|
||||
|
||||
# Content extraction settings
|
||||
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
|
||||
PDF_EXTRACT_IMAGES: Optional[bool] = None
|
||||
DATALAB_MARKER_API_KEY: Optional[str] = None
|
||||
DATALAB_MARKER_LANGS: Optional[str] = None
|
||||
DATALAB_MARKER_SKIP_CACHE: Optional[bool] = None
|
||||
DATALAB_MARKER_FORCE_OCR: Optional[bool] = None
|
||||
DATALAB_MARKER_PAGINATE: Optional[bool] = None
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR: Optional[bool] = None
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION: Optional[bool] = None
|
||||
DATALAB_MARKER_USE_LLM: Optional[bool] = None
|
||||
DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None
|
||||
EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY: Optional[str] = None
|
||||
|
||||
TIKA_SERVER_URL: Optional[str] = None
|
||||
DOCLING_SERVER_URL: Optional[str] = None
|
||||
DOCLING_OCR_ENGINE: Optional[str] = None
|
||||
DOCLING_OCR_LANG: Optional[str] = None
|
||||
DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
|
||||
DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
|
||||
DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
|
||||
DOCLING_PICTURE_DESCRIPTION_API: Optional[dict] = None
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None
|
||||
DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None
|
||||
MISTRAL_OCR_API_KEY: Optional[str] = None
|
||||
|
||||
# Reranking settings
|
||||
RAG_RERANKING_MODEL: Optional[str] = None
|
||||
RAG_RERANKING_ENGINE: Optional[str] = None
|
||||
RAG_EXTERNAL_RERANKER_URL: Optional[str] = None
|
||||
RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None
|
||||
|
||||
# Chunking settings
|
||||
TEXT_SPLITTER: Optional[str] = None
|
||||
CHUNK_SIZE: Optional[int] = None
|
||||
@@ -527,6 +601,9 @@ class ConfigForm(BaseModel):
|
||||
# File upload settings
|
||||
FILE_MAX_SIZE: Optional[int] = None
|
||||
FILE_MAX_COUNT: Optional[int] = None
|
||||
FILE_IMAGE_COMPRESSION_WIDTH: Optional[int] = None
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT: Optional[int] = None
|
||||
ALLOWED_FILE_EXTENSIONS: Optional[List[str]] = None
|
||||
|
||||
# Integration settings
|
||||
ENABLE_GOOGLE_DRIVE_INTEGRATION: Optional[bool] = None
|
||||
@@ -582,6 +659,11 @@ async def update_rag_config(
|
||||
if form_data.RELEVANCE_THRESHOLD is not None
|
||||
else request.app.state.config.RELEVANCE_THRESHOLD
|
||||
)
|
||||
request.app.state.config.HYBRID_BM25_WEIGHT = (
|
||||
form_data.HYBRID_BM25_WEIGHT
|
||||
if form_data.HYBRID_BM25_WEIGHT is not None
|
||||
else request.app.state.config.HYBRID_BM25_WEIGHT
|
||||
)
|
||||
|
||||
# Content extraction settings
|
||||
request.app.state.config.CONTENT_EXTRACTION_ENGINE = (
|
||||
@@ -594,6 +676,61 @@ async def update_rag_config(
|
||||
if form_data.PDF_EXTRACT_IMAGES is not None
|
||||
else request.app.state.config.PDF_EXTRACT_IMAGES
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_API_KEY = (
|
||||
form_data.DATALAB_MARKER_API_KEY
|
||||
if form_data.DATALAB_MARKER_API_KEY is not None
|
||||
else request.app.state.config.DATALAB_MARKER_API_KEY
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_LANGS = (
|
||||
form_data.DATALAB_MARKER_LANGS
|
||||
if form_data.DATALAB_MARKER_LANGS is not None
|
||||
else request.app.state.config.DATALAB_MARKER_LANGS
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_SKIP_CACHE = (
|
||||
form_data.DATALAB_MARKER_SKIP_CACHE
|
||||
if form_data.DATALAB_MARKER_SKIP_CACHE is not None
|
||||
else request.app.state.config.DATALAB_MARKER_SKIP_CACHE
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_FORCE_OCR = (
|
||||
form_data.DATALAB_MARKER_FORCE_OCR
|
||||
if form_data.DATALAB_MARKER_FORCE_OCR is not None
|
||||
else request.app.state.config.DATALAB_MARKER_FORCE_OCR
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_PAGINATE = (
|
||||
form_data.DATALAB_MARKER_PAGINATE
|
||||
if form_data.DATALAB_MARKER_PAGINATE is not None
|
||||
else request.app.state.config.DATALAB_MARKER_PAGINATE
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = (
|
||||
form_data.DATALAB_MARKER_STRIP_EXISTING_OCR
|
||||
if form_data.DATALAB_MARKER_STRIP_EXISTING_OCR is not None
|
||||
else request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
|
||||
form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
|
||||
if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None
|
||||
else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = (
|
||||
form_data.DATALAB_MARKER_OUTPUT_FORMAT
|
||||
if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None
|
||||
else request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_USE_LLM = (
|
||||
form_data.DATALAB_MARKER_USE_LLM
|
||||
if form_data.DATALAB_MARKER_USE_LLM is not None
|
||||
else request.app.state.config.DATALAB_MARKER_USE_LLM
|
||||
)
|
||||
request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = (
|
||||
form_data.EXTERNAL_DOCUMENT_LOADER_URL
|
||||
if form_data.EXTERNAL_DOCUMENT_LOADER_URL is not None
|
||||
else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL
|
||||
)
|
||||
request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = (
|
||||
form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY
|
||||
if form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY is not None
|
||||
else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY
|
||||
)
|
||||
request.app.state.config.TIKA_SERVER_URL = (
|
||||
form_data.TIKA_SERVER_URL
|
||||
if form_data.TIKA_SERVER_URL is not None
|
||||
@@ -614,6 +751,29 @@ async def update_rag_config(
|
||||
if form_data.DOCLING_OCR_LANG is not None
|
||||
else request.app.state.config.DOCLING_OCR_LANG
|
||||
)
|
||||
|
||||
request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
|
||||
form_data.DOCLING_DO_PICTURE_DESCRIPTION
|
||||
if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
|
||||
else request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION
|
||||
)
|
||||
|
||||
request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = (
|
||||
form_data.DOCLING_PICTURE_DESCRIPTION_MODE
|
||||
if form_data.DOCLING_PICTURE_DESCRIPTION_MODE is not None
|
||||
else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE
|
||||
)
|
||||
request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = (
|
||||
form_data.DOCLING_PICTURE_DESCRIPTION_LOCAL
|
||||
if form_data.DOCLING_PICTURE_DESCRIPTION_LOCAL is not None
|
||||
else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL
|
||||
)
|
||||
request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API = (
|
||||
form_data.DOCLING_PICTURE_DESCRIPTION_API
|
||||
if form_data.DOCLING_PICTURE_DESCRIPTION_API is not None
|
||||
else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API
|
||||
)
|
||||
|
||||
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
|
||||
form_data.DOCUMENT_INTELLIGENCE_ENDPOINT
|
||||
if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None
|
||||
@@ -630,6 +790,49 @@ async def update_rag_config(
|
||||
else request.app.state.config.MISTRAL_OCR_API_KEY
|
||||
)
|
||||
|
||||
# Reranking settings
|
||||
request.app.state.config.RAG_RERANKING_ENGINE = (
|
||||
form_data.RAG_RERANKING_ENGINE
|
||||
if form_data.RAG_RERANKING_ENGINE is not None
|
||||
else request.app.state.config.RAG_RERANKING_ENGINE
|
||||
)
|
||||
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_URL = (
|
||||
form_data.RAG_EXTERNAL_RERANKER_URL
|
||||
if form_data.RAG_EXTERNAL_RERANKER_URL is not None
|
||||
else request.app.state.config.RAG_EXTERNAL_RERANKER_URL
|
||||
)
|
||||
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = (
|
||||
form_data.RAG_EXTERNAL_RERANKER_API_KEY
|
||||
if form_data.RAG_EXTERNAL_RERANKER_API_KEY is not None
|
||||
else request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}"
|
||||
)
|
||||
try:
|
||||
request.app.state.config.RAG_RERANKING_MODEL = form_data.RAG_RERANKING_MODEL
|
||||
|
||||
try:
|
||||
request.app.state.rf = get_rf(
|
||||
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
True,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error loading reranking model: {e}")
|
||||
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
|
||||
except Exception as e:
|
||||
log.exception(f"Problem updating reranking model: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
# Chunking settings
|
||||
request.app.state.config.TEXT_SPLITTER = (
|
||||
form_data.TEXT_SPLITTER
|
||||
@@ -648,15 +851,18 @@ async def update_rag_config(
|
||||
)
|
||||
|
||||
# File upload settings
|
||||
request.app.state.config.FILE_MAX_SIZE = (
|
||||
form_data.FILE_MAX_SIZE
|
||||
if form_data.FILE_MAX_SIZE is not None
|
||||
else request.app.state.config.FILE_MAX_SIZE
|
||||
request.app.state.config.FILE_MAX_SIZE = form_data.FILE_MAX_SIZE
|
||||
request.app.state.config.FILE_MAX_COUNT = form_data.FILE_MAX_COUNT
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
)
|
||||
request.app.state.config.FILE_MAX_COUNT = (
|
||||
form_data.FILE_MAX_COUNT
|
||||
if form_data.FILE_MAX_COUNT is not None
|
||||
else request.app.state.config.FILE_MAX_COUNT
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
)
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
|
||||
form_data.ALLOWED_FILE_EXTENSIONS
|
||||
if form_data.ALLOWED_FILE_EXTENSIONS is not None
|
||||
else request.app.state.config.ALLOWED_FILE_EXTENSIONS
|
||||
)
|
||||
|
||||
# Integration settings
|
||||
@@ -690,6 +896,9 @@ async def update_rag_config(
|
||||
request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
|
||||
form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||
)
|
||||
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
|
||||
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
)
|
||||
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
|
||||
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
|
||||
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
|
||||
@@ -726,6 +935,10 @@ async def update_rag_config(
|
||||
)
|
||||
request.app.state.config.EXA_API_KEY = form_data.web.EXA_API_KEY
|
||||
request.app.state.config.PERPLEXITY_API_KEY = form_data.web.PERPLEXITY_API_KEY
|
||||
request.app.state.config.PERPLEXITY_MODEL = form_data.web.PERPLEXITY_MODEL
|
||||
request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = (
|
||||
form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE
|
||||
)
|
||||
request.app.state.config.SOUGOU_API_SID = form_data.web.SOUGOU_API_SID
|
||||
request.app.state.config.SOUGOU_API_SK = form_data.web.SOUGOU_API_SK
|
||||
|
||||
@@ -776,16 +989,37 @@ async def update_rag_config(
|
||||
"ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
"TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER,
|
||||
"RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
"HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||
# Content extraction settings
|
||||
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
|
||||
"DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
"DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
"DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
"DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
"DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
"DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
"EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
|
||||
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
|
||||
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
"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,
|
||||
# Reranking settings
|
||||
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
"RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
"RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
# Chunking settings
|
||||
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
|
||||
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
|
||||
@@ -793,6 +1027,9 @@ async def update_rag_config(
|
||||
# File upload settings
|
||||
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
|
||||
"FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT,
|
||||
"FILE_IMAGE_COMPRESSION_WIDTH": request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH,
|
||||
"FILE_IMAGE_COMPRESSION_HEIGHT": request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT,
|
||||
"ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS,
|
||||
# Integration settings
|
||||
"ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
"ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
|
||||
@@ -805,6 +1042,7 @@ async def update_rag_config(
|
||||
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
@@ -829,6 +1067,8 @@ async def update_rag_config(
|
||||
"BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
|
||||
"EXA_API_KEY": request.app.state.config.EXA_API_KEY,
|
||||
"PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY,
|
||||
"PERPLEXITY_MODEL": request.app.state.config.PERPLEXITY_MODEL,
|
||||
"PERPLEXITY_SEARCH_CONTEXT_USAGE": request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE,
|
||||
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
|
||||
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
|
||||
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
|
||||
@@ -973,14 +1213,27 @@ def save_docs_to_vector_db(
|
||||
(
|
||||
request.app.state.config.RAG_OPENAI_API_BASE_URL
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||
else request.app.state.config.RAG_OLLAMA_BASE_URL
|
||||
else (
|
||||
request.app.state.config.RAG_OLLAMA_BASE_URL
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama"
|
||||
else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL
|
||||
)
|
||||
),
|
||||
(
|
||||
request.app.state.config.RAG_OPENAI_API_KEY
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||
else request.app.state.config.RAG_OLLAMA_API_KEY
|
||||
else (
|
||||
request.app.state.config.RAG_OLLAMA_API_KEY
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama"
|
||||
else request.app.state.config.RAG_AZURE_OPENAI_API_KEY
|
||||
)
|
||||
),
|
||||
request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
azure_api_version=(
|
||||
request.app.state.config.RAG_AZURE_OPENAI_API_VERSION
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
embeddings = embedding_function(
|
||||
@@ -1094,10 +1347,27 @@ def process_file(
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_LANGS=request.app.state.config.DATALAB_MARKER_LANGS,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_OCR_ENGINE=request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
DOCLING_OCR_LANG=request.app.state.config.DOCLING_OCR_LANG,
|
||||
DOCLING_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,
|
||||
@@ -1514,19 +1784,14 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
elif engine == "exa":
|
||||
return search_exa(
|
||||
request.app.state.config.EXA_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
elif engine == "perplexity":
|
||||
return search_perplexity(
|
||||
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,
|
||||
model=request.app.state.config.PERPLEXITY_MODEL,
|
||||
search_context_usage=request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE,
|
||||
)
|
||||
elif engine == "sougou":
|
||||
if (
|
||||
@@ -1568,16 +1833,34 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
async def process_web_search(
|
||||
request: Request, form_data: SearchForm, user=Depends(get_verified_user)
|
||||
):
|
||||
|
||||
urls = []
|
||||
try:
|
||||
logging.info(
|
||||
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.query}"
|
||||
)
|
||||
web_results = await run_in_threadpool(
|
||||
search_web,
|
||||
request,
|
||||
request.app.state.config.WEB_SEARCH_ENGINE,
|
||||
form_data.query,
|
||||
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
|
||||
)
|
||||
|
||||
search_tasks = [
|
||||
run_in_threadpool(
|
||||
search_web,
|
||||
request,
|
||||
request.app.state.config.WEB_SEARCH_ENGINE,
|
||||
query,
|
||||
)
|
||||
for query in form_data.queries
|
||||
]
|
||||
|
||||
search_results = await asyncio.gather(*search_tasks)
|
||||
|
||||
for result in search_results:
|
||||
if result:
|
||||
for item in result:
|
||||
if item and item.link:
|
||||
urls.append(item.link)
|
||||
|
||||
urls = list(dict.fromkeys(urls))
|
||||
log.debug(f"urls: {urls}")
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
@@ -1586,20 +1869,37 @@ async def process_web_search(
|
||||
detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e),
|
||||
)
|
||||
|
||||
log.debug(f"web_results: {web_results}")
|
||||
|
||||
try:
|
||||
urls = [result.link for result in web_results]
|
||||
loader = get_web_loader(
|
||||
urls,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
|
||||
)
|
||||
docs = await loader.aload()
|
||||
if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER:
|
||||
search_results = [
|
||||
item for result in search_results for item in result if result
|
||||
]
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.snippet,
|
||||
metadata={
|
||||
"source": result.link,
|
||||
"title": result.title,
|
||||
"snippet": result.snippet,
|
||||
"link": result.link,
|
||||
},
|
||||
)
|
||||
for result in search_results
|
||||
if hasattr(result, "snippet")
|
||||
]
|
||||
else:
|
||||
loader = get_web_loader(
|
||||
urls,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
|
||||
)
|
||||
docs = await loader.aload()
|
||||
|
||||
urls = [
|
||||
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
|
||||
] # only keep URLs
|
||||
] # only keep the urls returned by the loader
|
||||
|
||||
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
|
||||
return {
|
||||
@@ -1616,29 +1916,28 @@ async def process_web_search(
|
||||
"loaded_count": len(docs),
|
||||
}
|
||||
else:
|
||||
collection_names = []
|
||||
for doc_idx, doc in enumerate(docs):
|
||||
if doc and doc.page_content:
|
||||
try:
|
||||
collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[
|
||||
:63
|
||||
]
|
||||
# Create a single collection for all documents
|
||||
collection_name = (
|
||||
f"web-search-{calculate_sha256_string('-'.join(form_data.queries))}"[
|
||||
:63
|
||||
]
|
||||
)
|
||||
|
||||
collection_names.append(collection_name)
|
||||
await run_in_threadpool(
|
||||
save_docs_to_vector_db,
|
||||
request,
|
||||
[doc],
|
||||
collection_name,
|
||||
overwrite=True,
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"error saving doc {doc_idx}: {e}")
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
save_docs_to_vector_db,
|
||||
request,
|
||||
docs,
|
||||
collection_name,
|
||||
overwrite=True,
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"error saving docs: {e}")
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_names": collection_names,
|
||||
"collection_names": [collection_name],
|
||||
"filenames": urls,
|
||||
"loaded_count": len(docs),
|
||||
}
|
||||
@@ -1687,6 +1986,11 @@ def query_doc_handler(
|
||||
if form_data.r
|
||||
else request.app.state.config.RELEVANCE_THRESHOLD
|
||||
),
|
||||
hybrid_bm25_weight=(
|
||||
form_data.hybrid_bm25_weight
|
||||
if form_data.hybrid_bm25_weight
|
||||
else request.app.state.config.HYBRID_BM25_WEIGHT
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
@@ -1713,6 +2017,7 @@ class QueryCollectionsForm(BaseModel):
|
||||
k_reranker: Optional[int] = None
|
||||
r: Optional[float] = None
|
||||
hybrid: Optional[bool] = None
|
||||
hybrid_bm25_weight: Optional[float] = None
|
||||
|
||||
|
||||
@router.post("/query/collection")
|
||||
@@ -1738,6 +2043,11 @@ def query_collection_handler(
|
||||
if form_data.r
|
||||
else request.app.state.config.RELEVANCE_THRESHOLD
|
||||
),
|
||||
hybrid_bm25_weight=(
|
||||
form_data.hybrid_bm25_weight
|
||||
if form_data.hybrid_bm25_weight
|
||||
else request.app.state.config.HYBRID_BM25_WEIGHT
|
||||
),
|
||||
)
|
||||
else:
|
||||
return query_collection(
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.task import (
|
||||
title_generation_template,
|
||||
follow_up_generation_template,
|
||||
query_generation_template,
|
||||
image_prompt_generation_template,
|
||||
autocomplete_generation_template,
|
||||
@@ -20,14 +21,12 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.constants import TASKS
|
||||
|
||||
from open_webui.routers.pipelines import process_pipeline_inlet_filter
|
||||
from open_webui.utils.filter import (
|
||||
get_sorted_filter_ids,
|
||||
process_filter_functions,
|
||||
)
|
||||
|
||||
from open_webui.utils.task import get_task_model_id
|
||||
|
||||
from open_webui.config import (
|
||||
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -61,6 +60,8 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
"FOLLOW_UP_GENERATION_PROMPT_TEMPLATE": request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
|
||||
"ENABLE_FOLLOW_UP_GENERATION": request.app.state.config.ENABLE_FOLLOW_UP_GENERATION,
|
||||
"ENABLE_TAGS_GENERATION": request.app.state.config.ENABLE_TAGS_GENERATION,
|
||||
"ENABLE_TITLE_GENERATION": request.app.state.config.ENABLE_TITLE_GENERATION,
|
||||
"ENABLE_SEARCH_QUERY_GENERATION": request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
|
||||
@@ -79,6 +80,8 @@ class TaskConfigForm(BaseModel):
|
||||
ENABLE_AUTOCOMPLETE_GENERATION: bool
|
||||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE: str
|
||||
FOLLOW_UP_GENERATION_PROMPT_TEMPLATE: str
|
||||
ENABLE_FOLLOW_UP_GENERATION: bool
|
||||
ENABLE_TAGS_GENERATION: bool
|
||||
ENABLE_SEARCH_QUERY_GENERATION: bool
|
||||
ENABLE_RETRIEVAL_QUERY_GENERATION: bool
|
||||
@@ -97,6 +100,13 @@ async def update_task_config(
|
||||
form_data.TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
)
|
||||
|
||||
request.app.state.config.ENABLE_FOLLOW_UP_GENERATION = (
|
||||
form_data.ENABLE_FOLLOW_UP_GENERATION
|
||||
)
|
||||
request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = (
|
||||
form_data.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
)
|
||||
|
||||
request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
|
||||
form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
)
|
||||
@@ -136,6 +146,8 @@ async def update_task_config(
|
||||
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
"ENABLE_TAGS_GENERATION": request.app.state.config.ENABLE_TAGS_GENERATION,
|
||||
"ENABLE_FOLLOW_UP_GENERATION": request.app.state.config.ENABLE_FOLLOW_UP_GENERATION,
|
||||
"FOLLOW_UP_GENERATION_PROMPT_TEMPLATE": request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
|
||||
"ENABLE_SEARCH_QUERY_GENERATION": request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
|
||||
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
|
||||
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -186,20 +198,100 @@ async def generate_title(
|
||||
else:
|
||||
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
messages = form_data["messages"]
|
||||
|
||||
# Remove reasoning details from the messages
|
||||
for message in messages:
|
||||
message["content"] = re.sub(
|
||||
r"<details\s+type=\"reasoning\"[^>]*>.*?<\/details>",
|
||||
"",
|
||||
message["content"],
|
||||
flags=re.S,
|
||||
).strip()
|
||||
|
||||
content = title_generation_template(
|
||||
template,
|
||||
messages,
|
||||
form_data["messages"],
|
||||
{
|
||||
"name": user.name,
|
||||
"location": user.info.get("location") if user.info else None,
|
||||
},
|
||||
)
|
||||
|
||||
max_tokens = (
|
||||
models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000)
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"stream": False,
|
||||
**(
|
||||
{"max_tokens": max_tokens}
|
||||
if models[task_model_id].get("owned_by") == "ollama"
|
||||
else {
|
||||
"max_completion_tokens": max_tokens,
|
||||
}
|
||||
),
|
||||
"metadata": {
|
||||
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
|
||||
"task": str(TASKS.TITLE_GENERATION),
|
||||
"task_body": form_data,
|
||||
"chat_id": form_data.get("chat_id", None),
|
||||
},
|
||||
}
|
||||
|
||||
# Process the payload through the pipeline
|
||||
try:
|
||||
payload = await process_pipeline_inlet_filter(request, payload, user, models)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
try:
|
||||
return await generate_chat_completion(request, form_data=payload, user=user)
|
||||
except Exception as e:
|
||||
log.error("Exception occurred", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"detail": "An internal error has occurred."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/follow_up/completions")
|
||||
async def generate_follow_ups(
|
||||
request: Request, form_data: dict, user=Depends(get_verified_user)
|
||||
):
|
||||
|
||||
if not request.app.state.config.ENABLE_FOLLOW_UP_GENERATION:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={"detail": "Follow-up generation is disabled"},
|
||||
)
|
||||
|
||||
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
|
||||
models = {
|
||||
request.state.model["id"]: request.state.model,
|
||||
}
|
||||
else:
|
||||
models = request.app.state.MODELS
|
||||
|
||||
model_id = form_data["model"]
|
||||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"generating chat title using model {task_model_id} for user {user.email} "
|
||||
)
|
||||
|
||||
if request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE != "":
|
||||
template = request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
else:
|
||||
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = follow_up_generation_template(
|
||||
template,
|
||||
form_data["messages"],
|
||||
{
|
||||
"name": user.name,
|
||||
"location": user.info.get("location") if user.info else None,
|
||||
@@ -210,16 +302,9 @@ async def generate_title(
|
||||
"model": task_model_id,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"stream": False,
|
||||
**(
|
||||
{"max_tokens": 1000}
|
||||
if models[task_model_id].get("owned_by") == "ollama"
|
||||
else {
|
||||
"max_completion_tokens": 1000,
|
||||
}
|
||||
),
|
||||
"metadata": {
|
||||
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
|
||||
"task": str(TASKS.TITLE_GENERATION),
|
||||
"task": str(TASKS.FOLLOW_UP_GENERATION),
|
||||
"task_body": form_data,
|
||||
"chat_id": form_data.get("chat_id", None),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import time
|
||||
import re
|
||||
import aiohttp
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
from open_webui.models.tools import (
|
||||
ToolForm,
|
||||
@@ -21,6 +24,7 @@ from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.utils.tools import get_tool_servers_data
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
@@ -51,11 +55,11 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
**{
|
||||
"id": f"server:{server['idx']}",
|
||||
"user_id": f"server:{server['idx']}",
|
||||
"name": server["openapi"]
|
||||
"name": server.get("openapi", {})
|
||||
.get("info", {})
|
||||
.get("title", "Tool Server"),
|
||||
"meta": {
|
||||
"description": server["openapi"]
|
||||
"description": server.get("openapi", {})
|
||||
.get("info", {})
|
||||
.get("description", ""),
|
||||
},
|
||||
@@ -95,6 +99,81 @@ async def get_tool_list(user=Depends(get_verified_user)):
|
||||
return tools
|
||||
|
||||
|
||||
############################
|
||||
# LoadFunctionFromLink
|
||||
############################
|
||||
|
||||
|
||||
class LoadUrlForm(BaseModel):
|
||||
url: HttpUrl
|
||||
|
||||
|
||||
def github_url_to_raw_url(url: str) -> str:
|
||||
# Handle 'tree' (folder) URLs (add main.py at the end)
|
||||
m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url)
|
||||
if m1:
|
||||
org, repo, branch, path = m1.groups()
|
||||
return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py"
|
||||
|
||||
# Handle 'blob' (file) URLs
|
||||
m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url)
|
||||
if m2:
|
||||
org, repo, branch, path = m2.groups()
|
||||
return (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}"
|
||||
)
|
||||
|
||||
# No match; return as-is
|
||||
return url
|
||||
|
||||
|
||||
@router.post("/load/url", response_model=Optional[dict])
|
||||
async def load_tool_from_url(
|
||||
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
|
||||
):
|
||||
# NOTE: This is NOT a SSRF vulnerability:
|
||||
# This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use,
|
||||
# and does NOT accept untrusted user input. Access is enforced by authentication.
|
||||
|
||||
url = str(form_data.url)
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Please enter a valid URL")
|
||||
|
||||
url = github_url_to_raw_url(url)
|
||||
url_parts = url.rstrip("/").split("/")
|
||||
|
||||
file_name = url_parts[-1]
|
||||
tool_name = (
|
||||
file_name[:-3]
|
||||
if (
|
||||
file_name.endswith(".py")
|
||||
and (not file_name.startswith(("main.py", "index.py", "__init__.py")))
|
||||
)
|
||||
else url_parts[-2] if len(url_parts) > 1 else "function"
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, headers={"Content-Type": "application/json"}
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise HTTPException(
|
||||
status_code=resp.status, detail="Failed to fetch the tool"
|
||||
)
|
||||
data = await resp.text()
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No data received from the URL"
|
||||
)
|
||||
return {
|
||||
"name": tool_name,
|
||||
"content": data,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error importing tool: {e}")
|
||||
|
||||
|
||||
############################
|
||||
# ExportTools
|
||||
############################
|
||||
|
||||
@@ -14,14 +14,18 @@ from open_webui.models.users import (
|
||||
)
|
||||
|
||||
|
||||
from open_webui.socket.main import get_active_status_by_user_id
|
||||
from open_webui.socket.main import (
|
||||
get_active_status_by_user_id,
|
||||
get_active_user_ids,
|
||||
get_user_active_status,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
|
||||
from open_webui.utils.access_control import get_permissions
|
||||
from open_webui.utils.access_control import get_permissions, has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -29,12 +33,30 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# GetActiveUsers
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def get_active_users(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""
|
||||
Get a list of active users.
|
||||
"""
|
||||
return {
|
||||
"user_ids": get_active_user_ids(),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# GetUsers
|
||||
############################
|
||||
|
||||
|
||||
PAGE_ITEM_COUNT = 10
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponse)
|
||||
@@ -111,6 +133,7 @@ class SharingPermissions(BaseModel):
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
controls: bool = True
|
||||
system_prompt: bool = True
|
||||
file_upload: bool = True
|
||||
delete: bool = True
|
||||
edit: bool = True
|
||||
@@ -165,22 +188,6 @@ async def update_default_user_permissions(
|
||||
return request.app.state.config.USER_PERMISSIONS
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserRole
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/update/role", response_model=Optional[UserModel])
|
||||
async def update_user_role(form_data: UserRoleUpdateForm, user=Depends(get_admin_user)):
|
||||
if user.id != form_data.id and form_data.id != Users.get_first_user().id:
|
||||
return Users.update_user_role_by_id(form_data.id, form_data.role)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserSettingsBySessionUser
|
||||
############################
|
||||
@@ -205,9 +212,22 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)):
|
||||
|
||||
@router.post("/user/settings/update", response_model=UserSettings)
|
||||
async def update_user_settings_by_session_user(
|
||||
form_data: UserSettings, user=Depends(get_verified_user)
|
||||
request: Request, form_data: UserSettings, user=Depends(get_verified_user)
|
||||
):
|
||||
user = Users.update_user_settings_by_id(user.id, form_data.model_dump())
|
||||
updated_user_settings = form_data.model_dump()
|
||||
if (
|
||||
user.role != "admin"
|
||||
and "toolServers" in updated_user_settings.get("ui").keys()
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"features.direct_tool_servers",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
||||
updated_user_settings["ui"].pop("toolServers", None)
|
||||
|
||||
user = Users.update_user_settings_by_id(user.id, updated_user_settings)
|
||||
if user:
|
||||
return user.settings
|
||||
else:
|
||||
@@ -306,6 +326,18 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserActiveStatusById
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/{user_id}/active", response_model=dict)
|
||||
async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)):
|
||||
return {
|
||||
"active": get_user_active_status(user_id),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserById
|
||||
############################
|
||||
@@ -320,11 +352,22 @@ async def update_user_by_id(
|
||||
# Prevent modification of the primary admin user by other admins
|
||||
try:
|
||||
first_user = Users.get_first_user()
|
||||
if first_user and user_id == first_user.id and session_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
if first_user:
|
||||
if user_id == first_user.id:
|
||||
if session_user.id != user_id:
|
||||
# If the user trying to update is the primary admin, and they are not the primary admin themselves
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
|
||||
if form_data.role != "admin":
|
||||
# If the primary admin is trying to change their own role, prevent it
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error checking primary admin status: {e}")
|
||||
raise HTTPException(
|
||||
@@ -352,6 +395,7 @@ async def update_user_by_id(
|
||||
updated_user = Users.update_user_by_id(
|
||||
user_id,
|
||||
{
|
||||
"role": form_data.role,
|
||||
"name": form_data.name,
|
||||
"email": form_data.email.lower(),
|
||||
"profile_image_url": form_data.profile_image_url,
|
||||
|
||||
@@ -33,7 +33,7 @@ class CodeForm(BaseModel):
|
||||
|
||||
|
||||
@router.post("/code/format")
|
||||
async def format_code(form_data: CodeForm, user=Depends(get_verified_user)):
|
||||
async def format_code(form_data: CodeForm, user=Depends(get_admin_user)):
|
||||
try:
|
||||
formatted_code = black.format_str(form_data.code, mode=black.Mode())
|
||||
return {"code": formatted_code}
|
||||
|
||||
@@ -135,11 +135,6 @@ async def periodic_usage_pool_cleanup():
|
||||
USAGE_POOL[model_id] = connections
|
||||
|
||||
send_usage = True
|
||||
|
||||
if send_usage:
|
||||
# Emit updated usage information after cleaning
|
||||
await sio.emit("usage", {"models": get_models_in_use()})
|
||||
|
||||
await asyncio.sleep(TIMEOUT_DURATION)
|
||||
finally:
|
||||
release_func()
|
||||
@@ -157,20 +152,55 @@ def get_models_in_use():
|
||||
return models_in_use
|
||||
|
||||
|
||||
def get_active_user_ids():
|
||||
"""Get the list of active user IDs."""
|
||||
return list(USER_POOL.keys())
|
||||
|
||||
|
||||
def get_user_active_status(user_id):
|
||||
"""Check if a user is currently active."""
|
||||
return user_id in USER_POOL
|
||||
|
||||
|
||||
def get_user_id_from_session_pool(sid):
|
||||
user = SESSION_POOL.get(sid)
|
||||
if user:
|
||||
return user["id"]
|
||||
return None
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
active_session_ids = sio.manager.get_participants(
|
||||
namespace="/",
|
||||
room=room,
|
||||
)
|
||||
|
||||
active_user_ids = list(
|
||||
set(
|
||||
[SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
|
||||
)
|
||||
)
|
||||
return active_user_ids
|
||||
|
||||
|
||||
def get_active_status_by_user_id(user_id):
|
||||
if user_id in USER_POOL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@sio.on("usage")
|
||||
async def usage(sid, data):
|
||||
model_id = data["model"]
|
||||
# Record the timestamp for the last update
|
||||
current_time = int(time.time())
|
||||
if sid in SESSION_POOL:
|
||||
model_id = data["model"]
|
||||
# Record the timestamp for the last update
|
||||
current_time = int(time.time())
|
||||
|
||||
# Store the new usage data and task
|
||||
USAGE_POOL[model_id] = {
|
||||
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
|
||||
sid: {"updated_at": current_time},
|
||||
}
|
||||
|
||||
# Broadcast the usage data to all clients
|
||||
await sio.emit("usage", {"models": get_models_in_use()})
|
||||
# Store the new usage data and task
|
||||
USAGE_POOL[model_id] = {
|
||||
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
|
||||
sid: {"updated_at": current_time},
|
||||
}
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -189,13 +219,6 @@ async def connect(sid, environ, auth):
|
||||
else:
|
||||
USER_POOL[user.id] = [sid]
|
||||
|
||||
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
|
||||
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
|
||||
await sio.emit("usage", {"models": get_models_in_use()})
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@sio.on("user-join")
|
||||
async def user_join(sid, data):
|
||||
@@ -223,10 +246,6 @@ async def user_join(sid, data):
|
||||
log.debug(f"{channels=}")
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
|
||||
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
|
||||
|
||||
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
|
||||
return {"id": user.id, "name": user.name}
|
||||
|
||||
|
||||
@@ -279,11 +298,6 @@ async def channel_events(sid, data):
|
||||
)
|
||||
|
||||
|
||||
@sio.on("user-list")
|
||||
async def user_list(sid):
|
||||
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
|
||||
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid):
|
||||
if sid in SESSION_POOL:
|
||||
@@ -295,8 +309,6 @@ async def disconnect(sid):
|
||||
|
||||
if len(USER_POOL[user_id]) == 0:
|
||||
del USER_POOL[user_id]
|
||||
|
||||
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
|
||||
else:
|
||||
pass
|
||||
# print(f"Unknown session ID {sid} disconnected")
|
||||
@@ -389,30 +401,3 @@ def get_event_call(request_info):
|
||||
|
||||
|
||||
get_event_caller = get_event_call
|
||||
|
||||
|
||||
def get_user_id_from_session_pool(sid):
|
||||
user = SESSION_POOL.get(sid)
|
||||
if user:
|
||||
return user["id"]
|
||||
return None
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
active_session_ids = sio.manager.get_participants(
|
||||
namespace="/",
|
||||
room=room,
|
||||
)
|
||||
|
||||
active_user_ids = list(
|
||||
set(
|
||||
[SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
|
||||
)
|
||||
)
|
||||
return active_user_ids
|
||||
|
||||
|
||||
def get_active_status_by_user_id(user_id):
|
||||
if user_id in USER_POOL:
|
||||
return True
|
||||
return False
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -269,11 +269,6 @@ tbody + tbody {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Add a rule to reset margin-bottom for <p> not followed by <ul> */
|
||||
.markdown-section p + ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* List item styles */
|
||||
.markdown-section li {
|
||||
padding: 2px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import shutil
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import BinaryIO, Tuple, Dict
|
||||
|
||||
@@ -17,6 +18,7 @@ from open_webui.config import (
|
||||
S3_SECRET_ACCESS_KEY,
|
||||
S3_USE_ACCELERATE_ENDPOINT,
|
||||
S3_ADDRESSING_STYLE,
|
||||
S3_ENABLE_TAGGING,
|
||||
GCS_BUCKET_NAME,
|
||||
GOOGLE_APPLICATION_CREDENTIALS_JSON,
|
||||
AZURE_STORAGE_ENDPOINT,
|
||||
@@ -135,23 +137,37 @@ class S3StorageProvider(StorageProvider):
|
||||
self.bucket_name = S3_BUCKET_NAME
|
||||
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
|
||||
|
||||
@staticmethod
|
||||
def sanitize_tag_value(s: str) -> str:
|
||||
"""Only include S3 allowed characters."""
|
||||
return re.sub(r"[^a-zA-Z0-9 äöüÄÖÜß\+\-=\._:/@]", "", s)
|
||||
|
||||
def upload_file(
|
||||
self, file: BinaryIO, filename: str, tags: Dict[str, str]
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Handles uploading of the file to S3 storage."""
|
||||
_, file_path = LocalStorageProvider.upload_file(file, filename, tags)
|
||||
tagging = {"TagSet": [{"Key": k, "Value": v} for k, v in tags.items()]}
|
||||
s3_key = os.path.join(self.key_prefix, filename)
|
||||
try:
|
||||
s3_key = os.path.join(self.key_prefix, filename)
|
||||
self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
|
||||
self.s3_client.put_object_tagging(
|
||||
Bucket=self.bucket_name,
|
||||
Key=s3_key,
|
||||
Tagging=tagging,
|
||||
)
|
||||
if S3_ENABLE_TAGGING and tags:
|
||||
sanitized_tags = {
|
||||
self.sanitize_tag_value(k): self.sanitize_tag_value(v)
|
||||
for k, v in tags.items()
|
||||
}
|
||||
tagging = {
|
||||
"TagSet": [
|
||||
{"Key": k, "Value": v} for k, v in sanitized_tags.items()
|
||||
]
|
||||
}
|
||||
self.s3_client.put_object_tagging(
|
||||
Bucket=self.bucket_name,
|
||||
Key=s3_key,
|
||||
Tagging=tagging,
|
||||
)
|
||||
return (
|
||||
open(file_path, "rb").read(),
|
||||
"s3://" + self.bucket_name + "/" + s3_key,
|
||||
f"s3://{self.bucket_name}/{s3_key}",
|
||||
)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error uploading file to S3: {e}")
|
||||
|
||||
+98
-13
@@ -2,16 +2,87 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
import json
|
||||
from redis.asyncio import Redis
|
||||
from fastapi import Request
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# A dictionary to keep track of active tasks
|
||||
tasks: Dict[str, asyncio.Task] = {}
|
||||
chat_tasks = {}
|
||||
|
||||
|
||||
def cleanup_task(task_id: str, id=None):
|
||||
REDIS_TASKS_KEY = "open-webui:tasks"
|
||||
REDIS_CHAT_TASKS_KEY = "open-webui:tasks:chat"
|
||||
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
|
||||
|
||||
|
||||
def is_redis(request: Request) -> bool:
|
||||
# Called everywhere a request is available to check Redis
|
||||
return hasattr(request.app.state, "redis") and (request.app.state.redis is not None)
|
||||
|
||||
|
||||
async def redis_task_command_listener(app):
|
||||
redis: Redis = app.state.redis
|
||||
pubsub = redis.pubsub()
|
||||
await pubsub.subscribe(REDIS_PUBSUB_CHANNEL)
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
command = json.loads(message["data"])
|
||||
if command.get("action") == "stop":
|
||||
task_id = command.get("task_id")
|
||||
local_task = tasks.get(task_id)
|
||||
if local_task:
|
||||
local_task.cancel()
|
||||
except Exception as e:
|
||||
print(f"Error handling distributed task command: {e}")
|
||||
|
||||
|
||||
### ------------------------------
|
||||
### REDIS-ENABLED HANDLERS
|
||||
### ------------------------------
|
||||
|
||||
|
||||
async def redis_save_task(redis: Redis, task_id: str, chat_id: Optional[str]):
|
||||
pipe = redis.pipeline()
|
||||
pipe.hset(REDIS_TASKS_KEY, task_id, chat_id or "")
|
||||
if chat_id:
|
||||
pipe.sadd(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
|
||||
await pipe.execute()
|
||||
|
||||
|
||||
async def redis_cleanup_task(redis: Redis, task_id: str, chat_id: Optional[str]):
|
||||
pipe = redis.pipeline()
|
||||
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
||||
if chat_id:
|
||||
pipe.srem(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
|
||||
if (await pipe.scard(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}").execute())[-1] == 0:
|
||||
pipe.delete(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}") # Remove if empty set
|
||||
await pipe.execute()
|
||||
|
||||
|
||||
async def redis_list_tasks(redis: Redis) -> List[str]:
|
||||
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
||||
|
||||
|
||||
async def redis_list_chat_tasks(redis: Redis, chat_id: str) -> List[str]:
|
||||
return list(await redis.smembers(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}"))
|
||||
|
||||
|
||||
async def redis_send_command(redis: Redis, command: dict):
|
||||
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
|
||||
|
||||
|
||||
async def cleanup_task(request, task_id: str, id=None):
|
||||
"""
|
||||
Remove a completed or canceled task from the global `tasks` dictionary.
|
||||
"""
|
||||
if is_redis(request):
|
||||
await redis_cleanup_task(request.app.state.redis, task_id, id)
|
||||
|
||||
tasks.pop(task_id, None) # Remove the task if it exists
|
||||
|
||||
# If an ID is provided, remove the task from the chat_tasks dictionary
|
||||
@@ -21,7 +92,7 @@ def cleanup_task(task_id: str, id=None):
|
||||
chat_tasks.pop(id, None)
|
||||
|
||||
|
||||
def create_task(coroutine, id=None):
|
||||
async def create_task(request, coroutine, id=None):
|
||||
"""
|
||||
Create a new asyncio task and add it to the global task dictionary.
|
||||
"""
|
||||
@@ -29,7 +100,9 @@ def create_task(coroutine, id=None):
|
||||
task = asyncio.create_task(coroutine) # Create the task
|
||||
|
||||
# Add a done callback for cleanup
|
||||
task.add_done_callback(lambda t: cleanup_task(task_id, id))
|
||||
task.add_done_callback(
|
||||
lambda t: asyncio.create_task(cleanup_task(request, task_id, id))
|
||||
)
|
||||
tasks[task_id] = task
|
||||
|
||||
# If an ID is provided, associate the task with that ID
|
||||
@@ -38,34 +111,46 @@ def create_task(coroutine, id=None):
|
||||
else:
|
||||
chat_tasks[id] = [task_id]
|
||||
|
||||
if is_redis(request):
|
||||
await redis_save_task(request.app.state.redis, task_id, id)
|
||||
|
||||
return task_id, task
|
||||
|
||||
|
||||
def get_task(task_id: str):
|
||||
"""
|
||||
Retrieve a task by its task ID.
|
||||
"""
|
||||
return tasks.get(task_id)
|
||||
|
||||
|
||||
def list_tasks():
|
||||
async def list_tasks(request):
|
||||
"""
|
||||
List all currently active task IDs.
|
||||
"""
|
||||
if is_redis(request):
|
||||
return await redis_list_tasks(request.app.state.redis)
|
||||
return list(tasks.keys())
|
||||
|
||||
|
||||
def list_task_ids_by_chat_id(id):
|
||||
async def list_task_ids_by_chat_id(request, id):
|
||||
"""
|
||||
List all tasks associated with a specific ID.
|
||||
"""
|
||||
if is_redis(request):
|
||||
return await redis_list_chat_tasks(request.app.state.redis, id)
|
||||
return chat_tasks.get(id, [])
|
||||
|
||||
|
||||
async def stop_task(task_id: str):
|
||||
async def stop_task(request, task_id: str):
|
||||
"""
|
||||
Cancel a running task and remove it from the global task list.
|
||||
"""
|
||||
if is_redis(request):
|
||||
# PUBSUB: All instances check if they have this task, and stop if so.
|
||||
await redis_send_command(
|
||||
request.app.state.redis,
|
||||
{
|
||||
"action": "stop",
|
||||
"task_id": task_id,
|
||||
},
|
||||
)
|
||||
# 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.get(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task with ID {task_id} not found.")
|
||||
|
||||
@@ -60,7 +60,7 @@ def get_permissions(
|
||||
|
||||
# Combine permissions from all user groups
|
||||
for group in user_groups:
|
||||
group_permissions = group.permissions
|
||||
group_permissions = group.permissions or {}
|
||||
permissions = combine_permissions(permissions, group_permissions)
|
||||
|
||||
# Ensure all fields from default_permissions are present and filled in
|
||||
|
||||
@@ -13,6 +13,8 @@ import pytz
|
||||
from pytz import UTC
|
||||
from typing import Optional, Union, List, Dict
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from open_webui.models.users import Users
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
@@ -21,6 +23,7 @@ from open_webui.env import (
|
||||
TRUSTED_SIGNATURE_KEY,
|
||||
STATIC_DIR,
|
||||
SRC_LOG_LEVELS,
|
||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
||||
)
|
||||
|
||||
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||
@@ -155,6 +158,7 @@ def get_http_authorization_cred(auth_header: Optional[str]):
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
auth_token: HTTPAuthorizationCredentials = Depends(bearer_security),
|
||||
):
|
||||
@@ -194,7 +198,17 @@ def get_current_user(
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
|
||||
)
|
||||
|
||||
return get_current_user_by_api_key(token)
|
||||
user = get_current_user_by_api_key(token)
|
||||
|
||||
# Add user info to current span
|
||||
current_span = trace.get_current_span()
|
||||
if current_span:
|
||||
current_span.set_attribute("client.user.id", user.id)
|
||||
current_span.set_attribute("client.user.email", user.email)
|
||||
current_span.set_attribute("client.user.role", user.role)
|
||||
current_span.set_attribute("client.auth.type", "api_key")
|
||||
|
||||
return user
|
||||
|
||||
# auth by jwt token
|
||||
try:
|
||||
@@ -213,6 +227,29 @@ def get_current_user(
|
||||
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:
|
||||
# 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.",
|
||||
)
|
||||
|
||||
# Add user info to current span
|
||||
current_span = trace.get_current_span()
|
||||
if current_span:
|
||||
current_span.set_attribute("client.user.id", user.id)
|
||||
current_span.set_attribute("client.user.email", user.email)
|
||||
current_span.set_attribute("client.user.role", user.role)
|
||||
current_span.set_attribute("client.auth.type", "jwt")
|
||||
|
||||
# Refresh the user's last active timestamp asynchronously
|
||||
# to prevent blocking the request
|
||||
if background_tasks:
|
||||
@@ -234,6 +271,14 @@ def get_current_user_by_api_key(api_key: str):
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
else:
|
||||
# Add user info to current span
|
||||
current_span = trace.get_current_span()
|
||||
if current_span:
|
||||
current_span.set_attribute("client.user.id", user.id)
|
||||
current_span.set_attribute("client.user.email", user.email)
|
||||
current_span.set_attribute("client.user.role", user.role)
|
||||
current_span.set_attribute("client.auth.type", "api_key")
|
||||
|
||||
Users.update_user_last_active_by_id(user.id)
|
||||
|
||||
return user
|
||||
|
||||
@@ -40,7 +40,10 @@ from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.models import get_all_models, check_model_access
|
||||
from open_webui.utils.payload import convert_payload_openai_to_ollama
|
||||
from open_webui.utils.response import (
|
||||
@@ -309,6 +312,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
metadata = {
|
||||
"chat_id": data["chat_id"],
|
||||
"message_id": data["id"],
|
||||
"filter_ids": data.get("filter_ids", []),
|
||||
"session_id": data["session_id"],
|
||||
"user_id": user.id,
|
||||
}
|
||||
@@ -316,12 +320,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
extra_params = {
|
||||
"__event_emitter__": get_event_emitter(metadata),
|
||||
"__event_call__": get_event_call(metadata),
|
||||
"__user__": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
},
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__request__": request,
|
||||
"__model__": model,
|
||||
@@ -330,7 +329,9 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
try:
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(model)
|
||||
for filter_id in get_sorted_filter_ids(
|
||||
request, model, metadata.get("filter_ids", [])
|
||||
)
|
||||
]
|
||||
|
||||
result, _ = await process_filter_functions(
|
||||
@@ -389,11 +390,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
||||
}
|
||||
)
|
||||
|
||||
if action_id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[action_id]
|
||||
else:
|
||||
function_module, _, _ = load_function_module_by_id(action_id)
|
||||
request.app.state.FUNCTIONS[action_id] = function_module
|
||||
function_module, _, _ = get_function_module_from_cache(request, action_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
valves = Functions.get_function_valves_by_id(action_id)
|
||||
@@ -422,12 +419,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
||||
params[key] = value
|
||||
|
||||
if "__user__" in sig.parameters:
|
||||
__user__ = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
}
|
||||
__user__ = (user.model_dump() if isinstance(user, UserModel) else {},)
|
||||
|
||||
try:
|
||||
if hasattr(function_module, "UserValves"):
|
||||
|
||||
@@ -44,12 +44,14 @@ class JupyterCodeExecuter:
|
||||
:param password: Jupyter password (optional)
|
||||
:param timeout: WebSocket timeout in seconds (default: 60s)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.base_url = base_url
|
||||
self.code = code
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.kernel_id = ""
|
||||
if self.base_url[-1] != "/":
|
||||
self.base_url += "/"
|
||||
self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url)
|
||||
self.params = {}
|
||||
self.result = ResultModel()
|
||||
@@ -61,7 +63,7 @@ class JupyterCodeExecuter:
|
||||
if self.kernel_id:
|
||||
try:
|
||||
async with self.session.delete(
|
||||
f"/api/kernels/{self.kernel_id}", params=self.params
|
||||
f"api/kernels/{self.kernel_id}", params=self.params
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
except Exception as err:
|
||||
@@ -81,7 +83,7 @@ class JupyterCodeExecuter:
|
||||
async def sign_in(self) -> None:
|
||||
# password authentication
|
||||
if self.password and not self.token:
|
||||
async with self.session.get("/login") as response:
|
||||
async with self.session.get("login") as response:
|
||||
response.raise_for_status()
|
||||
xsrf_token = response.cookies["_xsrf"].value
|
||||
if not xsrf_token:
|
||||
@@ -89,7 +91,7 @@ class JupyterCodeExecuter:
|
||||
self.session.cookie_jar.update_cookies(response.cookies)
|
||||
self.session.headers.update({"X-XSRFToken": xsrf_token})
|
||||
async with self.session.post(
|
||||
"/login",
|
||||
"login",
|
||||
data={"_xsrf": xsrf_token, "password": self.password},
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
@@ -101,17 +103,15 @@ class JupyterCodeExecuter:
|
||||
self.params.update({"token": self.token})
|
||||
|
||||
async def init_kernel(self) -> None:
|
||||
async with self.session.post(
|
||||
url="/api/kernels", params=self.params
|
||||
) as response:
|
||||
async with self.session.post(url="api/kernels", params=self.params) as response:
|
||||
response.raise_for_status()
|
||||
kernel_data = await response.json()
|
||||
self.kernel_id = kernel_data["id"]
|
||||
|
||||
def init_ws(self) -> (str, dict):
|
||||
ws_base = self.base_url.replace("http", "ws")
|
||||
ws_base = self.base_url.replace("http", "ws", 1)
|
||||
ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()])
|
||||
websocket_url = f"{ws_base}/api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}"
|
||||
websocket_url = f"{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}"
|
||||
ws_headers = {}
|
||||
if self.password and not self.token:
|
||||
ws_headers = {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import random
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from fastapi import Request
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.utils.models import check_model_access
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
|
||||
|
||||
from open_webui.routers.openai import embeddings as openai_embeddings
|
||||
from open_webui.routers.ollama import (
|
||||
embeddings as ollama_embeddings,
|
||||
GenerateEmbeddingsForm,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.utils.payload import convert_embedding_payload_openai_to_ollama
|
||||
from open_webui.utils.response import convert_embedding_response_ollama_to_openai
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
async def generate_embeddings(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
user: UserModel,
|
||||
bypass_filter: bool = False,
|
||||
):
|
||||
"""
|
||||
Dispatch and handle embeddings generation based on the model type (OpenAI, Ollama).
|
||||
|
||||
Args:
|
||||
request (Request): The FastAPI request context.
|
||||
form_data (dict): The input data sent to the endpoint.
|
||||
user (UserModel): The authenticated user.
|
||||
bypass_filter (bool): If True, disables access filtering (default False).
|
||||
|
||||
Returns:
|
||||
dict: The embeddings response, following OpenAI API compatibility.
|
||||
"""
|
||||
if BYPASS_MODEL_ACCESS_CONTROL:
|
||||
bypass_filter = True
|
||||
|
||||
# Attach extra metadata from request.state if present
|
||||
if hasattr(request.state, "metadata"):
|
||||
if "metadata" not in form_data:
|
||||
form_data["metadata"] = request.state.metadata
|
||||
else:
|
||||
form_data["metadata"] = {
|
||||
**form_data["metadata"],
|
||||
**request.state.metadata,
|
||||
}
|
||||
|
||||
# If "direct" flag present, use only that model
|
||||
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
|
||||
models = {
|
||||
request.state.model["id"]: request.state.model,
|
||||
}
|
||||
else:
|
||||
models = request.app.state.MODELS
|
||||
|
||||
model_id = form_data.get("model")
|
||||
if model_id not in models:
|
||||
raise Exception("Model not found")
|
||||
model = models[model_id]
|
||||
|
||||
# Access filtering
|
||||
if not getattr(request.state, "direct", False):
|
||||
if not bypass_filter and user.role == "user":
|
||||
check_model_access(user, model)
|
||||
|
||||
# Ollama backend
|
||||
if model.get("owned_by") == "ollama":
|
||||
ollama_payload = convert_embedding_payload_openai_to_ollama(form_data)
|
||||
response = await ollama_embeddings(
|
||||
request=request,
|
||||
form_data=GenerateEmbeddingsForm(**ollama_payload),
|
||||
user=user,
|
||||
)
|
||||
return convert_embedding_response_ollama_to_openai(response)
|
||||
|
||||
# Default: OpenAI or compatible backend
|
||||
return await openai_embeddings(
|
||||
request=request,
|
||||
form_data=form_data,
|
||||
user=user,
|
||||
)
|
||||
@@ -1,7 +1,10 @@
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
@@ -9,7 +12,17 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
def get_sorted_filter_ids(model: dict):
|
||||
def get_function_module(request, function_id, load_from_db=True):
|
||||
"""
|
||||
Get the function module by its ID.
|
||||
"""
|
||||
function_module, _, _ = get_function_module_from_cache(
|
||||
request, function_id, load_from_db
|
||||
)
|
||||
return function_module
|
||||
|
||||
|
||||
def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
|
||||
def get_priority(function_id):
|
||||
function = Functions.get_function_by_id(function_id)
|
||||
if function is not None:
|
||||
@@ -21,14 +34,26 @@ def get_sorted_filter_ids(model: dict):
|
||||
if "info" in model and "meta" in model["info"]:
|
||||
filter_ids.extend(model["info"]["meta"].get("filterIds", []))
|
||||
filter_ids = list(set(filter_ids))
|
||||
|
||||
enabled_filter_ids = [
|
||||
active_filter_ids = [
|
||||
function.id
|
||||
for function in Functions.get_functions_by_type("filter", active_only=True)
|
||||
]
|
||||
|
||||
filter_ids = [fid for fid in filter_ids if fid in enabled_filter_ids]
|
||||
def get_active_status(filter_id):
|
||||
function_module = get_function_module(request, filter_id)
|
||||
|
||||
if getattr(function_module, "toggle", None):
|
||||
return filter_id in (enabled_filter_ids or [])
|
||||
|
||||
return True
|
||||
|
||||
active_filter_ids = [
|
||||
filter_id for filter_id in active_filter_ids if get_active_status(filter_id)
|
||||
]
|
||||
|
||||
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
|
||||
filter_ids.sort(key=get_priority)
|
||||
|
||||
return filter_ids
|
||||
|
||||
|
||||
@@ -43,12 +68,9 @@ async def process_filter_functions(
|
||||
if not filter:
|
||||
continue
|
||||
|
||||
if filter_id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[filter_id]
|
||||
else:
|
||||
function_module, _, _ = load_function_module_by_id(filter_id)
|
||||
request.app.state.FUNCTIONS[filter_id] = function_module
|
||||
|
||||
function_module = get_function_module(
|
||||
request, filter_id, load_from_db=(filter_type != "stream")
|
||||
)
|
||||
# Prepare handler function
|
||||
handler = getattr(function_module, filter_type, None)
|
||||
if not handler:
|
||||
|
||||
@@ -32,15 +32,22 @@ from open_webui.socket.main import (
|
||||
from open_webui.routers.tasks import (
|
||||
generate_queries,
|
||||
generate_title,
|
||||
generate_follow_ups,
|
||||
generate_image_prompt,
|
||||
generate_chat_tags,
|
||||
)
|
||||
from open_webui.routers.retrieval import process_web_search, SearchForm
|
||||
from open_webui.routers.images import image_generations, GenerateImageForm
|
||||
from open_webui.routers.images import (
|
||||
load_b64_image_data,
|
||||
image_generations,
|
||||
GenerateImageForm,
|
||||
upload_image,
|
||||
)
|
||||
from open_webui.routers.pipelines import (
|
||||
process_pipeline_inlet_filter,
|
||||
process_pipeline_outlet_filter,
|
||||
)
|
||||
from open_webui.routers.memories import query_memory, QueryMemoryForm
|
||||
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
@@ -251,7 +258,12 @@ async def chat_completion_tools_handler(
|
||||
"name": (f"TOOL:{tool_name}"),
|
||||
},
|
||||
"document": [tool_result],
|
||||
"metadata": [{"source": (f"TOOL:{tool_name}")}],
|
||||
"metadata": [
|
||||
{
|
||||
"source": (f"TOOL:{tool_name}"),
|
||||
"parameters": tool_function_params,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -290,6 +302,45 @@ async def chat_completion_tools_handler(
|
||||
return body, {"sources": sources}
|
||||
|
||||
|
||||
async def chat_memory_handler(
|
||||
request: Request, form_data: dict, extra_params: dict, user
|
||||
):
|
||||
try:
|
||||
results = await query_memory(
|
||||
request,
|
||||
QueryMemoryForm(
|
||||
**{
|
||||
"content": get_last_user_message(form_data["messages"]) or "",
|
||||
"k": 3,
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
results = None
|
||||
|
||||
user_context = ""
|
||||
if results and hasattr(results, "documents"):
|
||||
if results.documents and len(results.documents) > 0:
|
||||
for doc_idx, doc in enumerate(results.documents[0]):
|
||||
created_at_date = "Unknown Date"
|
||||
|
||||
if results.metadatas[0][doc_idx].get("created_at"):
|
||||
created_at_timestamp = results.metadatas[0][doc_idx]["created_at"]
|
||||
created_at_date = time.strftime(
|
||||
"%Y-%m-%d", time.localtime(created_at_timestamp)
|
||||
)
|
||||
|
||||
user_context += f"{doc_idx + 1}. [{created_at_date}] {doc}\n"
|
||||
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
f"User Context:\n{user_context}\n", form_data["messages"], append=True
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
async def chat_web_search_handler(
|
||||
request: Request, form_data: dict, extra_params: dict, user
|
||||
):
|
||||
@@ -340,6 +391,11 @@ async def chat_web_search_handler(
|
||||
log.exception(e)
|
||||
queries = [user_message]
|
||||
|
||||
# Check if generated queries are empty
|
||||
if len(queries) == 1 and queries[0].strip() == "":
|
||||
queries = [user_message]
|
||||
|
||||
# Check if queries are not found
|
||||
if len(queries) == 0:
|
||||
await event_emitter(
|
||||
{
|
||||
@@ -353,8 +409,6 @@ async def chat_web_search_handler(
|
||||
)
|
||||
return form_data
|
||||
|
||||
all_results = []
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "status",
|
||||
@@ -366,106 +420,77 @@ async def chat_web_search_handler(
|
||||
}
|
||||
)
|
||||
|
||||
gathered_results = await asyncio.gather(
|
||||
*(
|
||||
process_web_search(
|
||||
request,
|
||||
SearchForm(**{"query": searchQuery}),
|
||||
user=user,
|
||||
)
|
||||
for searchQuery in queries
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
try:
|
||||
results = await process_web_search(
|
||||
request,
|
||||
SearchForm(queries=queries),
|
||||
user=user,
|
||||
)
|
||||
|
||||
for searchQuery, results in zip(queries, gathered_results):
|
||||
try:
|
||||
if isinstance(results, Exception):
|
||||
raise Exception(f"Error searching {searchQuery}: {str(results)}")
|
||||
if results:
|
||||
files = form_data.get("files", [])
|
||||
|
||||
if results:
|
||||
all_results.append(results)
|
||||
files = form_data.get("files", [])
|
||||
if results.get("collection_names"):
|
||||
for col_idx, collection_name in enumerate(
|
||||
results.get("collection_names")
|
||||
):
|
||||
files.append(
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
"name": ", ".join(queries),
|
||||
"type": "web_search",
|
||||
"urls": results["filenames"],
|
||||
"queries": queries,
|
||||
}
|
||||
)
|
||||
elif results.get("docs"):
|
||||
# Invoked when bypass embedding and retrieval is set to True
|
||||
docs = results["docs"]
|
||||
files.append(
|
||||
{
|
||||
"docs": docs,
|
||||
"name": ", ".join(queries),
|
||||
"type": "web_search",
|
||||
"urls": results["filenames"],
|
||||
"queries": queries,
|
||||
}
|
||||
)
|
||||
|
||||
if results.get("collection_names"):
|
||||
for col_idx, collection_name in enumerate(
|
||||
results.get("collection_names")
|
||||
):
|
||||
files.append(
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
"name": searchQuery,
|
||||
"type": "web_search",
|
||||
"urls": [results["filenames"][col_idx]],
|
||||
}
|
||||
)
|
||||
elif results.get("docs"):
|
||||
# Invoked when bypass embedding and retrieval is set to True
|
||||
docs = results["docs"]
|
||||
form_data["files"] = files
|
||||
|
||||
if len(docs) == len(results["filenames"]):
|
||||
# the number of docs and filenames (urls) should be the same
|
||||
for doc_idx, doc in enumerate(docs):
|
||||
files.append(
|
||||
{
|
||||
"docs": [doc],
|
||||
"name": searchQuery,
|
||||
"type": "web_search",
|
||||
"urls": [results["filenames"][doc_idx]],
|
||||
}
|
||||
)
|
||||
else:
|
||||
# edge case when the number of docs and filenames (urls) are not the same
|
||||
# this should not happen, but if it does, we will just append the docs
|
||||
files.append(
|
||||
{
|
||||
"docs": results.get("docs", []),
|
||||
"name": searchQuery,
|
||||
"type": "web_search",
|
||||
"urls": results["filenames"],
|
||||
}
|
||||
)
|
||||
|
||||
form_data["files"] = files
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "web_search",
|
||||
"description": 'Error searching "{{searchQuery}}"',
|
||||
"query": searchQuery,
|
||||
"description": "Searched {{count}} sites",
|
||||
"urls": results["filenames"],
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "web_search",
|
||||
"description": "No search results found",
|
||||
"done": True,
|
||||
"error": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if all_results:
|
||||
urls = []
|
||||
for results in all_results:
|
||||
if "filenames" in results:
|
||||
urls.extend(results["filenames"])
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "web_search",
|
||||
"description": "Searched {{count}} sites",
|
||||
"urls": urls,
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "web_search",
|
||||
"description": "No search results found",
|
||||
"description": "An error occurred while searching the web",
|
||||
"queries": queries,
|
||||
"done": True,
|
||||
"error": True,
|
||||
},
|
||||
@@ -631,6 +656,7 @@ async def chat_completion_files_handler(
|
||||
reranking_function=request.app.state.rf,
|
||||
k_reranker=request.app.state.config.TOP_K_RERANKER,
|
||||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
full_context=request.app.state.config.RAG_FULL_CONTEXT,
|
||||
),
|
||||
@@ -645,35 +671,40 @@ async def chat_completion_files_handler(
|
||||
|
||||
def apply_params_to_form_data(form_data, model):
|
||||
params = form_data.pop("params", {})
|
||||
if model.get("ollama"):
|
||||
custom_params = params.pop("custom_params", {})
|
||||
|
||||
open_webui_params = {
|
||||
"stream_response": bool,
|
||||
"function_calling": str,
|
||||
"system": str,
|
||||
}
|
||||
|
||||
for key in list(params.keys()):
|
||||
if key in open_webui_params:
|
||||
del params[key]
|
||||
|
||||
if custom_params:
|
||||
# Attempt to parse custom_params if they are strings
|
||||
for key, value in custom_params.items():
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Attempt to parse the string as JSON
|
||||
custom_params[key] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# If it fails, keep the original string
|
||||
pass
|
||||
|
||||
# If custom_params are provided, merge them into params
|
||||
params = deep_update(params, custom_params)
|
||||
|
||||
if model.get("owned_by") == "ollama":
|
||||
# Ollama specific parameters
|
||||
form_data["options"] = params
|
||||
|
||||
if "format" in params:
|
||||
form_data["format"] = params["format"]
|
||||
|
||||
if "keep_alive" in params:
|
||||
form_data["keep_alive"] = params["keep_alive"]
|
||||
else:
|
||||
if "seed" in params and params["seed"] is not None:
|
||||
form_data["seed"] = params["seed"]
|
||||
|
||||
if "stop" in params and params["stop"] is not None:
|
||||
form_data["stop"] = params["stop"]
|
||||
|
||||
if "temperature" in params and params["temperature"] is not None:
|
||||
form_data["temperature"] = params["temperature"]
|
||||
|
||||
if "max_tokens" in params and params["max_tokens"] is not None:
|
||||
form_data["max_tokens"] = params["max_tokens"]
|
||||
|
||||
if "top_p" in params and params["top_p"] is not None:
|
||||
form_data["top_p"] = params["top_p"]
|
||||
|
||||
if "frequency_penalty" in params and params["frequency_penalty"] is not None:
|
||||
form_data["frequency_penalty"] = params["frequency_penalty"]
|
||||
|
||||
if "reasoning_effort" in params and params["reasoning_effort"] is not None:
|
||||
form_data["reasoning_effort"] = params["reasoning_effort"]
|
||||
if isinstance(params, dict):
|
||||
for key, value in params.items():
|
||||
if value is not None:
|
||||
form_data[key] = value
|
||||
|
||||
if "logit_bias" in params and params["logit_bias"] is not None:
|
||||
try:
|
||||
@@ -681,13 +712,12 @@ def apply_params_to_form_data(form_data, model):
|
||||
convert_logit_bias_input_to_json(params["logit_bias"])
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error parsing logit_bias: {e}")
|
||||
log.exception(f"Error parsing logit_bias: {e}")
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
form_data = apply_params_to_form_data(form_data, model)
|
||||
log.debug(f"form_data: {form_data}")
|
||||
|
||||
@@ -697,12 +727,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
extra_params = {
|
||||
"__event_emitter__": event_emitter,
|
||||
"__event_call__": event_call,
|
||||
"__user__": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
},
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__request__": request,
|
||||
"__model__": model,
|
||||
@@ -779,9 +804,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
raise e
|
||||
|
||||
try:
|
||||
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(model)
|
||||
for filter_id in get_sorted_filter_ids(
|
||||
request, model, metadata.get("filter_ids", [])
|
||||
)
|
||||
]
|
||||
|
||||
form_data, flags = await process_filter_functions(
|
||||
@@ -796,6 +824,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
features = form_data.pop("features", None)
|
||||
if features:
|
||||
if "memory" in features and features["memory"]:
|
||||
form_data = await chat_memory_handler(
|
||||
request, form_data, extra_params, user
|
||||
)
|
||||
|
||||
if "web_search" in features and features["web_search"]:
|
||||
form_data = await chat_web_search_handler(
|
||||
request, form_data, extra_params, user
|
||||
@@ -898,6 +931,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
for doc_context, doc_meta in zip(
|
||||
source["document"], source["metadata"]
|
||||
):
|
||||
source_name = source.get("source", {}).get("name", None)
|
||||
citation_id = (
|
||||
doc_meta.get("source", None)
|
||||
or source.get("source", {}).get("id", None)
|
||||
@@ -905,7 +939,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
)
|
||||
if citation_id not in citation_idx:
|
||||
citation_idx[citation_id] = len(citation_idx) + 1
|
||||
context_string += f'<source id="{citation_idx[citation_id]}">{doc_context}</source>\n'
|
||||
context_string += (
|
||||
f'<source id="{citation_idx[citation_id]}"'
|
||||
+ (f' name="{source_name}"' if source_name else "")
|
||||
+ f">{doc_context}</source>\n"
|
||||
)
|
||||
|
||||
context_string = context_string.strip()
|
||||
prompt = get_last_user_message(form_data["messages"])
|
||||
@@ -972,11 +1010,101 @@ async def process_chat_response(
|
||||
message = message_map.get(metadata["message_id"]) if message_map else None
|
||||
|
||||
if message:
|
||||
messages = get_message_list(message_map, message.get("id"))
|
||||
message_list = get_message_list(message_map, metadata["message_id"])
|
||||
|
||||
# Remove details tags and files from the messages.
|
||||
# as get_message_list creates a new list, it does not affect
|
||||
# the original messages outside of this handler
|
||||
|
||||
messages = []
|
||||
for message in message_list:
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if item.get("type") == "text":
|
||||
content = item["text"]
|
||||
break
|
||||
|
||||
if isinstance(content, str):
|
||||
content = re.sub(
|
||||
r"<details\b[^>]*>.*?<\/details>|!\[.*?\]\(.*?\)",
|
||||
"",
|
||||
content,
|
||||
flags=re.S | re.I,
|
||||
).strip()
|
||||
|
||||
messages.append(
|
||||
{
|
||||
**message,
|
||||
"role": message.get(
|
||||
"role", "assistant"
|
||||
), # Safe fallback for missing role
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
if tasks and messages:
|
||||
if (
|
||||
TASKS.FOLLOW_UP_GENERATION in tasks
|
||||
and tasks[TASKS.FOLLOW_UP_GENERATION]
|
||||
):
|
||||
res = await generate_follow_ups(
|
||||
request,
|
||||
{
|
||||
"model": message["model"],
|
||||
"messages": messages,
|
||||
"message_id": metadata["message_id"],
|
||||
"chat_id": metadata["chat_id"],
|
||||
},
|
||||
user,
|
||||
)
|
||||
|
||||
if res and isinstance(res, dict):
|
||||
if len(res.get("choices", [])) == 1:
|
||||
follow_ups_string = (
|
||||
res.get("choices", [])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
else:
|
||||
follow_ups_string = ""
|
||||
|
||||
follow_ups_string = follow_ups_string[
|
||||
follow_ups_string.find("{") : follow_ups_string.rfind("}")
|
||||
+ 1
|
||||
]
|
||||
|
||||
try:
|
||||
follow_ups = json.loads(follow_ups_string).get(
|
||||
"follow_ups", []
|
||||
)
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"followUps": follow_ups,
|
||||
},
|
||||
)
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:message:follow_ups",
|
||||
"data": {
|
||||
"follow_ups": follow_ups,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if TASKS.TITLE_GENERATION in tasks:
|
||||
user_message = get_last_user_message(messages)
|
||||
if user_message and len(user_message) > 100:
|
||||
user_message = user_message[:100] + "..."
|
||||
|
||||
if tasks[TASKS.TITLE_GENERATION]:
|
||||
|
||||
res = await generate_title(
|
||||
request,
|
||||
{
|
||||
@@ -992,7 +1120,9 @@ async def process_chat_response(
|
||||
title_string = (
|
||||
res.get("choices", [])[0]
|
||||
.get("message", {})
|
||||
.get("content", message.get("content", "New Chat"))
|
||||
.get(
|
||||
"content", message.get("content", user_message)
|
||||
)
|
||||
)
|
||||
else:
|
||||
title_string = ""
|
||||
@@ -1003,13 +1133,13 @@ async def process_chat_response(
|
||||
|
||||
try:
|
||||
title = json.loads(title_string).get(
|
||||
"title", "New Chat"
|
||||
"title", user_message
|
||||
)
|
||||
except Exception as e:
|
||||
title = ""
|
||||
|
||||
if not title:
|
||||
title = messages[0].get("content", "New Chat")
|
||||
title = messages[0].get("content", user_message)
|
||||
|
||||
Chats.update_chat_title_by_id(metadata["chat_id"], title)
|
||||
|
||||
@@ -1020,14 +1150,14 @@ async def process_chat_response(
|
||||
}
|
||||
)
|
||||
elif len(messages) == 2:
|
||||
title = messages[0].get("content", "New Chat")
|
||||
title = messages[0].get("content", user_message)
|
||||
|
||||
Chats.update_chat_title_by_id(metadata["chat_id"], title)
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:title",
|
||||
"data": message.get("content", "New Chat"),
|
||||
"data": message.get("content", user_message),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1137,6 +1267,7 @@ async def process_chat_response(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
@@ -1159,8 +1290,34 @@ async def process_chat_response(
|
||||
|
||||
await background_tasks_handler()
|
||||
|
||||
if events and isinstance(events, list) and isinstance(response, dict):
|
||||
extra_response = {}
|
||||
for event in events:
|
||||
if isinstance(event, dict):
|
||||
extra_response.update(event)
|
||||
else:
|
||||
extra_response[event] = True
|
||||
|
||||
response = {
|
||||
**extra_response,
|
||||
**response,
|
||||
}
|
||||
|
||||
return response
|
||||
else:
|
||||
if events and isinstance(events, list) and isinstance(response, dict):
|
||||
extra_response = {}
|
||||
for event in events:
|
||||
if isinstance(event, dict):
|
||||
extra_response.update(event)
|
||||
else:
|
||||
extra_response[event] = True
|
||||
|
||||
response = {
|
||||
**extra_response,
|
||||
**response,
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
# Non standard response
|
||||
@@ -1173,19 +1330,16 @@ async def process_chat_response(
|
||||
extra_params = {
|
||||
"__event_emitter__": event_emitter,
|
||||
"__event_call__": event_caller,
|
||||
"__user__": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
},
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__request__": request,
|
||||
"__model__": model,
|
||||
}
|
||||
filter_functions = [
|
||||
Functions.get_function_by_id(filter_id)
|
||||
for filter_id in get_sorted_filter_ids(model)
|
||||
for filter_id in get_sorted_filter_ids(
|
||||
request, model, metadata.get("filter_ids", [])
|
||||
)
|
||||
]
|
||||
|
||||
# Streaming response
|
||||
@@ -1430,11 +1584,12 @@ async def process_chat_response(
|
||||
|
||||
if after_tag:
|
||||
content_blocks[-1]["content"] = after_tag
|
||||
tag_content_handler(
|
||||
content_type, tags, after_tag, content_blocks
|
||||
)
|
||||
|
||||
content = after_tag
|
||||
break
|
||||
|
||||
if content and content_blocks[-1]["type"] == content_type:
|
||||
elif content_blocks[-1]["type"] == content_type:
|
||||
start_tag = content_blocks[-1]["start_tag"]
|
||||
end_tag = content_blocks[-1]["end_tag"]
|
||||
# Match end tag e.g., </tag>
|
||||
@@ -1719,9 +1874,11 @@ async def process_chat_response(
|
||||
|
||||
value = delta.get("content")
|
||||
|
||||
reasoning_content = delta.get(
|
||||
"reasoning_content"
|
||||
) or delta.get("reasoning")
|
||||
reasoning_content = (
|
||||
delta.get("reasoning_content")
|
||||
or delta.get("reasoning")
|
||||
or delta.get("thinking")
|
||||
)
|
||||
if reasoning_content:
|
||||
if (
|
||||
not content_blocks
|
||||
@@ -1904,28 +2061,38 @@ async def process_chat_response(
|
||||
tools = metadata.get("tools", {})
|
||||
|
||||
results = []
|
||||
|
||||
for tool_call in response_tool_calls:
|
||||
tool_call_id = tool_call.get("id", "")
|
||||
tool_name = tool_call.get("function", {}).get("name", "")
|
||||
tool_args = tool_call.get("function", {}).get("arguments", "{}")
|
||||
|
||||
tool_function_params = {}
|
||||
try:
|
||||
# json.loads cannot be used because some models do not produce valid JSON
|
||||
tool_function_params = ast.literal_eval(
|
||||
tool_call.get("function", {}).get("arguments", "{}")
|
||||
)
|
||||
tool_function_params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
# Fallback to JSON parsing
|
||||
try:
|
||||
tool_function_params = json.loads(
|
||||
tool_call.get("function", {}).get("arguments", "{}")
|
||||
)
|
||||
tool_function_params = json.loads(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(
|
||||
f"Error parsing tool call arguments: {tool_call.get('function', {}).get('arguments', '{}')}"
|
||||
log.error(
|
||||
f"Error parsing tool call arguments: {tool_args}"
|
||||
)
|
||||
|
||||
# Mutate the original tool call response params as they are passed back to the passed
|
||||
# back to the LLM via the content blocks. If they are in a json block and are invalid json,
|
||||
# this can cause downstream LLM integrations to fail (e.g. bedrock gateway) where response
|
||||
# params are not valid json.
|
||||
# Main case so far is no args = "" = invalid json.
|
||||
log.debug(
|
||||
f"Parsed args from {tool_args} to {tool_function_params}"
|
||||
)
|
||||
tool_call.setdefault("function", {})["arguments"] = json.dumps(
|
||||
tool_function_params
|
||||
)
|
||||
|
||||
tool_result = None
|
||||
|
||||
if tool_name in tools:
|
||||
@@ -2112,28 +2279,21 @@ async def process_chat_response(
|
||||
stdoutLines = stdout.split("\n")
|
||||
for idx, line in enumerate(stdoutLines):
|
||||
if "data:image/png;base64" in line:
|
||||
id = str(uuid4())
|
||||
|
||||
# ensure the path exists
|
||||
os.makedirs(
|
||||
os.path.join(CACHE_DIR, "images"),
|
||||
exist_ok=True,
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = (
|
||||
load_b64_image_data(line)
|
||||
)
|
||||
|
||||
image_path = os.path.join(
|
||||
CACHE_DIR,
|
||||
f"images/{id}.png",
|
||||
)
|
||||
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(
|
||||
base64.b64decode(
|
||||
line.split(",")[1]
|
||||
)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
|
||||
stdoutLines[idx] = (
|
||||
f""
|
||||
f""
|
||||
)
|
||||
|
||||
output["stdout"] = "\n".join(stdoutLines)
|
||||
@@ -2144,30 +2304,22 @@ async def process_chat_response(
|
||||
resultLines = result.split("\n")
|
||||
for idx, line in enumerate(resultLines):
|
||||
if "data:image/png;base64" in line:
|
||||
id = str(uuid4())
|
||||
|
||||
# ensure the path exists
|
||||
os.makedirs(
|
||||
os.path.join(CACHE_DIR, "images"),
|
||||
exist_ok=True,
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = (
|
||||
load_b64_image_data(line)
|
||||
)
|
||||
|
||||
image_path = os.path.join(
|
||||
CACHE_DIR,
|
||||
f"images/{id}.png",
|
||||
)
|
||||
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(
|
||||
base64.b64decode(
|
||||
line.split(",")[1]
|
||||
)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
|
||||
resultLines[idx] = (
|
||||
f""
|
||||
f""
|
||||
)
|
||||
|
||||
output["result"] = "\n".join(resultLines)
|
||||
except Exception as e:
|
||||
output = str(e)
|
||||
@@ -2276,8 +2428,8 @@ async def process_chat_response(
|
||||
await response.background()
|
||||
|
||||
# background_tasks.add_task(post_response_handler, response, events)
|
||||
task_id, _ = create_task(
|
||||
post_response_handler(response, events), id=metadata["chat_id"]
|
||||
task_id, _ = await create_task(
|
||||
request, post_response_handler(response, events), id=metadata["chat_id"]
|
||||
)
|
||||
return {"status": True, "task_id": task_id}
|
||||
|
||||
|
||||
@@ -34,11 +34,15 @@ def get_message_list(messages, message_id):
|
||||
:return: List of ordered messages starting from the root to the given message
|
||||
"""
|
||||
|
||||
# Handle case where messages is None
|
||||
if not messages:
|
||||
return [] # Return empty list instead of None to prevent iteration errors
|
||||
|
||||
# Find the message by its id
|
||||
current_message = messages.get(message_id)
|
||||
|
||||
if not current_message:
|
||||
return None
|
||||
return [] # Return empty list instead of None to prevent iteration errors
|
||||
|
||||
# Reconstruct the chain by following the parentId links
|
||||
message_list = []
|
||||
@@ -47,7 +51,7 @@ def get_message_list(messages, message_id):
|
||||
message_list.insert(
|
||||
0, current_message
|
||||
) # Insert the message at the beginning of the list
|
||||
parent_id = current_message["parentId"]
|
||||
parent_id = current_message.get("parentId") # Use .get() for safety
|
||||
current_message = messages.get(parent_id) if parent_id else None
|
||||
|
||||
return message_list
|
||||
@@ -70,12 +74,12 @@ def get_last_user_message_item(messages: list[dict]) -> Optional[dict]:
|
||||
|
||||
|
||||
def get_content_from_message(message: dict) -> Optional[str]:
|
||||
if isinstance(message["content"], list):
|
||||
if isinstance(message.get("content"), list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
return item["text"]
|
||||
else:
|
||||
return message["content"]
|
||||
return message.get("content")
|
||||
return None
|
||||
|
||||
|
||||
@@ -130,7 +134,9 @@ def prepend_to_first_user_message_content(
|
||||
return messages
|
||||
|
||||
|
||||
def add_or_update_system_message(content: str, messages: list[dict]):
|
||||
def add_or_update_system_message(
|
||||
content: str, messages: list[dict], append: bool = False
|
||||
):
|
||||
"""
|
||||
Adds a new system message at the beginning of the messages list
|
||||
or updates the existing system message at the beginning.
|
||||
@@ -141,7 +147,10 @@ def add_or_update_system_message(content: str, messages: list[dict]):
|
||||
"""
|
||||
|
||||
if messages and messages[0].get("role") == "system":
|
||||
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
|
||||
if append:
|
||||
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
|
||||
else:
|
||||
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
|
||||
else:
|
||||
# Insert at the beginning
|
||||
messages.insert(0, {"role": "system", "content": content})
|
||||
@@ -199,6 +208,7 @@ def openai_chat_message_template(model: str):
|
||||
def openai_chat_chunk_message_template(
|
||||
model: str,
|
||||
content: Optional[str] = None,
|
||||
reasoning_content: Optional[str] = None,
|
||||
tool_calls: Optional[list[dict]] = None,
|
||||
usage: Optional[dict] = None,
|
||||
) -> dict:
|
||||
@@ -211,6 +221,9 @@ def openai_chat_chunk_message_template(
|
||||
if content:
|
||||
template["choices"][0]["delta"]["content"] = content
|
||||
|
||||
if reasoning_content:
|
||||
template["choices"][0]["delta"]["reasoning_content"] = reasoning_content
|
||||
|
||||
if tool_calls:
|
||||
template["choices"][0]["delta"]["tool_calls"] = tool_calls
|
||||
|
||||
@@ -225,6 +238,7 @@ def openai_chat_chunk_message_template(
|
||||
def openai_chat_completion_message_template(
|
||||
model: str,
|
||||
message: Optional[str] = None,
|
||||
reasoning_content: Optional[str] = None,
|
||||
tool_calls: Optional[list[dict]] = None,
|
||||
usage: Optional[dict] = None,
|
||||
) -> dict:
|
||||
@@ -232,8 +246,9 @@ def openai_chat_completion_message_template(
|
||||
template["object"] = "chat.completion"
|
||||
if message is not None:
|
||||
template["choices"][0]["message"] = {
|
||||
"content": message,
|
||||
"role": "assistant",
|
||||
"content": message,
|
||||
**({"reasoning_content": reasoning_content} if reasoning_content else {}),
|
||||
**({"tool_calls": tool_calls} if tool_calls else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from aiocache import cached
|
||||
@@ -13,7 +14,10 @@ from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
@@ -30,34 +34,46 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
async def fetch_ollama_models(request: Request, user: UserModel = None):
|
||||
raw_ollama_models = await ollama.get_all_models(request, user=user)
|
||||
return [
|
||||
{
|
||||
"id": model["model"],
|
||||
"name": model["name"],
|
||||
"object": "model",
|
||||
"created": int(time.time()),
|
||||
"owned_by": "ollama",
|
||||
"ollama": model,
|
||||
"connection_type": model.get("connection_type", "local"),
|
||||
"tags": model.get("tags", []),
|
||||
}
|
||||
for model in raw_ollama_models["models"]
|
||||
]
|
||||
|
||||
|
||||
async def fetch_openai_models(request: Request, user: UserModel = None):
|
||||
openai_response = await openai.get_all_models(request, user=user)
|
||||
return openai_response["data"]
|
||||
|
||||
|
||||
async def get_all_base_models(request: Request, user: UserModel = None):
|
||||
function_models = []
|
||||
openai_models = []
|
||||
ollama_models = []
|
||||
openai_task = (
|
||||
fetch_openai_models(request, user)
|
||||
if request.app.state.config.ENABLE_OPENAI_API
|
||||
else asyncio.sleep(0, result=[])
|
||||
)
|
||||
ollama_task = (
|
||||
fetch_ollama_models(request, user)
|
||||
if request.app.state.config.ENABLE_OLLAMA_API
|
||||
else asyncio.sleep(0, result=[])
|
||||
)
|
||||
function_task = get_function_models(request)
|
||||
|
||||
if request.app.state.config.ENABLE_OPENAI_API:
|
||||
openai_models = await openai.get_all_models(request, user=user)
|
||||
openai_models = openai_models["data"]
|
||||
openai_models, ollama_models, function_models = await asyncio.gather(
|
||||
openai_task, ollama_task, function_task
|
||||
)
|
||||
|
||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||
ollama_models = await ollama.get_all_models(request, user=user)
|
||||
ollama_models = [
|
||||
{
|
||||
"id": model["model"],
|
||||
"name": model["name"],
|
||||
"object": "model",
|
||||
"created": int(time.time()),
|
||||
"owned_by": "ollama",
|
||||
"ollama": model,
|
||||
"tags": model.get("tags", []),
|
||||
}
|
||||
for model in ollama_models["models"]
|
||||
]
|
||||
|
||||
function_models = await get_function_models(request)
|
||||
models = function_models + openai_models + ollama_models
|
||||
|
||||
return models
|
||||
return function_models + openai_models + ollama_models
|
||||
|
||||
|
||||
async def get_all_models(request, user: UserModel = None):
|
||||
@@ -110,6 +126,14 @@ async def get_all_models(request, user: UserModel = None):
|
||||
for function in Functions.get_functions_by_type("action", active_only=True)
|
||||
]
|
||||
|
||||
global_filter_ids = [
|
||||
function.id for function in Functions.get_global_filter_functions()
|
||||
]
|
||||
enabled_filter_ids = [
|
||||
function.id
|
||||
for function in Functions.get_functions_by_type("filter", active_only=True)
|
||||
]
|
||||
|
||||
custom_models = Models.get_all_models()
|
||||
for custom_model in custom_models:
|
||||
if custom_model.base_model_id is None:
|
||||
@@ -125,13 +149,20 @@ async def get_all_models(request, user: UserModel = None):
|
||||
model["name"] = custom_model.name
|
||||
model["info"] = custom_model.model_dump()
|
||||
|
||||
# Set action_ids and filter_ids
|
||||
action_ids = []
|
||||
filter_ids = []
|
||||
|
||||
if "info" in model and "meta" in model["info"]:
|
||||
action_ids.extend(
|
||||
model["info"]["meta"].get("actionIds", [])
|
||||
)
|
||||
filter_ids.extend(
|
||||
model["info"]["meta"].get("filterIds", [])
|
||||
)
|
||||
|
||||
model["action_ids"] = action_ids
|
||||
model["filter_ids"] = filter_ids
|
||||
else:
|
||||
models.remove(model)
|
||||
|
||||
@@ -140,7 +171,9 @@ async def get_all_models(request, user: UserModel = None):
|
||||
):
|
||||
owned_by = "openai"
|
||||
pipe = None
|
||||
|
||||
action_ids = []
|
||||
filter_ids = []
|
||||
|
||||
for model in models:
|
||||
if (
|
||||
@@ -154,9 +187,13 @@ async def get_all_models(request, user: UserModel = None):
|
||||
|
||||
if custom_model.meta:
|
||||
meta = custom_model.meta.model_dump()
|
||||
|
||||
if "actionIds" in meta:
|
||||
action_ids.extend(meta["actionIds"])
|
||||
|
||||
if "filterIds" in meta:
|
||||
filter_ids.extend(meta["filterIds"])
|
||||
|
||||
models.append(
|
||||
{
|
||||
"id": f"{custom_model.id}",
|
||||
@@ -168,6 +205,7 @@ async def get_all_models(request, user: UserModel = None):
|
||||
"preset": True,
|
||||
**({"pipe": pipe} if pipe is not None else {}),
|
||||
"action_ids": action_ids,
|
||||
"filter_ids": filter_ids,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -181,8 +219,11 @@ async def get_all_models(request, user: UserModel = None):
|
||||
"id": f"{function.id}.{action['id']}",
|
||||
"name": action.get("name", f"{function.name} ({action['id']})"),
|
||||
"description": function.meta.description,
|
||||
"icon_url": action.get(
|
||||
"icon_url", function.meta.manifest.get("icon_url", None)
|
||||
"icon": action.get(
|
||||
"icon_url",
|
||||
function.meta.manifest.get("icon_url", None)
|
||||
or getattr(module, "icon_url", None)
|
||||
or getattr(module, "icon", None),
|
||||
),
|
||||
}
|
||||
for action in actions
|
||||
@@ -193,16 +234,27 @@ async def get_all_models(request, user: UserModel = None):
|
||||
"id": function.id,
|
||||
"name": function.name,
|
||||
"description": function.meta.description,
|
||||
"icon_url": function.meta.manifest.get("icon_url", None),
|
||||
"icon": function.meta.manifest.get("icon_url", None)
|
||||
or getattr(module, "icon_url", None)
|
||||
or getattr(module, "icon", None),
|
||||
}
|
||||
]
|
||||
|
||||
# Process filter_ids to get the filters
|
||||
def get_filter_items_from_module(function, module):
|
||||
return [
|
||||
{
|
||||
"id": function.id,
|
||||
"name": function.name,
|
||||
"description": function.meta.description,
|
||||
"icon": function.meta.manifest.get("icon_url", None)
|
||||
or getattr(module, "icon_url", None)
|
||||
or getattr(module, "icon", None),
|
||||
}
|
||||
]
|
||||
|
||||
def get_function_module_by_id(function_id):
|
||||
if function_id in request.app.state.FUNCTIONS:
|
||||
function_module = request.app.state.FUNCTIONS[function_id]
|
||||
else:
|
||||
function_module, _, _ = load_function_module_by_id(function_id)
|
||||
request.app.state.FUNCTIONS[function_id] = function_module
|
||||
function_module, _, _ = get_function_module_from_cache(request, function_id)
|
||||
return function_module
|
||||
|
||||
for model in models:
|
||||
@@ -211,6 +263,11 @@ async def get_all_models(request, user: UserModel = None):
|
||||
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
|
||||
if action_id in enabled_action_ids
|
||||
]
|
||||
filter_ids = [
|
||||
filter_id
|
||||
for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids))
|
||||
if filter_id in enabled_filter_ids
|
||||
]
|
||||
|
||||
model["actions"] = []
|
||||
for action_id in action_ids:
|
||||
@@ -222,6 +279,20 @@ async def get_all_models(request, user: UserModel = None):
|
||||
model["actions"].extend(
|
||||
get_action_items_from_module(action_function, function_module)
|
||||
)
|
||||
|
||||
model["filters"] = []
|
||||
for filter_id in filter_ids:
|
||||
filter_function = Functions.get_function_by_id(filter_id)
|
||||
if filter_function is None:
|
||||
raise Exception(f"Filter not found: {filter_id}")
|
||||
|
||||
function_module = get_function_module_by_id(filter_id)
|
||||
|
||||
if getattr(function_module, "toggle", None):
|
||||
model["filters"].extend(
|
||||
get_filter_items_from_module(filter_function, function_module)
|
||||
)
|
||||
|
||||
log.debug(f"get_all_models() returned {len(models)} models")
|
||||
|
||||
request.app.state.MODELS = {model["id"]: model for model in models}
|
||||
|
||||
@@ -34,12 +34,14 @@ from open_webui.config import (
|
||||
OAUTH_ALLOWED_ROLES,
|
||||
OAUTH_ADMIN_ROLES,
|
||||
OAUTH_ALLOWED_DOMAINS,
|
||||
OAUTH_UPDATE_PICTURE_ON_LOGIN,
|
||||
WEBHOOK_URL,
|
||||
JWT_EXPIRES_IN,
|
||||
AppConfig,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
WEBUI_NAME,
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
@@ -72,6 +74,7 @@ auth_manager_config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES
|
||||
auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
|
||||
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
|
||||
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||
|
||||
|
||||
class OAuthManager:
|
||||
@@ -158,7 +161,13 @@ class OAuthManager:
|
||||
nested_claims = oauth_claim.split(".")
|
||||
for nested_claim in nested_claims:
|
||||
claim_data = claim_data.get(nested_claim, {})
|
||||
user_oauth_groups = claim_data if isinstance(claim_data, list) else []
|
||||
|
||||
if isinstance(claim_data, list):
|
||||
user_oauth_groups = claim_data
|
||||
elif isinstance(claim_data, str):
|
||||
user_oauth_groups = [claim_data]
|
||||
else:
|
||||
user_oauth_groups = []
|
||||
|
||||
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
|
||||
all_available_groups: list[GroupModel] = Groups.get_groups()
|
||||
@@ -169,7 +178,7 @@ class OAuthManager:
|
||||
all_group_names = {g.name for g in all_available_groups}
|
||||
groups_created = False
|
||||
# Determine creator ID: Prefer admin, fallback to current user if no admin exists
|
||||
admin_user = Users.get_admin_user()
|
||||
admin_user = Users.get_super_admin_user()
|
||||
creator_id = admin_user.id if admin_user else user.id
|
||||
log.debug(f"Using creator ID {creator_id} for potential group creation.")
|
||||
|
||||
@@ -276,6 +285,51 @@ class OAuthManager:
|
||||
id=group_model.id, form_data=update_form, overwrite=False
|
||||
)
|
||||
|
||||
async def _process_picture_url(
|
||||
self, picture_url: str, access_token: str = None
|
||||
) -> str:
|
||||
"""Process a picture URL and return a base64 encoded data URL.
|
||||
|
||||
Args:
|
||||
picture_url: The URL of the picture to process
|
||||
access_token: Optional OAuth access token for authenticated requests
|
||||
|
||||
Returns:
|
||||
A data URL containing the base64 encoded picture, or "/user.png" if processing fails
|
||||
"""
|
||||
if not picture_url:
|
||||
return "/user.png"
|
||||
|
||||
try:
|
||||
get_kwargs = {}
|
||||
if access_token:
|
||||
get_kwargs["headers"] = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
picture_url, **get_kwargs, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
picture = await resp.read()
|
||||
base64_encoded_picture = base64.b64encode(picture).decode(
|
||||
"utf-8"
|
||||
)
|
||||
guessed_mime_type = mimetypes.guess_type(picture_url)[0]
|
||||
if guessed_mime_type is None:
|
||||
guessed_mime_type = "image/jpeg"
|
||||
return (
|
||||
f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"Failed to fetch profile picture from {picture_url}"
|
||||
)
|
||||
return "/user.png"
|
||||
except Exception as e:
|
||||
log.error(f"Error processing profile picture '{picture_url}': {e}")
|
||||
return "/user.png"
|
||||
|
||||
async def handle_login(self, request, provider):
|
||||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(404)
|
||||
@@ -320,7 +374,9 @@ class OAuthManager:
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
"https://api.github.com/user/emails", headers=headers
|
||||
"https://api.github.com/user/emails",
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
emails = await resp.json()
|
||||
@@ -376,6 +432,22 @@ class OAuthManager:
|
||||
if user.role != determined_role:
|
||||
Users.update_user_role_by_id(user.id, determined_role)
|
||||
|
||||
# Update profile picture if enabled and different from current
|
||||
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
new_picture_url = user_data.get(
|
||||
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
|
||||
)
|
||||
processed_picture_url = await self._process_picture_url(
|
||||
new_picture_url, token.get("access_token")
|
||||
)
|
||||
if processed_picture_url != user.profile_image_url:
|
||||
Users.update_user_profile_image_url_by_id(
|
||||
user.id, processed_picture_url
|
||||
)
|
||||
log.debug(f"Updated profile picture for user {user.email}")
|
||||
|
||||
if not user:
|
||||
user_count = Users.get_num_users()
|
||||
|
||||
@@ -391,40 +463,9 @@ class OAuthManager:
|
||||
picture_url = user_data.get(
|
||||
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
|
||||
)
|
||||
if picture_url:
|
||||
# Download the profile image into a base64 string
|
||||
try:
|
||||
access_token = token.get("access_token")
|
||||
get_kwargs = {}
|
||||
if access_token:
|
||||
get_kwargs["headers"] = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
picture_url, **get_kwargs
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
picture = await resp.read()
|
||||
base64_encoded_picture = base64.b64encode(
|
||||
picture
|
||||
).decode("utf-8")
|
||||
guessed_mime_type = mimetypes.guess_type(
|
||||
picture_url
|
||||
)[0]
|
||||
if guessed_mime_type is None:
|
||||
# assume JPG, browsers are tolerant enough of image formats
|
||||
guessed_mime_type = "image/jpeg"
|
||||
picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
|
||||
else:
|
||||
picture_url = "/user.png"
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error downloading profile image '{picture_url}': {e}"
|
||||
)
|
||||
picture_url = "/user.png"
|
||||
if not picture_url:
|
||||
picture_url = "/user.png"
|
||||
picture_url = await self._process_picture_url(
|
||||
picture_url, token.get("access_token")
|
||||
)
|
||||
else:
|
||||
picture_url = "/user.png"
|
||||
|
||||
@@ -495,5 +536,10 @@ class OAuthManager:
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
# Redirect back to the frontend with the JWT token
|
||||
redirect_url = f"{request.base_url}auth#token={jwt_token}"
|
||||
|
||||
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
if redirect_base_url.endswith("/"):
|
||||
redirect_base_url = redirect_base_url[:-1]
|
||||
redirect_url = f"{redirect_base_url}/auth#token={jwt_token}"
|
||||
|
||||
return RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from open_webui.utils.task import prompt_template, prompt_variables_template
|
||||
from open_webui.utils.misc import (
|
||||
deep_update,
|
||||
add_or_update_system_message,
|
||||
)
|
||||
|
||||
@@ -9,9 +10,8 @@ import json
|
||||
|
||||
# inplace function: form_data is modified
|
||||
def apply_model_system_prompt_to_body(
|
||||
params: dict, form_data: dict, metadata: Optional[dict] = None, user=None
|
||||
system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None
|
||||
) -> dict:
|
||||
system = params.get("system", None)
|
||||
if not system:
|
||||
return form_data
|
||||
|
||||
@@ -45,20 +45,67 @@ def apply_model_params_to_body(
|
||||
if not params:
|
||||
return form_data
|
||||
|
||||
for key, cast_func in mappings.items():
|
||||
if (value := params.get(key)) is not None:
|
||||
form_data[key] = cast_func(value)
|
||||
for key, value in params.items():
|
||||
if value is not None:
|
||||
if key in mappings:
|
||||
cast_func = mappings[key]
|
||||
if isinstance(cast_func, Callable):
|
||||
form_data[key] = cast_func(value)
|
||||
else:
|
||||
form_data[key] = value
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
def remove_open_webui_params(params: dict) -> dict:
|
||||
"""
|
||||
Removes OpenWebUI specific parameters from the provided dictionary.
|
||||
|
||||
Args:
|
||||
params (dict): The dictionary containing parameters.
|
||||
|
||||
Returns:
|
||||
dict: The modified dictionary with OpenWebUI parameters removed.
|
||||
"""
|
||||
open_webui_params = {
|
||||
"stream_response": bool,
|
||||
"function_calling": str,
|
||||
"system": str,
|
||||
}
|
||||
|
||||
for key in list(params.keys()):
|
||||
if key in open_webui_params:
|
||||
del params[key]
|
||||
|
||||
return params
|
||||
|
||||
|
||||
# inplace function: form_data is modified
|
||||
def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
|
||||
params = remove_open_webui_params(params)
|
||||
|
||||
custom_params = params.pop("custom_params", {})
|
||||
if custom_params:
|
||||
# Attempt to parse custom_params if they are strings
|
||||
for key, value in custom_params.items():
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Attempt to parse the string as JSON
|
||||
custom_params[key] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# If it fails, keep the original string
|
||||
pass
|
||||
|
||||
# If there are custom parameters, we need to apply them first
|
||||
params = deep_update(params, custom_params)
|
||||
|
||||
mappings = {
|
||||
"temperature": float,
|
||||
"top_p": float,
|
||||
"min_p": float,
|
||||
"max_tokens": int,
|
||||
"frequency_penalty": float,
|
||||
"presence_penalty": float,
|
||||
"reasoning_effort": str,
|
||||
"seed": lambda x: x,
|
||||
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
|
||||
@@ -69,6 +116,23 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
|
||||
|
||||
|
||||
def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
|
||||
params = remove_open_webui_params(params)
|
||||
|
||||
custom_params = params.pop("custom_params", {})
|
||||
if custom_params:
|
||||
# Attempt to parse custom_params if they are strings
|
||||
for key, value in custom_params.items():
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Attempt to parse the string as JSON
|
||||
custom_params[key] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# If it fails, keep the original string
|
||||
pass
|
||||
|
||||
# If there are custom parameters, we need to apply them first
|
||||
params = deep_update(params, custom_params)
|
||||
|
||||
# Convert OpenAI parameter names to Ollama parameter names if needed.
|
||||
name_differences = {
|
||||
"max_tokens": "num_predict",
|
||||
@@ -111,16 +175,32 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
|
||||
"num_thread": int,
|
||||
}
|
||||
|
||||
# Extract keep_alive from options if it exists
|
||||
if "options" in form_data and "keep_alive" in form_data["options"]:
|
||||
form_data["keep_alive"] = form_data["options"]["keep_alive"]
|
||||
del form_data["options"]["keep_alive"]
|
||||
def parse_json(value: str) -> dict:
|
||||
"""
|
||||
Parses a JSON string into a dictionary, handling potential JSONDecodeError.
|
||||
"""
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception as e:
|
||||
return value
|
||||
|
||||
if "options" in form_data and "format" in form_data["options"]:
|
||||
form_data["format"] = form_data["options"]["format"]
|
||||
del form_data["options"]["format"]
|
||||
ollama_root_params = {
|
||||
"format": lambda x: parse_json(x),
|
||||
"keep_alive": lambda x: parse_json(x),
|
||||
"think": bool,
|
||||
}
|
||||
|
||||
return apply_model_params_to_body(params, form_data, mappings)
|
||||
for key, value in ollama_root_params.items():
|
||||
if (param := params.get(key, None)) is not None:
|
||||
# Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided
|
||||
form_data[key] = value(param)
|
||||
del params[key]
|
||||
|
||||
# Unlike OpenAI, Ollama does not support params directly in the body
|
||||
form_data["options"] = apply_model_params_to_body(
|
||||
params, (form_data.get("options", {}) or {}), mappings
|
||||
)
|
||||
return form_data
|
||||
|
||||
|
||||
def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
|
||||
@@ -215,36 +295,48 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
|
||||
openai_payload.get("messages")
|
||||
)
|
||||
ollama_payload["stream"] = openai_payload.get("stream", False)
|
||||
|
||||
if "tools" in openai_payload:
|
||||
ollama_payload["tools"] = openai_payload["tools"]
|
||||
|
||||
if "format" in openai_payload:
|
||||
ollama_payload["format"] = openai_payload["format"]
|
||||
|
||||
# If there are advanced parameters in the payload, format them in Ollama's options field
|
||||
if openai_payload.get("options"):
|
||||
ollama_payload["options"] = openai_payload["options"]
|
||||
ollama_options = openai_payload["options"]
|
||||
|
||||
def parse_json(value: str) -> dict:
|
||||
"""
|
||||
Parses a JSON string into a dictionary, handling potential JSONDecodeError.
|
||||
"""
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception as e:
|
||||
return value
|
||||
|
||||
ollama_root_params = {
|
||||
"format": lambda x: parse_json(x),
|
||||
"keep_alive": lambda x: parse_json(x),
|
||||
"think": bool,
|
||||
}
|
||||
|
||||
# Ollama's options field can contain parameters that should be at the root level.
|
||||
for key, value in ollama_root_params.items():
|
||||
if (param := ollama_options.get(key, None)) is not None:
|
||||
# Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided
|
||||
ollama_payload[key] = value(param)
|
||||
del ollama_options[key]
|
||||
|
||||
# Re-Mapping OpenAI's `max_tokens` -> Ollama's `num_predict`
|
||||
if "max_tokens" in ollama_options:
|
||||
ollama_options["num_predict"] = ollama_options["max_tokens"]
|
||||
del ollama_options[
|
||||
"max_tokens"
|
||||
] # To prevent Ollama warning of invalid option provided
|
||||
del ollama_options["max_tokens"]
|
||||
|
||||
# Ollama lacks a "system" prompt option. It has to be provided as a direct parameter, so we copy it down.
|
||||
# Comment: Not sure why this is needed, but we'll keep it for compatibility.
|
||||
if "system" in ollama_options:
|
||||
ollama_payload["system"] = ollama_options["system"]
|
||||
del ollama_options[
|
||||
"system"
|
||||
] # To prevent Ollama warning of invalid option provided
|
||||
del ollama_options["system"]
|
||||
|
||||
# Extract keep_alive from options if it exists
|
||||
if "keep_alive" in ollama_options:
|
||||
ollama_payload["keep_alive"] = ollama_options["keep_alive"]
|
||||
del ollama_options["keep_alive"]
|
||||
ollama_payload["options"] = ollama_options
|
||||
|
||||
# If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options
|
||||
if "stop" in openai_payload:
|
||||
@@ -265,3 +357,32 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
|
||||
ollama_payload["format"] = format
|
||||
|
||||
return ollama_payload
|
||||
|
||||
|
||||
def convert_embedding_payload_openai_to_ollama(openai_payload: dict) -> dict:
|
||||
"""
|
||||
Convert an embeddings request payload from OpenAI format to Ollama format.
|
||||
|
||||
Args:
|
||||
openai_payload (dict): The original payload designed for OpenAI API usage.
|
||||
|
||||
Returns:
|
||||
dict: A payload compatible with the Ollama API embeddings endpoint.
|
||||
"""
|
||||
ollama_payload = {"model": openai_payload.get("model")}
|
||||
input_value = openai_payload.get("input")
|
||||
|
||||
# Ollama expects 'input' as a list, and 'prompt' as a single string.
|
||||
if isinstance(input_value, list):
|
||||
ollama_payload["input"] = input_value
|
||||
ollama_payload["prompt"] = "\n".join(str(x) for x in input_value)
|
||||
else:
|
||||
ollama_payload["input"] = [input_value]
|
||||
ollama_payload["prompt"] = str(input_value)
|
||||
|
||||
# Optionally forward other fields if present
|
||||
for optional_key in ("options", "truncate", "keep_alive"):
|
||||
if optional_key in openai_payload:
|
||||
ollama_payload[optional_key] = openai_payload[optional_key]
|
||||
|
||||
return ollama_payload
|
||||
|
||||
@@ -115,7 +115,7 @@ def load_tool_module_by_id(tool_id, content=None):
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
def load_function_module_by_id(function_id, content=None):
|
||||
def load_function_module_by_id(function_id: str, content: str | None = None):
|
||||
if content is None:
|
||||
function = Functions.get_function_by_id(function_id)
|
||||
if not function:
|
||||
@@ -166,6 +166,62 @@ def load_function_module_by_id(function_id, content=None):
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
def get_function_module_from_cache(request, function_id, load_from_db=True):
|
||||
if load_from_db:
|
||||
# Always load from the database by default
|
||||
# This is useful for hooks like "inlet" or "outlet" where the content might change
|
||||
# and we want to ensure the latest content is used.
|
||||
|
||||
function = Functions.get_function_by_id(function_id)
|
||||
if not function:
|
||||
raise Exception(f"Function not found: {function_id}")
|
||||
content = function.content
|
||||
|
||||
new_content = replace_imports(content)
|
||||
if new_content != content:
|
||||
content = new_content
|
||||
# Update the function content in the database
|
||||
Functions.update_function_by_id(function_id, {"content": content})
|
||||
|
||||
if (
|
||||
hasattr(request.app.state, "FUNCTION_CONTENTS")
|
||||
and function_id in request.app.state.FUNCTION_CONTENTS
|
||||
) and (
|
||||
hasattr(request.app.state, "FUNCTIONS")
|
||||
and function_id in request.app.state.FUNCTIONS
|
||||
):
|
||||
if request.app.state.FUNCTION_CONTENTS[function_id] == content:
|
||||
return request.app.state.FUNCTIONS[function_id], None, None
|
||||
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(
|
||||
function_id, content
|
||||
)
|
||||
else:
|
||||
# Load from cache (e.g. "stream" hook)
|
||||
# This is useful for performance reasons
|
||||
|
||||
if (
|
||||
hasattr(request.app.state, "FUNCTIONS")
|
||||
and function_id in request.app.state.FUNCTIONS
|
||||
):
|
||||
return request.app.state.FUNCTIONS[function_id], None, None
|
||||
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(
|
||||
function_id
|
||||
)
|
||||
|
||||
if not hasattr(request.app.state, "FUNCTIONS"):
|
||||
request.app.state.FUNCTIONS = {}
|
||||
|
||||
if not hasattr(request.app.state, "FUNCTION_CONTENTS"):
|
||||
request.app.state.FUNCTION_CONTENTS = {}
|
||||
|
||||
request.app.state.FUNCTIONS[function_id] = function_module
|
||||
request.app.state.FUNCTION_CONTENTS[function_id] = content
|
||||
|
||||
return function_module, function_type, frontmatter
|
||||
|
||||
|
||||
def install_frontmatter_requirements(requirements: str):
|
||||
if requirements:
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import socketio
|
||||
import redis
|
||||
from redis import asyncio as aioredis
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_redis_service_url(redis_url):
|
||||
@@ -18,23 +17,46 @@ def parse_redis_service_url(redis_url):
|
||||
}
|
||||
|
||||
|
||||
def get_redis_connection(redis_url, redis_sentinels, decode_responses=True):
|
||||
if redis_sentinels:
|
||||
redis_config = parse_redis_service_url(redis_url)
|
||||
sentinel = redis.sentinel.Sentinel(
|
||||
redis_sentinels,
|
||||
port=redis_config["port"],
|
||||
db=redis_config["db"],
|
||||
username=redis_config["username"],
|
||||
password=redis_config["password"],
|
||||
decode_responses=decode_responses,
|
||||
)
|
||||
def get_redis_connection(
|
||||
redis_url, redis_sentinels, async_mode=False, decode_responses=True
|
||||
):
|
||||
if async_mode:
|
||||
import redis.asyncio as redis
|
||||
|
||||
# Get a master connection from Sentinel
|
||||
return sentinel.master_for(redis_config["service"])
|
||||
# If using sentinel in async mode
|
||||
if redis_sentinels:
|
||||
redis_config = parse_redis_service_url(redis_url)
|
||||
sentinel = redis.sentinel.Sentinel(
|
||||
redis_sentinels,
|
||||
port=redis_config["port"],
|
||||
db=redis_config["db"],
|
||||
username=redis_config["username"],
|
||||
password=redis_config["password"],
|
||||
decode_responses=decode_responses,
|
||||
)
|
||||
return sentinel.master_for(redis_config["service"])
|
||||
elif redis_url:
|
||||
return redis.from_url(redis_url, decode_responses=decode_responses)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
# Standard Redis connection
|
||||
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
|
||||
import redis
|
||||
|
||||
if redis_sentinels:
|
||||
redis_config = parse_redis_service_url(redis_url)
|
||||
sentinel = redis.sentinel.Sentinel(
|
||||
redis_sentinels,
|
||||
port=redis_config["port"],
|
||||
db=redis_config["db"],
|
||||
username=redis_config["username"],
|
||||
password=redis_config["password"],
|
||||
decode_responses=decode_responses,
|
||||
)
|
||||
return sentinel.master_for(redis_config["service"])
|
||||
elif redis_url:
|
||||
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
|
||||
|
||||
@@ -83,6 +83,7 @@ def convert_ollama_usage_to_openai(data: dict) -> dict:
|
||||
def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
|
||||
model = ollama_response.get("model", "ollama")
|
||||
message_content = ollama_response.get("message", {}).get("content", "")
|
||||
reasoning_content = ollama_response.get("message", {}).get("thinking", None)
|
||||
tool_calls = ollama_response.get("message", {}).get("tool_calls", None)
|
||||
openai_tool_calls = None
|
||||
|
||||
@@ -94,7 +95,7 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
|
||||
usage = convert_ollama_usage_to_openai(data)
|
||||
|
||||
response = openai_chat_completion_message_template(
|
||||
model, message_content, openai_tool_calls, usage
|
||||
model, message_content, reasoning_content, openai_tool_calls, usage
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -105,6 +106,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
||||
|
||||
model = data.get("model", "ollama")
|
||||
message_content = data.get("message", {}).get("content", None)
|
||||
reasoning_content = data.get("message", {}).get("thinking", None)
|
||||
tool_calls = data.get("message", {}).get("tool_calls", None)
|
||||
openai_tool_calls = None
|
||||
|
||||
@@ -118,10 +120,71 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
||||
usage = convert_ollama_usage_to_openai(data)
|
||||
|
||||
data = openai_chat_chunk_message_template(
|
||||
model, message_content, openai_tool_calls, usage
|
||||
model, message_content, reasoning_content, openai_tool_calls, usage
|
||||
)
|
||||
|
||||
line = f"data: {json.dumps(data)}\n\n"
|
||||
yield line
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def convert_embedding_response_ollama_to_openai(response) -> dict:
|
||||
"""
|
||||
Convert the response from Ollama embeddings endpoint to the OpenAI-compatible format.
|
||||
|
||||
Args:
|
||||
response (dict): The response from the Ollama API,
|
||||
e.g. {"embedding": [...], "model": "..."}
|
||||
or {"embeddings": [{"embedding": [...], "index": 0}, ...], "model": "..."}
|
||||
|
||||
Returns:
|
||||
dict: Response adapted to OpenAI's embeddings API format.
|
||||
e.g. {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [...], "index": 0},
|
||||
...
|
||||
],
|
||||
"model": "...",
|
||||
}
|
||||
"""
|
||||
# Ollama batch-style output
|
||||
if isinstance(response, dict) and "embeddings" in response:
|
||||
openai_data = []
|
||||
for i, emb in enumerate(response["embeddings"]):
|
||||
openai_data.append(
|
||||
{
|
||||
"object": "embedding",
|
||||
"embedding": emb.get("embedding"),
|
||||
"index": emb.get("index", i),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"object": "list",
|
||||
"data": openai_data,
|
||||
"model": response.get("model"),
|
||||
}
|
||||
# Ollama single output
|
||||
elif isinstance(response, dict) and "embedding" in response:
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"object": "embedding",
|
||||
"embedding": response["embedding"],
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"model": response.get("model"),
|
||||
}
|
||||
# Already OpenAI-compatible?
|
||||
elif (
|
||||
isinstance(response, dict)
|
||||
and "data" in response
|
||||
and isinstance(response["data"], list)
|
||||
):
|
||||
return response
|
||||
|
||||
# Fallback: return as is if unrecognized
|
||||
return response
|
||||
|
||||
@@ -22,7 +22,7 @@ def get_task_model_id(
|
||||
# Set the task model
|
||||
task_model_id = default_model_id
|
||||
# Check if the user has a custom task model and use that model
|
||||
if models[task_model_id].get("owned_by") == "ollama":
|
||||
if models[task_model_id].get("connection_type") == "local":
|
||||
if task_model and task_model in models:
|
||||
task_model_id = task_model
|
||||
else:
|
||||
@@ -207,6 +207,24 @@ def title_generation_template(
|
||||
return template
|
||||
|
||||
|
||||
def follow_up_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def tags_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
) -> str:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""OpenTelemetry metrics bootstrap for Open WebUI.
|
||||
|
||||
This module initialises a MeterProvider that sends metrics to an OTLP
|
||||
collector. The collector is responsible for exposing a Prometheus
|
||||
`/metrics` endpoint – WebUI does **not** expose it directly.
|
||||
|
||||
Metrics collected:
|
||||
|
||||
* http.server.requests (counter)
|
||||
* http.server.duration (histogram, milliseconds)
|
||||
|
||||
Attributes used: http.method, http.route, http.status_code
|
||||
|
||||
If you wish to add more attributes (e.g. user-agent) you can, but beware of
|
||||
high-cardinality label sets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Dict, List, Sequence, Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
||||
OTLPMetricExporter,
|
||||
)
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.view import View
|
||||
from opentelemetry.sdk.metrics.export import (
|
||||
PeriodicExportingMetricReader,
|
||||
)
|
||||
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
||||
|
||||
from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
|
||||
|
||||
_EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds
|
||||
|
||||
|
||||
def _build_meter_provider() -> MeterProvider:
|
||||
"""Return a configured MeterProvider."""
|
||||
|
||||
# Periodic reader pushes metrics over OTLP/gRPC to collector
|
||||
readers: List[PeriodicExportingMetricReader] = [
|
||||
PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT),
|
||||
export_interval_millis=_EXPORT_INTERVAL_MILLIS,
|
||||
)
|
||||
]
|
||||
|
||||
# Optional view to limit cardinality: drop user-agent etc.
|
||||
views: List[View] = [
|
||||
View(
|
||||
instrument_name="http.server.duration",
|
||||
attribute_keys=["http.method", "http.route", "http.status_code"],
|
||||
),
|
||||
View(
|
||||
instrument_name="http.server.requests",
|
||||
attribute_keys=["http.method", "http.route", "http.status_code"],
|
||||
),
|
||||
]
|
||||
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({SERVICE_NAME: OTEL_SERVICE_NAME}),
|
||||
metric_readers=list(readers),
|
||||
views=views,
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def setup_metrics(app: FastAPI) -> None:
|
||||
"""Attach OTel metrics middleware to *app* and initialise provider."""
|
||||
|
||||
metrics.set_meter_provider(_build_meter_provider())
|
||||
meter = metrics.get_meter(__name__)
|
||||
|
||||
# Instruments
|
||||
request_counter = meter.create_counter(
|
||||
name="http.server.requests",
|
||||
description="Total HTTP requests",
|
||||
unit="1",
|
||||
)
|
||||
duration_histogram = meter.create_histogram(
|
||||
name="http.server.duration",
|
||||
description="HTTP request duration",
|
||||
unit="ms",
|
||||
)
|
||||
|
||||
# FastAPI middleware
|
||||
@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)
|
||||
|
||||
attrs: Dict[str, str | int] = {
|
||||
"http.method": request.method,
|
||||
"http.route": route_path,
|
||||
"http.status_code": response.status_code,
|
||||
}
|
||||
|
||||
request_counter.add(1, attrs)
|
||||
duration_histogram.record(elapsed_ms, attrs)
|
||||
|
||||
return response
|
||||
@@ -7,7 +7,12 @@ from sqlalchemy import Engine
|
||||
|
||||
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
|
||||
from open_webui.utils.telemetry.instrumentors import Instrumentor
|
||||
from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
from open_webui.utils.telemetry.metrics import setup_metrics
|
||||
from open_webui.env import (
|
||||
OTEL_SERVICE_NAME,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
ENABLE_OTEL_METRICS,
|
||||
)
|
||||
|
||||
|
||||
def setup(app: FastAPI, db_engine: Engine):
|
||||
@@ -21,3 +26,7 @@ def setup(app: FastAPI, db_engine: Engine):
|
||||
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
|
||||
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
|
||||
Instrumentor(app=app, db_engine=db_engine).instrument()
|
||||
|
||||
# set up metrics only if enabled
|
||||
if ENABLE_OTEL_METRICS:
|
||||
setup_metrics(app)
|
||||
|
||||
@@ -37,6 +37,7 @@ from open_webui.models.tools import Tools
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.utils.plugin import load_tool_module_by_id
|
||||
from open_webui.env import (
|
||||
SRC_LOG_LEVELS,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
)
|
||||
@@ -44,6 +45,7 @@ from open_webui.env import (
|
||||
import copy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
|
||||
def get_async_tool_function_and_apply_extra_params(
|
||||
@@ -158,7 +160,7 @@ def get_tools(
|
||||
# TODO: Fix hack for OpenAI API
|
||||
# Some times breaks OpenAI but others don't. Leaving the comment
|
||||
for val in spec.get("parameters", {}).get("properties", {}).values():
|
||||
if val["type"] == "str":
|
||||
if val.get("type") == "str":
|
||||
val["type"] = "string"
|
||||
|
||||
# Remove internal reserved parameters (e.g. __id__, __user__)
|
||||
@@ -477,7 +479,7 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
||||
"specs": convert_openapi_to_tool_payload(res),
|
||||
}
|
||||
|
||||
print("Fetched data:", data)
|
||||
log.info(f"Fetched data: {data}")
|
||||
return data
|
||||
|
||||
|
||||
@@ -488,8 +490,19 @@ async def get_tool_servers_data(
|
||||
server_entries = []
|
||||
for idx, server in enumerate(servers):
|
||||
if server.get("config", {}).get("enable"):
|
||||
url_path = server.get("path", "openapi.json")
|
||||
full_url = f"{server.get('url')}/{url_path}"
|
||||
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
|
||||
openapi_path = server.get("path", "openapi.json")
|
||||
if "://" in openapi_path:
|
||||
# If it contains "://", it's a full URL
|
||||
full_url = openapi_path
|
||||
else:
|
||||
if not openapi_path.startswith("/"):
|
||||
# Ensure the path starts with a slash
|
||||
openapi_path = f"/{openapi_path}"
|
||||
|
||||
full_url = f"{server.get('url')}{openapi_path}"
|
||||
|
||||
info = server.get("info", {})
|
||||
|
||||
auth_type = server.get("auth_type", "bearer")
|
||||
token = None
|
||||
@@ -498,26 +511,37 @@ async def get_tool_servers_data(
|
||||
token = server.get("key", "")
|
||||
elif auth_type == "session":
|
||||
token = session_token
|
||||
server_entries.append((idx, server, full_url, token))
|
||||
server_entries.append((idx, server, full_url, info, token))
|
||||
|
||||
# Create async tasks to fetch data
|
||||
tasks = [get_tool_server_data(token, url) for (_, _, url, token) in server_entries]
|
||||
tasks = [
|
||||
get_tool_server_data(token, url) for (_, _, url, _, token) in server_entries
|
||||
]
|
||||
|
||||
# Execute tasks concurrently
|
||||
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Build final results with index and server metadata
|
||||
results = []
|
||||
for (idx, server, url, _), response in zip(server_entries, responses):
|
||||
for (idx, server, url, info, _), response in zip(server_entries, responses):
|
||||
if isinstance(response, Exception):
|
||||
print(f"Failed to connect to {url} OpenAPI tool server")
|
||||
log.error(f"Failed to connect to {url} OpenAPI tool server")
|
||||
continue
|
||||
|
||||
openapi_data = response.get("openapi", {})
|
||||
|
||||
if info and isinstance(openapi_data, dict):
|
||||
if "name" in info:
|
||||
openapi_data["info"]["title"] = info.get("name", "Tool Server")
|
||||
|
||||
if "description" in info:
|
||||
openapi_data["info"]["description"] = info.get("description", "")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"idx": idx,
|
||||
"url": server.get("url"),
|
||||
"openapi": response.get("openapi"),
|
||||
"openapi": openapi_data,
|
||||
"info": response.get("info"),
|
||||
"specs": response.get("specs"),
|
||||
}
|
||||
@@ -620,5 +644,5 @@ async def execute_tool_server(
|
||||
|
||||
except Exception as err:
|
||||
error = str(err)
|
||||
print("API Request Error:", error)
|
||||
log.exception(f"API Request Error: {error}")
|
||||
return {"error": error}
|
||||
|
||||
+12
-10
@@ -1,5 +1,5 @@
|
||||
fastapi==0.115.7
|
||||
uvicorn[standard]==0.34.0
|
||||
uvicorn[standard]==0.34.2
|
||||
pydantic==2.10.6
|
||||
python-multipart==0.0.20
|
||||
|
||||
@@ -7,15 +7,16 @@ python-socketio==5.13.0
|
||||
python-jose==3.4.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
requests==2.32.3
|
||||
requests==2.32.4
|
||||
aiohttp==3.11.11
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.6.0
|
||||
|
||||
sqlalchemy==2.0.38
|
||||
alembic==1.14.0
|
||||
peewee==3.17.9
|
||||
peewee==3.18.1
|
||||
peewee-migrate==1.12.2
|
||||
psycopg2-binary==2.9.9
|
||||
pgvector==0.4.0
|
||||
@@ -37,7 +38,8 @@ asgiref==3.8.1
|
||||
# AI libraries
|
||||
openai
|
||||
anthropic
|
||||
google-generativeai==0.8.4
|
||||
google-genai==1.15.0
|
||||
google-generativeai==0.8.5
|
||||
tiktoken
|
||||
|
||||
langchain==0.3.24
|
||||
@@ -64,7 +66,7 @@ pypdf==4.3.1
|
||||
fpdf2==2.8.2
|
||||
pymdown-extensions==10.14.2
|
||||
docx2txt==0.8
|
||||
python-pptx==1.0.0
|
||||
python-pptx==1.0.2
|
||||
unstructured==0.16.17
|
||||
nltk==3.9.1
|
||||
Markdown==3.7
|
||||
@@ -73,13 +75,13 @@ pandas==2.2.3
|
||||
openpyxl==3.1.5
|
||||
pyxlsb==1.0.10
|
||||
xlrd==2.0.1
|
||||
validators==0.34.0
|
||||
validators==0.35.0
|
||||
psutil
|
||||
sentencepiece
|
||||
soundfile==0.13.1
|
||||
azure-ai-documentintelligence==1.0.0
|
||||
azure-ai-documentintelligence==1.0.2
|
||||
|
||||
pillow==11.1.0
|
||||
pillow==11.2.1
|
||||
opencv-python-headless==4.11.0.86
|
||||
rapidocr-onnxruntime==1.4.4
|
||||
rank-bm25==0.2.2
|
||||
@@ -93,12 +95,12 @@ authlib==1.4.1
|
||||
|
||||
black==25.1.0
|
||||
langfuse==2.44.0
|
||||
youtube-transcript-api==1.0.3
|
||||
youtube-transcript-api==1.1.0
|
||||
pytube==15.0.0
|
||||
|
||||
extract_msg
|
||||
pydub
|
||||
duckduckgo-search~=8.0.0
|
||||
duckduckgo-search==8.0.2
|
||||
|
||||
## Google Drive
|
||||
google-api-python-client
|
||||
|
||||
+8
-2
@@ -14,7 +14,11 @@ if [[ "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then
|
||||
python -c "import nltk; nltk.download('punkt_tab')"
|
||||
fi
|
||||
|
||||
KEY_FILE=.webui_secret_key
|
||||
if [ -n "${WEBUI_SECRET_KEY_FILE}" ]; then
|
||||
KEY_FILE="${WEBUI_SECRET_KEY_FILE}"
|
||||
else
|
||||
KEY_FILE=".webui_secret_key"
|
||||
fi
|
||||
|
||||
PORT="${PORT:-8080}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
@@ -65,4 +69,6 @@ if [ -n "$SPACE_ID" ]; then
|
||||
export WEBUI_URL=${SPACE_HOST}
|
||||
fi
|
||||
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}"
|
||||
PYTHON_CMD=$(command -v python3 || command -v python)
|
||||
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}"
|
||||
|
||||
@@ -18,6 +18,10 @@ IF /I "%WEB_LOADER_ENGINE%" == "playwright" (
|
||||
)
|
||||
|
||||
SET "KEY_FILE=.webui_secret_key"
|
||||
IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" (
|
||||
SET "KEY_FILE=%WEBUI_SECRET_KEY_FILE%"
|
||||
)
|
||||
|
||||
IF "%PORT%"=="" SET PORT=8080
|
||||
IF "%HOST%"=="" SET HOST=0.0.0.0
|
||||
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
|
||||
CONFIG_FILE_EXTENSIONS = (".json", ".yml", ".yaml", ".ini", ".conf", ".toml")
|
||||
|
||||
|
||||
def is_text_file(filepath):
|
||||
# Check for binary file by scanning for null bytes.
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
chunk = f.read(4096)
|
||||
if b"\0" in chunk:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def should_skip_file(path):
|
||||
base = os.path.basename(path)
|
||||
# Skip dotfiles and dotdirs
|
||||
if base.startswith("."):
|
||||
return True
|
||||
# Skip config files by extension
|
||||
if base.lower().endswith(CONFIG_FILE_EXTENSIONS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_tracked_files():
|
||||
try:
|
||||
output = subprocess.check_output(["git", "ls-files"], text=True)
|
||||
files = output.strip().split("\n")
|
||||
files = [f for f in files if f and os.path.isfile(f)]
|
||||
return files
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: Are you in a git repository?")
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
files = get_tracked_files()
|
||||
email_counter = Counter()
|
||||
total_lines = 0
|
||||
|
||||
for file in files:
|
||||
if should_skip_file(file):
|
||||
continue
|
||||
if not is_text_file(file):
|
||||
continue
|
||||
try:
|
||||
blame = subprocess.check_output(
|
||||
["git", "blame", "-e", file], text=True, errors="replace"
|
||||
)
|
||||
for line in blame.splitlines():
|
||||
# The email always inside <>
|
||||
if "<" in line and ">" in line:
|
||||
try:
|
||||
email = line.split("<")[1].split(">")[0].strip()
|
||||
except Exception:
|
||||
continue
|
||||
email_counter[email] += 1
|
||||
total_lines += 1
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
for email, lines in email_counter.most_common():
|
||||
percent = (lines / total_lines * 100) if total_lines else 0
|
||||
print(f"{email}: {lines}/{total_lines} {percent:.2f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+127
-49
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.15",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
@@ -22,12 +22,16 @@
|
||||
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
||||
"@tiptap/extension-highlight": "^2.10.0",
|
||||
"@tiptap/extension-placeholder": "^2.10.0",
|
||||
"@tiptap/extension-table": "^2.12.0",
|
||||
"@tiptap/extension-table-cell": "^2.12.0",
|
||||
"@tiptap/extension-table-header": "^2.12.0",
|
||||
"@tiptap/extension-table-row": "^2.12.0",
|
||||
"@tiptap/extension-typography": "^2.10.0",
|
||||
"@tiptap/pm": "^2.11.7",
|
||||
"@tiptap/starter-kit": "^2.10.0",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.19.7",
|
||||
"bits-ui": "^0.21.15",
|
||||
"codemirror": "^6.0.1",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"codemirror-lang-hcl": "^0.1.0",
|
||||
@@ -36,6 +40,7 @@
|
||||
"dompurify": "^3.2.5",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"focus-trap": "^7.6.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
"highlight.js": "^11.9.0",
|
||||
"html-entities": "^2.5.3",
|
||||
@@ -46,7 +51,7 @@
|
||||
"idb": "^7.1.1",
|
||||
"js-sha256": "^0.10.1",
|
||||
"jspdf": "^3.0.0",
|
||||
"katex": "^0.16.21",
|
||||
"katex": "^0.16.22",
|
||||
"kokoro-js": "^1.1.1",
|
||||
"marked": "^9.1.0",
|
||||
"mermaid": "^11.6.0",
|
||||
@@ -61,6 +66,7 @@
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"pyodide": "^0.27.3",
|
||||
"socket.io-client": "^4.2.0",
|
||||
@@ -68,6 +74,7 @@
|
||||
"svelte-sonner": "^0.3.19",
|
||||
"tippy.js": "^6.3.7",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.3.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
@@ -1194,26 +1201,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
|
||||
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.1"
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz",
|
||||
"integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
|
||||
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.0.0",
|
||||
"@floating-ui/utils": "^0.2.0"
|
||||
"@floating-ui/core": "^1.7.1",
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
|
||||
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@gulpjs/to-absolute-glob": {
|
||||
"version": "4.0.0",
|
||||
@@ -1743,9 +1753,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.2.tgz",
|
||||
"integrity": "sha512-vo1yOMUt2hzp63IutEaTUxROdvQg1qlMRsbCvbay2AK2Gai7wIgCyK5weEX3nHkiLgo4qCXHijFNC/ILhlRpOQ==",
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
@@ -2025,9 +2036,10 @@
|
||||
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="
|
||||
},
|
||||
"node_modules/@melt-ui/svelte": {
|
||||
"version": "0.76.0",
|
||||
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.0.tgz",
|
||||
"integrity": "sha512-X1ktxKujjLjOBt8LBvfckHGDMrkHWceRt1jdsUTf0EH76ikNPP1ofSoiV0IhlduDoCBV+2YchJ8kXCDfDXfC9Q==",
|
||||
"version": "0.76.2",
|
||||
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz",
|
||||
"integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@floating-ui/dom": "^1.4.5",
|
||||
@@ -2603,11 +2615,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.7.tgz",
|
||||
"integrity": "sha512-BVvNZhx362+l2tSwSuyEUV4h7+jk9raNdoTSdLfwTshXJSaGmYKluGRJznziCI3KX02Z19DdsQrdfrpXAU3Hfg==",
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/container-queries": {
|
||||
@@ -3172,6 +3185,59 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.12.0.tgz",
|
||||
"integrity": "sha512-tT3IbbBal0vPQ1Bc/3Xl+tmqqZQCYWxnycBPl/WZBqhd57DWzfJqRPESwCGUIJgjOtTnipy/ulvj0FxHi1j9JA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.12.0.tgz",
|
||||
"integrity": "sha512-8i35uCkmkSiQxMiZ+DLgT/wj24P5U/Zo3jr1e0tMAAMG7sRO1MljjLmkpV8WCdBo0xoRqzkz4J7Nkq+DtzZv9Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.12.0.tgz",
|
||||
"integrity": "sha512-gRKEsy13KKLpg9RxyPeUGqh4BRFSJ2Bc2KQP1ldhef6CPRYHCbGycxXCVQ5aAb7Mhpo54L+AAkmAv1iMHUTflw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.12.0.tgz",
|
||||
"integrity": "sha512-AEW/Zl9V0IoaYDBLMhF5lVl0xgoIJs3IuKCsIYxGDlxBfTVFC6PfQzvuy296CMjO5ZcZ0xalVipPV9ggsMRD+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
|
||||
@@ -4321,16 +4387,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "0.19.7",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.19.7.tgz",
|
||||
"integrity": "sha512-GHUpKvN7QyazhnZNkUy0lxg6W1M6KJHWSZ4a/UGCjPE6nQgk6vKbGysY67PkDtQMknZTZAzVoMj1Eic4IKeCRQ==",
|
||||
"version": "0.21.15",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.15.tgz",
|
||||
"integrity": "sha512-+m5WSpJnFdCcNdXSTIVC1WYBozipO03qRh03GFWgrdxoHiolCfwW71EYG4LPCWYPG6KcTZV0Cj6iHSiZ7cdKdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.5.1",
|
||||
"@melt-ui/svelte": "0.76.0",
|
||||
"@melt-ui/svelte": "0.76.2",
|
||||
"nanoid": "^5.0.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/huntabyte"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^4.0.0"
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.118"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
@@ -6801,9 +6871,10 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz",
|
||||
"integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==",
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz",
|
||||
"integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.2.0"
|
||||
}
|
||||
@@ -7869,9 +7940,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.21",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz",
|
||||
"integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==",
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
"integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
@@ -9807,16 +9878,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.4.tgz",
|
||||
"integrity": "sha512-TkDY3Gw52gRFRfRn2f4wJv5WOgAOXLJA2CQJYIJ5+kdFbfj3acR4JUW6LX2e1hiEBiUwvEhzH5a3cZ5YSztpIA==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz",
|
||||
"integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.37.2"
|
||||
"prosemirror-transform": "^1.10.3",
|
||||
"prosemirror-view": "^1.39.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node": {
|
||||
@@ -9835,9 +9906,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz",
|
||||
"integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==",
|
||||
"version": "1.10.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz",
|
||||
"integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
@@ -11781,9 +11852,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
@@ -11806,6 +11878,12 @@
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown-plugin-gfm": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
|
||||
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
|
||||
+11
-3
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
|
||||
"build": "npm run pyodide:fetch && vite build",
|
||||
"build:watch": "npm run pyodide:fetch && vite build --watch",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -65,12 +66,16 @@
|
||||
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
||||
"@tiptap/extension-highlight": "^2.10.0",
|
||||
"@tiptap/extension-placeholder": "^2.10.0",
|
||||
"@tiptap/extension-table": "^2.12.0",
|
||||
"@tiptap/extension-table-cell": "^2.12.0",
|
||||
"@tiptap/extension-table-header": "^2.12.0",
|
||||
"@tiptap/extension-table-row": "^2.12.0",
|
||||
"@tiptap/extension-typography": "^2.10.0",
|
||||
"@tiptap/pm": "^2.11.7",
|
||||
"@tiptap/starter-kit": "^2.10.0",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.19.7",
|
||||
"bits-ui": "^0.21.15",
|
||||
"codemirror": "^6.0.1",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"codemirror-lang-hcl": "^0.1.0",
|
||||
@@ -79,6 +84,7 @@
|
||||
"dompurify": "^3.2.5",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"focus-trap": "^7.6.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
"highlight.js": "^11.9.0",
|
||||
"html-entities": "^2.5.3",
|
||||
@@ -89,7 +95,7 @@
|
||||
"idb": "^7.1.1",
|
||||
"js-sha256": "^0.10.1",
|
||||
"jspdf": "^3.0.0",
|
||||
"katex": "^0.16.21",
|
||||
"katex": "^0.16.22",
|
||||
"kokoro-js": "^1.1.1",
|
||||
"marked": "^9.1.0",
|
||||
"mermaid": "^11.6.0",
|
||||
@@ -104,6 +110,7 @@
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"pyodide": "^0.27.3",
|
||||
"socket.io-client": "^4.2.0",
|
||||
@@ -111,6 +118,7 @@
|
||||
"svelte-sonner": "^0.3.19",
|
||||
"tippy.js": "^6.3.7",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.3.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
|
||||
+12
-10
@@ -7,7 +7,7 @@ authors = [
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastapi==0.115.7",
|
||||
"uvicorn[standard]==0.34.0",
|
||||
"uvicorn[standard]==0.34.2",
|
||||
"pydantic==2.10.6",
|
||||
"python-multipart==0.0.20",
|
||||
|
||||
@@ -15,15 +15,16 @@ dependencies = [
|
||||
"python-jose==3.4.0",
|
||||
"passlib[bcrypt]==1.7.4",
|
||||
|
||||
"requests==2.32.3",
|
||||
"requests==2.32.4",
|
||||
"aiohttp==3.11.11",
|
||||
"async-timeout",
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
"starlette-compress==1.6.0",
|
||||
|
||||
"sqlalchemy==2.0.38",
|
||||
"alembic==1.14.0",
|
||||
"peewee==3.17.9",
|
||||
"peewee==3.18.1",
|
||||
"peewee-migrate==1.12.2",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pgvector==0.4.0",
|
||||
@@ -45,7 +46,8 @@ dependencies = [
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-generativeai==0.8.4",
|
||||
"google-genai==1.15.0",
|
||||
"google-generativeai==0.8.5",
|
||||
"tiktoken",
|
||||
|
||||
"langchain==0.3.24",
|
||||
@@ -71,7 +73,7 @@ dependencies = [
|
||||
"fpdf2==2.8.2",
|
||||
"pymdown-extensions==10.14.2",
|
||||
"docx2txt==0.8",
|
||||
"python-pptx==1.0.0",
|
||||
"python-pptx==1.0.2",
|
||||
"unstructured==0.16.17",
|
||||
"nltk==3.9.1",
|
||||
"Markdown==3.7",
|
||||
@@ -80,13 +82,13 @@ dependencies = [
|
||||
"openpyxl==3.1.5",
|
||||
"pyxlsb==1.0.10",
|
||||
"xlrd==2.0.1",
|
||||
"validators==0.34.0",
|
||||
"validators==0.35.0",
|
||||
"psutil",
|
||||
"sentencepiece",
|
||||
"soundfile==0.13.1",
|
||||
"azure-ai-documentintelligence==1.0.0",
|
||||
"azure-ai-documentintelligence==1.0.2",
|
||||
|
||||
"pillow==11.1.0",
|
||||
"pillow==11.2.1",
|
||||
"opencv-python-headless==4.11.0.86",
|
||||
"rapidocr-onnxruntime==1.4.4",
|
||||
"rank-bm25==0.2.2",
|
||||
@@ -100,12 +102,12 @@ dependencies = [
|
||||
|
||||
"black==25.1.0",
|
||||
"langfuse==2.44.0",
|
||||
"youtube-transcript-api==1.0.3",
|
||||
"youtube-transcript-api==1.1.0",
|
||||
"pytube==15.0.0",
|
||||
|
||||
"extract_msg",
|
||||
"pydub",
|
||||
"duckduckgo-search~=8.0.0",
|
||||
"duckduckgo-search==8.0.2",
|
||||
|
||||
"google-api-python-client",
|
||||
"google-auth-httplib2",
|
||||
|
||||
@@ -12,7 +12,8 @@ const packages = [
|
||||
'sympy',
|
||||
'tiktoken',
|
||||
'seaborn',
|
||||
'pytz'
|
||||
'pytz',
|
||||
'black'
|
||||
];
|
||||
|
||||
import { loadPyodide } from 'pyodide';
|
||||
|
||||
+49
-5
@@ -24,6 +24,12 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/assets/fonts/Vazirmatn-Variable.ttf');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
html {
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -38,6 +44,10 @@ code {
|
||||
font-family: 'InstrumentSerif', sans-serif;
|
||||
}
|
||||
|
||||
.marked a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
math {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -75,7 +85,7 @@ textarea::placeholder {
|
||||
}
|
||||
|
||||
.font-primary {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
font-family: 'Archivo', 'Vazirmatn', sans-serif;
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
@@ -97,7 +107,7 @@ li p {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
background-color: rgba(236, 236, 236, 0.8);
|
||||
background-color: rgba(215, 215, 215, 0.8);
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
@@ -105,12 +115,12 @@ li p {
|
||||
|
||||
/* Dark theme scrollbar styles */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(42, 42, 42, 0.8); /* Darker color for dark theme */
|
||||
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
|
||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.8rem;
|
||||
height: 0.6rem;
|
||||
width: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -308,12 +318,20 @@ input[type='number'] {
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
/* Below color is from tailwind, and has the proper contrast
|
||||
text-gray-600 from: https://tailwindcss.com/docs/color */
|
||||
color: #676767;
|
||||
pointer-events: none;
|
||||
|
||||
@apply line-clamp-1 absolute;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-autocompletion::after {
|
||||
color: #a0a0a0;
|
||||
|
||||
@@ -398,3 +416,29 @@ input[type='number'] {
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table styling for tiptap editors */
|
||||
.tiptap table {
|
||||
@apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full;
|
||||
}
|
||||
|
||||
.tiptap thead {
|
||||
@apply text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none;
|
||||
}
|
||||
|
||||
.tiptap th,
|
||||
.tiptap td {
|
||||
@apply px-3 py-1.5 border border-gray-100 dark:border-gray-850;
|
||||
}
|
||||
|
||||
.tiptap th {
|
||||
@apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
|
||||
}
|
||||
|
||||
.tiptap td {
|
||||
@apply text-gray-900 dark:text-white w-max;
|
||||
}
|
||||
|
||||
.tiptap tr {
|
||||
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
href="/opensearch.xml"
|
||||
/>
|
||||
<script src="/static/loader.js" defer></script>
|
||||
<link rel="stylesheet" href="/static/custom.css" />
|
||||
|
||||
<script>
|
||||
function resizeIframe(obj) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const getAudioConfig = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -64,9 +64,12 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm
|
||||
return res;
|
||||
};
|
||||
|
||||
export const transcribeAudio = async (token: string, file: File) => {
|
||||
export const transcribeAudio = async (token: string, file: File, language?: string) => {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
if (language) {
|
||||
data.append('language', language);
|
||||
}
|
||||
|
||||
let error = null;
|
||||
const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, {
|
||||
@@ -83,7 +86,7 @@ export const transcribeAudio = async (token: string, file: File) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -120,7 +123,7 @@ export const synthesizeOpenAISpeech = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -152,7 +155,7 @@ export const getModels = async (token: string = ''): Promise<AvailableModelsResp
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -180,7 +183,7 @@ export const getVoices = async (token: string = '') => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
+26
-25
@@ -15,7 +15,7 @@ export const getAdminDetails = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export const getAdminConfig = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -70,7 +70,7 @@ export const updateAdminConfig = async (token: string, body: object) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -98,7 +98,7 @@ export const getSessionUser = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -129,7 +129,7 @@ export const ldapUserSignIn = async (user: string, password: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
error = err.detail;
|
||||
return null;
|
||||
@@ -157,7 +157,7 @@ export const getLdapConfig = async (token: string = '') => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -187,7 +187,7 @@ export const updateLdapConfig = async (token: string = '', enable_ldap: boolean)
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -214,7 +214,7 @@ export const getLdapServer = async (token: string = '') => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -242,7 +242,7 @@ export const updateLdapServer = async (token: string = '', body: object) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -273,7 +273,7 @@ export const userSignIn = async (email: string, password: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
error = err.detail;
|
||||
return null;
|
||||
@@ -312,7 +312,7 @@ export const userSignUp = async (
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -336,10 +336,10 @@ export const userSignOut = async () => {
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res;
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -347,6 +347,7 @@ export const userSignOut = async () => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
export const addUser = async (
|
||||
@@ -378,7 +379,7 @@ export const addUser = async (
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -409,7 +410,7 @@ export const updateUserProfile = async (token: string, name: string, profileImag
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -440,7 +441,7 @@ export const updateUserPassword = async (token: string, password: string, newPas
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -467,7 +468,7 @@ export const getSignUpEnabledStatus = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -494,7 +495,7 @@ export const getDefaultUserRole = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -524,7 +525,7 @@ export const updateDefaultUserRole = async (token: string, role: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -551,7 +552,7 @@ export const toggleSignUpEnabledStatus = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -578,7 +579,7 @@ export const getJWTExpiresDuration = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -608,7 +609,7 @@ export const updateJWTExpiresDuration = async (token: string, duration: string)
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -635,7 +636,7 @@ export const createAPIKey = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -660,7 +661,7 @@ export const getAPIKey = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -685,7 +686,7 @@ export const deleteAPIKey = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export const createNewChannel = async (token: string = '', channel: ChannelForm)
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export const getChannels = async (token: string = '') => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getChannelById = async (token: string = '', channel_id: string) =>
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export const updateChannelById = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -157,7 +157,7 @@ export const deleteChannelById = async (token: string = '', channel_id: string)
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ export const getChannelMessages = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ export const getChannelThreadMessages = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -275,7 +275,7 @@ export const sendMessage = async (token: string = '', channel_id: string, messag
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -315,7 +315,7 @@ export const updateMessage = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -355,7 +355,7 @@ export const addReaction = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -395,7 +395,7 @@ export const removeReaction = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -429,7 +429,7 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
+101
-63
@@ -21,7 +21,7 @@ export const createNewChat = async (token: string, chat: object) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export const importChat = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ export const getChatList = async (token: string = '', page: number | null = null
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -111,10 +111,79 @@ export const getChatList = async (token: string = '', page: number | null = null
|
||||
}));
|
||||
};
|
||||
|
||||
export const getChatListByUserId = async (token: string = '', userId: string) => {
|
||||
export const getChatListByUserId = async (
|
||||
token: string = '',
|
||||
userId: string,
|
||||
page: number = 1,
|
||||
filter?: object
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/list/user/${userId}`, {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.append('page', `${page}`);
|
||||
|
||||
if (filter) {
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/chats/list/user/${userId}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.map((chat) => ({
|
||||
...chat,
|
||||
time_range: getTimeRange(chat.updated_at)
|
||||
}));
|
||||
};
|
||||
|
||||
export const getArchivedChatList = async (
|
||||
token: string = '',
|
||||
page: number = 1,
|
||||
filter?: object
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('page', `${page}`);
|
||||
|
||||
if (filter) {
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -131,7 +200,7 @@ export const getChatListByUserId = async (token: string = '', userId: string) =>
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -145,37 +214,6 @@ export const getChatListByUserId = async (token: string = '', userId: string) =>
|
||||
}));
|
||||
};
|
||||
|
||||
export const getArchivedChatList = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
@@ -196,7 +234,7 @@ export const getAllChats = async (token: string) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -231,7 +269,7 @@ export const getChatListBySearchText = async (token: string, text: string, page:
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -265,7 +303,7 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -296,7 +334,7 @@ export const getAllArchivedChats = async (token: string) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -327,7 +365,7 @@ export const getAllUserChats = async (token: string) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -358,7 +396,7 @@ export const getAllTags = async (token: string) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -389,7 +427,7 @@ export const getPinnedChatList = async (token: string = '') => {
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -426,7 +464,7 @@ export const getChatListByTagName = async (token: string = '', tagName: string)
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -461,7 +499,7 @@ export const getChatById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -493,7 +531,7 @@ export const getChatByShareId = async (token: string, share_id: string) => {
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -531,7 +569,7 @@ export const getChatPinnedStatusById = async (token: string, id: string) => {
|
||||
error = err;
|
||||
}
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -569,7 +607,7 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => {
|
||||
error = err;
|
||||
}
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -610,7 +648,7 @@ export const cloneChatById = async (token: string, id: string, title?: string) =
|
||||
error = err;
|
||||
}
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -648,7 +686,7 @@ export const cloneSharedChatById = async (token: string, id: string) => {
|
||||
error = err;
|
||||
}
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -680,7 +718,7 @@ export const shareChatById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -715,7 +753,7 @@ export const updateChatFolderIdById = async (token: string, id: string, folderId
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -747,7 +785,7 @@ export const archiveChatById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -779,7 +817,7 @@ export const deleteSharedChatById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -814,7 +852,7 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -846,7 +884,7 @@ export const deleteChatById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -878,7 +916,7 @@ export const getTagsById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -912,7 +950,7 @@ export const addTagById = async (token: string, id: string, tagName: string) =>
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -947,7 +985,7 @@ export const deleteTagById = async (token: string, id: string, tagName: string)
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -978,7 +1016,7 @@ export const deleteTagsById = async (token: string, id: string) => {
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -1010,7 +1048,7 @@ export const deleteAllChats = async (token: string) => {
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -1042,7 +1080,7 @@ export const archiveAllChats = async (token: string) => {
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const importConfig = async (token: string, config) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export const exportConfig = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -73,7 +73,7 @@ export const getDirectConnectionsConfig = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -103,7 +103,7 @@ export const setDirectConnectionsConfig = async (token: string, config: object)
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -130,7 +130,7 @@ export const getToolServerConnections = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -160,7 +160,7 @@ export const setToolServerConnections = async (token: string, connections: objec
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export const verifyToolServerConnection = async (token: string, connection: obje
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -217,7 +217,7 @@ export const getCodeExecutionConfig = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -247,7 +247,7 @@ export const setCodeExecutionConfig = async (token: string, config: object) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -274,7 +274,7 @@ export const getModelsConfig = async (token: string) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -304,7 +304,7 @@ export const setModelsConfig = async (token: string, config: object) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -334,7 +334,7 @@ export const setDefaultPromptSuggestions = async (token: string, promptSuggestio
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -361,7 +361,7 @@ export const getBanners = async (token: string): Promise<Banner[]> => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
@@ -391,7 +391,7 @@ export const setBanners = async (token: string, banners: Banner[]) => {
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user