Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2777bab148 | ||
|
|
4ec70e3d46 | ||
|
|
43b2eca418 | ||
|
|
99f7d1b62b | ||
|
|
62a5db7719 | ||
|
|
278a4edd0b | ||
|
|
279e3e970f | ||
|
|
88a5b5ff4c | ||
|
|
c159a1a120 | ||
|
|
1decefbe59 | ||
|
|
28157b4f8c | ||
|
|
e013df19f9 | ||
|
|
486f953ba2 | ||
|
|
407dc9a401 | ||
|
|
9fb2c9c86d | ||
|
|
60b8cfb9fa | ||
|
|
5a66f69460 | ||
|
|
578f116ebb | ||
|
|
d3b5ba8472 | ||
|
|
3e28c46026 | ||
|
|
71d7ba6c07 | ||
|
|
581c0f137e | ||
|
|
f2e78d7940 | ||
|
|
c821c3ecb0 | ||
|
|
b3de3295d6 | ||
|
|
0952772705 | ||
|
|
c0189e43ad | ||
|
|
8fad4fb264 | ||
|
|
e9cab818f8 | ||
|
|
3523cea8cc | ||
|
|
01002042a8 | ||
|
|
f81f3b3a02 | ||
|
|
99ee1a2702 | ||
|
|
7ede54ee0a | ||
|
|
e96d139180 | ||
|
|
e6da38464b | ||
|
|
1e9abe6d8c | ||
|
|
61af00d0a6 | ||
|
|
bb6864dd12 | ||
|
|
1404fd9fbd | ||
|
|
02479425a5 | ||
|
|
cd762db9bf | ||
|
|
6663fc3a6c | ||
|
|
67db71f140 | ||
|
|
aa94359f74 | ||
|
|
9726c0b7ee | ||
|
|
449f0c155b | ||
|
|
48f0992300 | ||
|
|
492bcf9d26 | ||
|
|
bbfe456f90 | ||
|
|
774140ae0f | ||
|
|
5590ad438f | ||
|
|
9eea1aff10 | ||
|
|
1a15a62b73 | ||
|
|
efcf6db687 | ||
|
|
66f00ce4bf | ||
|
|
d720e027ad | ||
|
|
f46837fd10 | ||
|
|
1477f14827 | ||
|
|
094a16ab49 | ||
|
|
86011e40be | ||
|
|
4451f86eb0 | ||
|
|
8f51c11bc2 | ||
|
|
01f438c3fe | ||
|
|
1f91e5d3c6 | ||
|
|
faf01bdeac | ||
|
|
bb21bf5239 | ||
|
|
919d65f36f | ||
|
|
19e82ace23 | ||
|
|
98159dbcae | ||
|
|
ba972ecd52 | ||
|
|
d2d23f4abd | ||
|
|
45180e829a | ||
|
|
cd02ff2e07 | ||
|
|
39fe385017 | ||
|
|
7452b87877 | ||
|
|
45d9a720b9 | ||
|
|
7fcc545672 | ||
|
|
5e1f4fa0ff | ||
|
|
efebf4d3a0 | ||
|
|
e304ca5bd5 | ||
|
|
8eb5807c5f | ||
|
|
7670543a39 | ||
|
|
ed926c8f26 | ||
|
|
b3a95f40fc | ||
|
|
96643f5b6d | ||
|
|
575db66295 | ||
|
|
47ec443728 | ||
|
|
d6f709574e | ||
|
|
4bc77b544e | ||
|
|
be93081bd7 | ||
|
|
70d0477418 | ||
|
|
f592748011 | ||
|
|
8a157578f4 | ||
|
|
8a1f321fd4 | ||
|
|
f97f21bf3a | ||
|
|
a651598d19 | ||
|
|
68d42ef850 | ||
|
|
979ec71522 | ||
|
|
6d0f757848 | ||
|
|
dc9445c4ec | ||
|
|
901c997054 | ||
|
|
99b88c3024 | ||
|
|
094a82b264 | ||
|
|
99604fd644 | ||
|
|
33ecc405cb | ||
|
|
ad49435a94 | ||
|
|
282a884fad | ||
|
|
d7363fd65f | ||
|
|
f23eb2a31c | ||
|
|
ccd2a0be5b | ||
|
|
47560d4d72 | ||
|
|
7f0c50f445 | ||
|
|
bed6aa63e1 | ||
|
|
6a109e972e | ||
|
|
2387877dae | ||
|
|
40f60c163d | ||
|
|
28c1de237b | ||
|
|
04bd09d027 | ||
|
|
35e8c77025 | ||
|
|
ffd9e9cbe7 | ||
|
|
8f5843c8bc | ||
|
|
397421ce0b | ||
|
|
0dc6220ae6 | ||
|
|
8ae99ad7bb | ||
|
|
fe4b7621b3 | ||
|
|
3da22af859 | ||
|
|
cb1445086e | ||
|
|
9ba424e18a | ||
|
|
dfc9412117 | ||
|
|
43aa23ea77 | ||
|
|
286896b941 | ||
|
|
b810868239 | ||
|
|
0b59aa940e | ||
|
|
f1c28455ad | ||
|
|
daa4b3284f | ||
|
|
72023f4d03 | ||
|
|
313c54a4d1 | ||
|
|
c82183f985 | ||
|
|
84fe62d557 | ||
|
|
43234c3c4d | ||
|
|
9e2d8470d4 | ||
|
|
057533f9cc | ||
|
|
e277dba8fe | ||
|
|
4525ac687b | ||
|
|
53f1caf91f | ||
|
|
7dd5c957cf | ||
|
|
a1e62ab422 | ||
|
|
1a42e96a3b | ||
|
|
13fe78428b | ||
|
|
50705f926e | ||
|
|
709258bbb5 | ||
|
|
d303f0351d | ||
|
|
82ed9b0a97 | ||
|
|
e13f1738d4 | ||
|
|
b43acc2b3d | ||
|
|
3e8d3b08fa | ||
|
|
c134de8799 | ||
|
|
ad98d4300b | ||
|
|
115231c0e5 | ||
|
|
72168b1586 | ||
|
|
93205d4320 | ||
|
|
64b6ac4c1f | ||
|
|
28faaa23c4 | ||
|
|
2ed9896dea | ||
|
|
84fd0e4152 | ||
|
|
7fb0228ac8 | ||
|
|
0845b7ffe5 | ||
|
|
55b6a82cd5 | ||
|
|
ff55ca4d75 | ||
|
|
2011e5711d | ||
|
|
e7d9755d97 | ||
|
|
20101579e0 | ||
|
|
8a745b9bbf | ||
|
|
2cb87a71f7 | ||
|
|
86a3e0a0b8 | ||
|
|
37b8ea3ab2 | ||
|
|
4cca1829c2 | ||
|
|
230e21b10c | ||
|
|
dd3cd4ac4f | ||
|
|
f0c2d47b24 | ||
|
|
5fa502e419 | ||
|
|
5a2fafceae | ||
|
|
b5ab9634e8 | ||
|
|
e289e02193 | ||
|
|
e966f54001 | ||
|
|
c11f02662f | ||
|
|
23575825a1 | ||
|
|
1e67035bd3 | ||
|
|
85077d198b | ||
|
|
7f17b31c12 | ||
|
|
01e54dae94 | ||
|
|
47cde38369 | ||
|
|
4616997e6a | ||
|
|
1aba7accbf | ||
|
|
32753326fa | ||
|
|
966e244e33 | ||
|
|
0c5950fb60 | ||
|
|
1d3c656286 | ||
|
|
74b1c80132 | ||
|
|
7aa41d5d0c | ||
|
|
4c30f22444 | ||
|
|
6af94ae8f3 | ||
|
|
2625cd8719 | ||
|
|
f91da291d9 | ||
|
|
5543f30c49 | ||
|
|
06666cb148 | ||
|
|
df314fda1d | ||
|
|
f758bf74c2 | ||
|
|
357b57e1d6 | ||
|
|
a10e038fff | ||
|
|
d8c4dd6f79 | ||
|
|
7ace997c53 | ||
|
|
62506b1955 | ||
|
|
53c9c48387 | ||
|
|
2df4e7207b | ||
|
|
f890fe6901 | ||
|
|
438e5d966f | ||
|
|
17cc3b7d72 | ||
|
|
d89c5b6c30 | ||
|
|
8fd5006b6f | ||
|
|
97448e25ec | ||
|
|
8916a284bd | ||
|
|
8e2aaaf94f | ||
|
|
e15f8b8baf | ||
|
|
890691319f | ||
|
|
21094ca88b | ||
|
|
9aea18f90c | ||
|
|
6202e09348 | ||
|
|
29ba56757d | ||
|
|
fbf5f2bb67 | ||
|
|
1134512b22 | ||
|
|
8424acef4b | ||
|
|
bc9b51402f | ||
|
|
1623396086 | ||
|
|
29447366d1 | ||
|
|
5941713b0c | ||
|
|
32a022a823 | ||
|
|
497471d16b | ||
|
|
fbb8c111ed | ||
|
|
059cc636f6 | ||
|
|
4df4091899 | ||
|
|
c7753f2d9b | ||
|
|
7bd7559bfe | ||
|
|
635cb8e3ff | ||
|
|
b23abcbfe5 | ||
|
|
30d0f8b1f6 | ||
|
|
b581536a66 | ||
|
|
77dcfb5062 | ||
|
|
b82324b7b7 | ||
|
|
e33fb2c751 | ||
|
|
1416387af1 | ||
|
|
f436f2c106 | ||
|
|
5229459c02 | ||
|
|
662f3cd1b3 | ||
|
|
ee60c3e92a | ||
|
|
58a6f5df98 | ||
|
|
50f6db5ae2 | ||
|
|
dc453efa5c | ||
|
|
ac3c271c9f | ||
|
|
397c1e7684 | ||
|
|
c8966b92f1 | ||
|
|
c3ca241e57 | ||
|
|
f9d829c1cd | ||
|
|
0a261b7401 | ||
|
|
d5ad0ae862 | ||
|
|
cd778582d5 | ||
|
|
25cc8049c8 | ||
|
|
c8a02bf8a8 | ||
|
|
e2572dca43 | ||
|
|
c4e0051ab2 | ||
|
|
3f35ba27fc | ||
|
|
2fa2728dbc | ||
|
|
57c082c155 | ||
|
|
86e0898920 | ||
|
|
2c3655a969 | ||
|
|
bfd8c6b87e | ||
|
|
0af66558ae | ||
|
|
9a145c591f | ||
|
|
d7e9b0828b | ||
|
|
e9c93d082e | ||
|
|
3f7d3def02 | ||
|
|
6497b46a78 | ||
|
|
bd2819a96a | ||
|
|
d8c76e9a0c | ||
|
|
b1d5e3b687 | ||
|
|
50181ee99e | ||
|
|
eac6f6be0a | ||
|
|
72f75afb33 | ||
|
|
ca9a545a50 | ||
|
|
3395a25222 | ||
|
|
9b5db894d4 | ||
|
|
c77b36bdcc | ||
|
|
77189664c2 | ||
|
|
f85aaa4ed9 | ||
|
|
86fa564b44 | ||
|
|
53425ffadb | ||
|
|
17ca02aa6b | ||
|
|
4e9c75be50 | ||
|
|
a068ba88de | ||
|
|
1a93891d97 | ||
|
|
8af9ad3f30 | ||
|
|
e2b2e8b245 | ||
|
|
e9c2d9d035 | ||
|
|
98f38f00ff | ||
|
|
88abd01b87 | ||
|
|
3a9601c053 | ||
|
|
4485c7a5d2 | ||
|
|
caf8482fba | ||
|
|
f4d2c6027a | ||
|
|
78cf70faec | ||
|
|
4b41cd1891 | ||
|
|
92f27bb21e | ||
|
|
17084f629c | ||
|
|
c1d566bad4 | ||
|
|
e8cb57750b | ||
|
|
736b29ddca | ||
|
|
f923a85f40 | ||
|
|
8714df17dd | ||
|
|
9fbc76f4bb | ||
|
|
ea215aa8c2 | ||
|
|
c69f2cc776 | ||
|
|
0c40d93da4 | ||
|
|
451b614f4d | ||
|
|
34689e37f8 | ||
|
|
95f9d69e9a | ||
|
|
a5683c411a | ||
|
|
c42c1ee632 | ||
|
|
69f6fbe4e7 | ||
|
|
7619f449c8 | ||
|
|
d2f2d42e09 | ||
|
|
5d7e8c8e5f | ||
|
|
2ae7584686 | ||
|
|
9af64f0db1 | ||
|
|
3f85375db2 | ||
|
|
2bccc7b8fc | ||
|
|
9979ed4b46 | ||
|
|
151c072bbc | ||
|
|
81fed7291c | ||
|
|
f8e8d7e048 | ||
|
|
056431606b | ||
|
|
ff9f2614f3 | ||
|
|
c92d1ce219 | ||
|
|
4ba96fe3e5 | ||
|
|
04f37ca279 | ||
|
|
1fc40ad5ee | ||
|
|
19bc5f1c34 | ||
|
|
bef5e6bb29 | ||
|
|
8b489cb31f | ||
|
|
601f1af0a5 | ||
|
|
67dcef5e9a | ||
|
|
d2c7a60a43 | ||
|
|
961df8ef3d | ||
|
|
d6326549d6 | ||
|
|
b084613004 | ||
|
|
e2ce735d12 | ||
|
|
867a86b264 | ||
|
|
0b68fc0a1b | ||
|
|
d9f46baa70 | ||
|
|
e6726d8444 | ||
|
|
55248bed2d | ||
|
|
8d6c1c497f | ||
|
|
30540cb40e | ||
|
|
761d439830 | ||
|
|
7611441676 | ||
|
|
2bfe684716 | ||
|
|
07b16cc8d1 | ||
|
|
2eef60ced4 | ||
|
|
85a3c32f07 | ||
|
|
ca213cbd12 | ||
|
|
13682e28db | ||
|
|
db7ea6d23f | ||
|
|
4a57e581d9 | ||
|
|
2e733b46b0 | ||
|
|
5d670cb7ce | ||
|
|
cce1f876c9 | ||
|
|
f47befa902 | ||
|
|
73c92e2e3e | ||
|
|
6598812a25 | ||
|
|
353ecda084 | ||
|
|
4797799400 | ||
|
|
93a91a678a | ||
|
|
e16251c473 | ||
|
|
07aafa5fea | ||
|
|
0fd5881b7d | ||
|
|
fa04711680 | ||
|
|
d2fa66bb9d | ||
|
|
8b9c5c4c1e | ||
|
|
0067cf6eff | ||
|
|
0912a023c2 | ||
|
|
041da26756 | ||
|
|
001d07f590 | ||
|
|
d1a654019b | ||
|
|
aae6008542 | ||
|
|
b9885529a5 | ||
|
|
8eee4d1c12 | ||
|
|
8e25eb104b | ||
|
|
91952096c2 | ||
|
|
80dbd76d92 | ||
|
|
2f349b5979 | ||
|
|
f2cae3d0a7 | ||
|
|
4cc2c1915e | ||
|
|
52b2158c67 | ||
|
|
70eb83b701 | ||
|
|
1ba8c3389e | ||
|
|
52ef3e74e6 | ||
|
|
9df790a9f9 | ||
|
|
ba1e32f60d | ||
|
|
5d4199bf52 | ||
|
|
428db553e2 | ||
|
|
7fffecd168 | ||
|
|
6c06024cf9 | ||
|
|
f0e1c3f540 | ||
|
|
502a36869e | ||
|
|
5ae0036265 | ||
|
|
3a1bbcb12e | ||
|
|
2e36540023 | ||
|
|
e9fb161bcd | ||
|
|
bfa42c6277 | ||
|
|
0acd78049d | ||
|
|
4079ac55d8 | ||
|
|
55ad48d1c3 | ||
|
|
7c4550fa92 | ||
|
|
182192f3c9 | ||
|
|
d0657054a5 | ||
|
|
8b46a9010e | ||
|
|
f24b76d9a3 | ||
|
|
bd570b7e2e | ||
|
|
53a328d00b | ||
|
|
f81964b412 | ||
|
|
40c525b7b9 | ||
|
|
f3b0f7d358 | ||
|
|
3494fa8707 | ||
|
|
e2b722deb2 | ||
|
|
ae2a746d0c | ||
|
|
797822a3a3 | ||
|
|
66341124a6 | ||
|
|
0b627248f5 | ||
|
|
985e54b662 | ||
|
|
f41a8e67e8 | ||
|
|
3f3a69568f | ||
|
|
ae2de6b09c | ||
|
|
173fa62af3 | ||
|
|
88ff0b952e | ||
|
|
55d3c07d85 | ||
|
|
2d2fbdb341 | ||
|
|
e262069265 | ||
|
|
4c9ab7bd50 | ||
|
|
e8696c63fe | ||
|
|
5db60ca34f | ||
|
|
60c5ea1bcc | ||
|
|
7aeca7dee2 | ||
|
|
8562e3e438 | ||
|
|
6ce1166b98 | ||
|
|
35400daf19 | ||
|
|
01320d99d6 | ||
|
|
7a80e60785 | ||
|
|
56eeed6277 | ||
|
|
7c29084a19 | ||
|
|
58180c0586 | ||
|
|
00084c6ca6 | ||
|
|
f47a100730 | ||
|
|
a1fac8fc7f | ||
|
|
c1ba289ab5 | ||
|
|
ab140ea531 | ||
|
|
09e475f6cc | ||
|
|
49926f06ee | ||
|
|
e988750272 | ||
|
|
bf4e13e144 | ||
|
|
9992dc98bb | ||
|
|
ea38d415ce | ||
|
|
5855517a46 | ||
|
|
cf00df3b96 | ||
|
|
446f4ee5a8 | ||
|
|
093acb3724 | ||
|
|
a679fb3f45 | ||
|
|
994170b482 | ||
|
|
d4f70830d1 | ||
|
|
3098471a8a | ||
|
|
fce486fe30 | ||
|
|
aee68aedb2 | ||
|
|
4b0bf3130b | ||
|
|
293cdb810e | ||
|
|
414d026d59 | ||
|
|
c9a4bc18f4 | ||
|
|
4e64c3eca9 | ||
|
|
bd215a1b96 | ||
|
|
2035eabb1f | ||
|
|
0e640dd71e | ||
|
|
d2a3aacaba | ||
|
|
56af3e7357 | ||
|
|
708e0ed05e | ||
|
|
84289b9a8d | ||
|
|
b378ac1f57 | ||
|
|
6a17ba5b7a | ||
|
|
3dc23368aa | ||
|
|
aa83ebae58 | ||
|
|
cb487d3f61 | ||
|
|
6b34b2c946 | ||
|
|
1f22e1d84c | ||
|
|
b510f21a5b | ||
|
|
6b639cd42b | ||
|
|
1c83f48c45 | ||
|
|
d3547f0f54 | ||
|
|
73b07df28f | ||
|
|
05895d9657 | ||
|
|
f1300a8fd9 | ||
|
|
22f9481e99 | ||
|
|
6434f1ec99 | ||
|
|
eac359d6b4 | ||
|
|
343e41886f | ||
|
|
77babb6b63 | ||
|
|
30218a6db0 | ||
|
|
0763d8e9b9 | ||
|
|
f027df06d1 | ||
|
|
3ab10c6758 | ||
|
|
73a38aca72 | ||
|
|
22d98e6ee5 | ||
|
|
00a00f5162 | ||
|
|
47d86d3c2f | ||
|
|
4cccb097f9 | ||
|
|
b8da4a8cd8 | ||
|
|
694494ad5e | ||
|
|
f1de837fef | ||
|
|
071a2ac835 | ||
|
|
526a98171e | ||
|
|
1339d0059a | ||
|
|
ad785c46dc | ||
|
|
57452ad49e | ||
|
|
97a788e4a3 | ||
|
|
fd9b6d1193 | ||
|
|
1cbbdf7771 | ||
|
|
a63b447992 | ||
|
|
cd6eb9a9e2 | ||
|
|
b8edcd41f3 | ||
|
|
3243344a85 | ||
|
|
22d53be3a5 | ||
|
|
bf0e7651fa | ||
|
|
56a094a34a | ||
|
|
807c34b40c | ||
|
|
b2a04bac03 | ||
|
|
0a8f482264 | ||
|
|
f2da65b778 | ||
|
|
58dbd869ba | ||
|
|
49a928d48c | ||
|
|
bcfb4d1e43 | ||
|
|
c1e4139e5c | ||
|
|
49a6211d36 | ||
|
|
0c5fbdedd8 | ||
|
|
84e568f902 | ||
|
|
b633cb7c24 | ||
|
|
eeccf23e4d | ||
|
|
a64b91e2e2 | ||
|
|
b52d28c94c | ||
|
|
c03741df93 | ||
|
|
1d85729b4a | ||
|
|
2d130f3bec | ||
|
|
67baecdc8e | ||
|
|
92fe7544c8 | ||
|
|
48d233abc6 | ||
|
|
b8912aa671 | ||
|
|
723840dad5 | ||
|
|
df7b5ec907 | ||
|
|
3220088411 | ||
|
|
dcade8cdf8 | ||
|
|
9f5c905592 | ||
|
|
da01021836 | ||
|
|
626ad9c83d | ||
|
|
4179e7e4c7 | ||
|
|
6c476ec939 | ||
|
|
7a97c25fa7 | ||
|
|
274a65903b | ||
|
|
af45dfd002 | ||
|
|
f59da361f1 | ||
|
|
78754fbe96 | ||
|
|
347e4dd73d | ||
|
|
4a3935ef9a | ||
|
|
4d93ad4474 | ||
|
|
ffa8ad0a22 | ||
|
|
f44812b1a8 | ||
|
|
bd18bf5c83 | ||
|
|
fc9d19e814 | ||
|
|
86736a7817 | ||
|
|
7a0b55758d | ||
|
|
ab8e54dd29 | ||
|
|
51758c429b | ||
|
|
e71177f776 | ||
|
|
860f3b3cab | ||
|
|
8dcf668448 | ||
|
|
9590897f3a | ||
|
|
216c991c01 | ||
|
|
6c283cdd93 | ||
|
|
d463a29ba1 | ||
|
|
21337a2fd8 | ||
|
|
a99e20cc3d | ||
|
|
f31cc07a9d | ||
|
|
41ce1b5b28 | ||
|
|
8da08ad73a | ||
|
|
c03b574ef6 | ||
|
|
b778bcd8e6 | ||
|
|
65654a3b4c | ||
|
|
3e686e9915 | ||
|
|
0613563644 | ||
|
|
d21a2581a6 | ||
|
|
8bc7d85eac | ||
|
|
dfdf94483b | ||
|
|
bf3c807047 | ||
|
|
ac0932f356 | ||
|
|
496ea40d1d | ||
|
|
f6ee1965cb | ||
|
|
1ff2ad0c08 | ||
|
|
14c6f8bf80 | ||
|
|
d8b80caff3 | ||
|
|
a26607c720 | ||
|
|
1e2ae1da1d | ||
|
|
e95a93d80b | ||
|
|
8f655ec0a6 | ||
|
|
fa3c3c2443 | ||
|
|
5c59c50e2d | ||
|
|
58cbf5aba2 | ||
|
|
3acd286791 | ||
|
|
24805ca79e | ||
|
|
ed20f2ea5f | ||
|
|
40ebf8cd62 | ||
|
|
521d223f05 | ||
|
|
a2aa68c84a | ||
|
|
74140ea251 | ||
|
|
84f7a55753 | ||
|
|
0d1b06d87c | ||
|
|
28d54f87f1 | ||
|
|
5fbfe2bdca | ||
|
|
6e0328798d | ||
|
|
0119036a11 | ||
|
|
7a023e0841 | ||
|
|
b397008ee0 | ||
|
|
b329c63c80 | ||
|
|
95b11207a1 | ||
|
|
74d0e6b2c7 | ||
|
|
7ffc12567f | ||
|
|
d8ed4fa0c2 | ||
|
|
549294ee30 | ||
|
|
b249809d2d | ||
|
|
a01e5126a3 | ||
|
|
c100442102 | ||
|
|
32c85c59b1 | ||
|
|
699c8073ff | ||
|
|
6f0def4ee3 | ||
|
|
55d0534977 | ||
|
|
9c82070ed9 | ||
|
|
6bfe16367b | ||
|
|
fb916087be | ||
|
|
418a58674f | ||
|
|
4628097df9 | ||
|
|
1159f3a781 | ||
|
|
a638a187bb | ||
|
|
44a23a5c99 | ||
|
|
08ff9863d5 | ||
|
|
d9a3b02e3f | ||
|
|
53d94d6f61 | ||
|
|
81f8187e57 | ||
|
|
4364dac226 | ||
|
|
6362845065 | ||
|
|
2db9d6b5d2 | ||
|
|
71ee33bf82 | ||
|
|
c94ce71ca3 | ||
|
|
3af2938efc | ||
|
|
e35f7748f6 | ||
|
|
157daa6def | ||
|
|
739098ab60 | ||
|
|
37c2fb0aa8 | ||
|
|
3251f8b14d | ||
|
|
ea18ffdade | ||
|
|
2d4675dc1c | ||
|
|
30a079cba8 | ||
|
|
6478a50c72 | ||
|
|
c414f0c3ce | ||
|
|
5e4e1cf662 | ||
|
|
18adb28da0 | ||
|
|
853cf82ed8 | ||
|
|
dcb1c0a146 | ||
|
|
c1c589d609 | ||
|
|
032fa52190 | ||
|
|
671f577264 | ||
|
|
f08f6ac171 | ||
|
|
a395abd5d7 | ||
|
|
25c3e7f851 | ||
|
|
6bb5248812 | ||
|
|
3500d26931 | ||
|
|
0d3f066afc | ||
|
|
bcee131971 | ||
|
|
3e67ce6e8e | ||
|
|
43e11aa807 | ||
|
|
2fa8aff82f | ||
|
|
6ccf783f00 | ||
|
|
bb19b71e39 | ||
|
|
260137fc12 | ||
|
|
67bc431247 | ||
|
|
86e46ebe6a | ||
|
|
d4ece7384c | ||
|
|
9d633b062b | ||
|
|
da91aba10f | ||
|
|
ca1a61ce60 | ||
|
|
5a0d0c6930 | ||
|
|
fce27202d9 | ||
|
|
9634df4347 | ||
|
|
2e59d47e7d | ||
|
|
cbbc4cfd26 | ||
|
|
51242f4484 | ||
|
|
9aff166f83 | ||
|
|
4bc054a347 | ||
|
|
4f5d949af6 | ||
|
|
19f1286cc7 | ||
|
|
82093cf690 | ||
|
|
83c09f15ac | ||
|
|
980b7c55a5 | ||
|
|
8807ac8cac | ||
|
|
20b6892d9b | ||
|
|
5832c3ea97 | ||
|
|
ff8c429687 | ||
|
|
abe280f0a3 | ||
|
|
9067eac4ca | ||
|
|
65882c30cb | ||
|
|
1b7ac7c739 | ||
|
|
5fe0c835b1 | ||
|
|
728c39eed7 | ||
|
|
423d0923d9 | ||
|
|
d9f2b6b14e | ||
|
|
5b73f5065b | ||
|
|
ae716bddee | ||
|
|
500e6e64fe | ||
|
|
1f0cc2724a | ||
|
|
aea1a27f2f | ||
|
|
cecb3a42b0 | ||
|
|
fa91795b77 | ||
|
|
0aabbdd23a | ||
|
|
e03925ffc0 | ||
|
|
2d46cc6d97 | ||
|
|
14ef2e5dea | ||
|
|
74392f4598 | ||
|
|
6343f4293a | ||
|
|
e72da0df5a | ||
|
|
f7fae947a7 | ||
|
|
120b8f2a83 | ||
|
|
1092827895 | ||
|
|
058369adea | ||
|
|
17f0bef2e2 | ||
|
|
07bb5cc4aa | ||
|
|
5889e8238a | ||
|
|
9063942055 | ||
|
|
3345514efc | ||
|
|
fd735590da | ||
|
|
8a4ea2b801 | ||
|
|
cd554ebce5 | ||
|
|
2470da8336 | ||
|
|
9b6dd59b86 | ||
|
|
f966935d1d | ||
|
|
045984e86f | ||
|
|
b1752cfb6a | ||
|
|
21eff61940 | ||
|
|
4897cfa9c8 | ||
|
|
f79746b148 | ||
|
|
ebd2abdd0a | ||
|
|
69fd8436aa | ||
|
|
7063d7d103 | ||
|
|
541f7bc82c | ||
|
|
ed8438628e | ||
|
|
288fba874e | ||
|
|
8f5d23ec37 | ||
|
|
d5b8acfbe7 | ||
|
|
50ed194c48 | ||
|
|
4a6d333a03 | ||
|
|
8b8ce2592d | ||
|
|
493e147be6 | ||
|
|
51691adcbc | ||
|
|
6a4542a321 | ||
|
|
92c9068369 | ||
|
|
f19d5b05a8 | ||
|
|
7f1f39058a | ||
|
|
fb71a4106f | ||
|
|
d76a3d5ce2 | ||
|
|
203c5b1956 | ||
|
|
f402957f05 | ||
|
|
18bd83413b | ||
|
|
0013f5c1fc | ||
|
|
b4f04ff3a7 | ||
|
|
9c49d9e641 | ||
|
|
c067cf996e | ||
|
|
d87d6f99d3 | ||
|
|
a03bdba711 | ||
|
|
41faec758b | ||
|
|
6469822af1 | ||
|
|
519b7af6d8 | ||
|
|
1d9c1f741d | ||
|
|
c9fda793e2 | ||
|
|
39bcee3f7b | ||
|
|
6649201fe5 | ||
|
|
e89e0dea4a | ||
|
|
6ee0c0411e | ||
|
|
f4d54c518e | ||
|
|
8e7731f139 | ||
|
|
30fd6822f8 | ||
|
|
a123629f74 | ||
|
|
3cde5fb3aa | ||
|
|
bd44ab64d0 | ||
|
|
d4dfece4f9 | ||
|
|
98d38657cb | ||
|
|
6f55679a8a | ||
|
|
217bd440c3 | ||
|
|
2b9e30a107 | ||
|
|
57156065e3 | ||
|
|
751e32e427 | ||
|
|
ada78cb952 | ||
|
|
7607c53bd5 | ||
|
|
027c08430a | ||
|
|
5abc03f4dd | ||
|
|
6176dba3c9 | ||
|
|
80f3c97668 | ||
|
|
5b14d15b00 | ||
|
|
847a78a276 | ||
|
|
d8ccb087ac | ||
|
|
3b4344c1bf | ||
|
|
87847ab31a | ||
|
|
9ae22c5efe | ||
|
|
b5cbac7b28 | ||
|
|
3be734fd84 | ||
|
|
6f896c4352 | ||
|
|
3e01286b61 | ||
|
|
b711c88bab | ||
|
|
de343021c1 | ||
|
|
f5613fb3c2 | ||
|
|
9cabef67a7 | ||
|
|
0df488e456 | ||
|
|
bc7e5c7c04 | ||
|
|
12edc3221e | ||
|
|
60fb643109 | ||
|
|
fb78bff3c0 | ||
|
|
ce7cc37077 | ||
|
|
81d6e217f1 | ||
|
|
33487283e8 | ||
|
|
af59bee2ea | ||
|
|
d3da236d96 | ||
|
|
fbaab9e446 | ||
|
|
4a69d857f3 | ||
|
|
290f4023b1 | ||
|
|
4da2b050c6 | ||
|
|
d99698f7eb | ||
|
|
12c4a7a3f7 | ||
|
|
955c2e2570 | ||
|
|
627f680659 | ||
|
|
01420fd189 | ||
|
|
4351702587 | ||
|
|
b52a162597 | ||
|
|
baa1a44397 | ||
|
|
3c80219808 | ||
|
|
bcfe1a087f | ||
|
|
d509ca1133 | ||
|
|
9b84a8e443 | ||
|
|
3402bfd96c | ||
|
|
627b39cdbb | ||
|
|
9b8d6ed6d2 | ||
|
|
2fbff741da | ||
|
|
e3c8e1bb71 | ||
|
|
d3b14ff827 | ||
|
|
6820e41eb2 | ||
|
|
22052d2c24 | ||
|
|
0202926e35 | ||
|
|
47cd0770e4 | ||
|
|
5b50f3cd69 | ||
|
|
788e7d0487 | ||
|
|
11914d20b4 | ||
|
|
77c1905609 | ||
|
|
54d606eabf | ||
|
|
033d07ee23 | ||
|
|
0db8bedf45 | ||
|
|
3bc5485867 | ||
|
|
3b9d86de0b | ||
|
|
6a3b0e3110 | ||
|
|
249b12e44f | ||
|
|
84bf1892bb | ||
|
|
96758176cc | ||
|
|
a8a8fd537f | ||
|
|
46e0992a83 | ||
|
|
1f641ce1fb | ||
|
|
22af53f60c | ||
|
|
d1b0a5739d | ||
|
|
18c19743c4 | ||
|
|
39e4551a45 | ||
|
|
77acb9bb41 | ||
|
|
24f0da172e | ||
|
|
45d58e73a9 | ||
|
|
8d84b4c2a4 | ||
|
|
8fb54b133e | ||
|
|
6d31fc7875 | ||
|
|
49e57b6d13 | ||
|
|
3b9f4a6f5e | ||
|
|
6ee4274b3b | ||
|
|
04962922d7 | ||
|
|
9d8378d2e2 | ||
|
|
67c88f90dc | ||
|
|
b615d13e46 | ||
|
|
196af9eaf7 | ||
|
|
0a3c448a52 | ||
|
|
5b8007fcb5 | ||
|
|
dc0d420af5 | ||
|
|
8882df5829 | ||
|
|
9b5c9ffb30 | ||
|
|
b09973cafe | ||
|
|
40b564dedd | ||
|
|
a1ddbff744 | ||
|
|
04ac14463b | ||
|
|
784c5b5471 | ||
|
|
b93ee37f9a | ||
|
|
bdab9dd596 | ||
|
|
e37e6e6b03 | ||
|
|
dd87111c80 | ||
|
|
c2ac797650 | ||
|
|
9a3a0070d6 | ||
|
|
e2f5d7caec | ||
|
|
9632f40335 | ||
|
|
e9821c0881 | ||
|
|
b3c4bc6041 | ||
|
|
d5f9bbc7a7 | ||
|
|
f2ee99d760 | ||
|
|
91628f5392 | ||
|
|
1de03c364f | ||
|
|
6bf218f720 | ||
|
|
40a715be89 | ||
|
|
248fd10406 | ||
|
|
0033b3fe4e | ||
|
|
f69db54b7e | ||
|
|
0765f6c230 | ||
|
|
42f2de68bd | ||
|
|
6f98379c82 | ||
|
|
2cde746e38 | ||
|
|
74cafce4d2 | ||
|
|
d3ba1a0592 | ||
|
|
e3b8f700e4 | ||
|
|
ed72ad1728 | ||
|
|
27aa628c86 | ||
|
|
a748f19ac2 | ||
|
|
5d5bb9d1f2 | ||
|
|
8f74070688 | ||
|
|
09b5c1b4dc | ||
|
|
8febc54a88 | ||
|
|
7cfcf9b21d | ||
|
|
9a064d9623 | ||
|
|
45085d04eb | ||
|
|
c107a0cbf4 | ||
|
|
793648bbd6 | ||
|
|
c42a6d8029 | ||
|
|
a56e9e586b | ||
|
|
0450c7cf48 | ||
|
|
c17b2f3ca0 | ||
|
|
30521bcac7 | ||
|
|
3a9d5b0494 | ||
|
|
78dce85b03 | ||
|
|
2d0e55e8f6 | ||
|
|
5722da8e3b | ||
|
|
e0afd7f496 | ||
|
|
ade4b0d691 | ||
|
|
12ebdbae81 | ||
|
|
a3c2018a4a | ||
|
|
b3001f95f6 | ||
|
|
8cd7de3740 | ||
|
|
d04d0c0510 | ||
|
|
821243ef40 | ||
|
|
67063e6400 | ||
|
|
a5367145bf | ||
|
|
7003395a5f | ||
|
|
9bd001a14b | ||
|
|
a31a1f3c0d | ||
|
|
3392e2ef15 | ||
|
|
57f9eadd75 | ||
|
|
a519c62250 | ||
|
|
25e241ae41 | ||
|
|
3e15c8ab69 | ||
|
|
be6633e3be | ||
|
|
b56dbb26be | ||
|
|
cfcfefb20c | ||
|
|
05a9b72670 | ||
|
|
3e2fd074bb | ||
|
|
31c63d8898 | ||
|
|
453a2bd9b5 | ||
|
|
4a825dce7f | ||
|
|
7cb105abd4 | ||
|
|
e84c521177 | ||
|
|
be7166d6fc | ||
|
|
1c41e95ba6 | ||
|
|
9a476ae41d | ||
|
|
2eab43c35e | ||
|
|
f92890b741 | ||
|
|
fc2fc6516e | ||
|
|
0a1f9966ef | ||
|
|
44754e4c4a | ||
|
|
7cc2afe973 | ||
|
|
a4885ac549 | ||
|
|
26ce14d163 | ||
|
|
5eca495d3e | ||
|
|
aeef5d4dcd | ||
|
|
9f87a0cf21 | ||
|
|
5526b66165 | ||
|
|
a3add18fa9 | ||
|
|
198b89faa5 | ||
|
|
db0d2ae6d4 | ||
|
|
c9e1f454fc | ||
|
|
f58cb09d27 | ||
|
|
2e2a63c201 | ||
|
|
bdd5120407 | ||
|
|
9b5da77ffc | ||
|
|
86044feaca | ||
|
|
17debaa6de | ||
|
|
c8a49d373a | ||
|
|
0ac57a088f | ||
|
|
7c734d3fea | ||
|
|
600344f2e8 | ||
|
|
1b237c7f60 | ||
|
|
e28bc37e41 | ||
|
|
75096c24ac | ||
|
|
d2485bbf09 | ||
|
|
8661c4416a | ||
|
|
e98dc0a745 | ||
|
|
ed6bbddf6e | ||
|
|
bdbc8f048c | ||
|
|
74167f4b48 | ||
|
|
195e0221b8 | ||
|
|
7b0fc0ad1c | ||
|
|
c093b88abe | ||
|
|
34ec073b3d | ||
|
|
bb0176085d | ||
|
|
54e5e4bb4b | ||
|
|
4c7436a05c | ||
|
|
ee00bdef63 | ||
|
|
e5415b8a87 | ||
|
|
1f4e51edc6 | ||
|
|
a9804e2566 | ||
|
|
e1efd54a70 | ||
|
|
d1ee4e0925 | ||
|
|
9623ef4360 | ||
|
|
bb506edcc3 | ||
|
|
f55e93cabe | ||
|
|
661ea773f4 | ||
|
|
34ddd62951 | ||
|
|
7adb15d4b0 | ||
|
|
21f9982c5a | ||
|
|
ff3dec9ef2 | ||
|
|
05773bc831 | ||
|
|
6ff84d8ec0 | ||
|
|
98317f4576 | ||
|
|
139e4f93cb | ||
|
|
a3c9b46e9a | ||
|
|
2c6227e4b6 | ||
|
|
3f800c069e | ||
|
|
9b718e7817 | ||
|
|
7e273ebe86 | ||
|
|
225d20fb84 | ||
|
|
b7255854d2 | ||
|
|
bab7a65c2c | ||
|
|
1cc15e6470 | ||
|
|
7e49c8733b | ||
|
|
40fc1a1f95 | ||
|
|
d7f247d7d2 | ||
|
|
57972605f2 | ||
|
|
d54896ca66 | ||
|
|
6a644cd03a | ||
|
|
64b583dc29 | ||
|
|
bed2c7e34f | ||
|
|
e5435c6480 | ||
|
|
555c449f49 | ||
|
|
a85ecadfeb | ||
|
|
355a3eaedb | ||
|
|
8d49288499 | ||
|
|
6d0bd226c1 | ||
|
|
3d4752480e | ||
|
|
af9d264405 | ||
|
|
1dd475cccf | ||
|
|
09ddf69a73 | ||
|
|
67ac39b72b | ||
|
|
ab3c19dd36 | ||
|
|
7a25c9637a | ||
|
|
59ba21bdf8 | ||
|
|
988fc3a72c | ||
|
|
8b43641849 | ||
|
|
b8c8c64720 | ||
|
|
557be6c824 | ||
|
|
c38177bcd5 | ||
|
|
00e2ad1a9f | ||
|
|
fffe0b8580 | ||
|
|
030e9ab1ae | ||
|
|
b685fd4a33 | ||
|
|
876bb4bdc6 | ||
|
|
534c12b1b9 | ||
|
|
15821af252 | ||
|
|
a463ef3c03 | ||
|
|
9813019489 | ||
|
|
de018f0912 | ||
|
|
8b35ea6eea | ||
|
|
bab1453b4c | ||
|
|
a14cfb6448 | ||
|
|
71d550dd26 | ||
|
|
2748fc1e4d | ||
|
|
ce311598cb | ||
|
|
8a334decf6 | ||
|
|
eac2f36f4f | ||
|
|
c75d0bd20b | ||
|
|
74912a5383 | ||
|
|
5c2e0e4beb | ||
|
|
7b5df819b1 | ||
|
|
345922a970 | ||
|
|
7523613fbc | ||
|
|
962e078a9a | ||
|
|
1b064a6c85 | ||
|
|
e06cdea0b5 | ||
|
|
8f9e202a88 | ||
|
|
1a52585769 | ||
|
|
2b88f66762 | ||
|
|
53a08eb00d | ||
|
|
ffd7b004f4 | ||
|
|
f123d70cff | ||
|
|
372836c87c | ||
|
|
ff639a20d0 | ||
|
|
bfd92ec4af | ||
|
|
88ea0f523f | ||
|
|
dd1e4ee983 | ||
|
|
c91b0cd8c4 | ||
|
|
3ff864eb63 | ||
|
|
90257442e3 | ||
|
|
0447790e6a | ||
|
|
09f0c6beee | ||
|
|
c8e8111dd9 | ||
|
|
38dfa6e6db | ||
|
|
276bfa02d0 | ||
|
|
79aa73347e | ||
|
|
2ae9bc30bd | ||
|
|
aa13a7caf9 | ||
|
|
8c5beddb5f | ||
|
|
c303232657 | ||
|
|
8b49d6dda0 | ||
|
|
e3bacc3296 | ||
|
|
767c4426cd | ||
|
|
789e6a0db3 | ||
|
|
a66206f44f | ||
|
|
2f4604921f | ||
|
|
21d616f8ed | ||
|
|
f429dd667f | ||
|
|
d1e3940abd | ||
|
|
cf24e39404 | ||
|
|
dd0e6bf58f | ||
|
|
88c4f55a51 | ||
|
|
3f865f0b66 | ||
|
|
b6e2b2e514 | ||
|
|
8e95e5267c | ||
|
|
d4fcf561e5 | ||
|
|
0ba84a670d | ||
|
|
1f123eb100 | ||
|
|
6676824947 | ||
|
|
2bad7eaa07 | ||
|
|
d60c800d66 | ||
|
|
05bee5663d | ||
|
|
baa5920b7a | ||
|
|
889a3e1c48 | ||
|
|
565ec1567b | ||
|
|
7f681e8c02 | ||
|
|
45d7726ee0 | ||
|
|
ac5567f78d | ||
|
|
1875d7d726 | ||
|
|
c77c4ff3f7 | ||
|
|
2a25d625b2 | ||
|
|
559c25fa3c | ||
|
|
9dd24ee8fa | ||
|
|
5dba298c1e | ||
|
|
0b1db0b216 | ||
|
|
947b77db24 | ||
|
|
74ae9ab897 | ||
|
|
c9c5340762 | ||
|
|
77a96870ea | ||
|
|
e237600e51 | ||
|
|
708010a16a | ||
|
|
f0e60c28df | ||
|
|
610680ac14 | ||
|
|
f4b92868c4 | ||
|
|
9b2c3dec3a | ||
|
|
6c7a7bc891 | ||
|
|
018000c41b | ||
|
|
95610080fb | ||
|
|
a5cdd25802 | ||
|
|
2df1f83e79 | ||
|
|
13cd025a7d | ||
|
|
986e583594 | ||
|
|
0563738940 | ||
|
|
21d0431178 | ||
|
|
bb1f8f8ae9 | ||
|
|
b1f0ad79a8 | ||
|
|
aef0ad2d10 | ||
|
|
38e8209d71 | ||
|
|
2eae63bcd4 | ||
|
|
63d99abf41 | ||
|
|
7513dc7e34 | ||
|
|
2658ab1e46 | ||
|
|
df060df88b | ||
|
|
e035d5d767 | ||
|
|
493595c891 | ||
|
|
cd70469a8a | ||
|
|
17b9a81504 | ||
|
|
e3e2bf841e | ||
|
|
2f94b3947e | ||
|
|
9f33abf384 | ||
|
|
a21519f8f2 | ||
|
|
e1f5f1e0b1 | ||
|
|
4e50dd4df6 | ||
|
|
13c16b974c | ||
|
|
b5445b5db2 | ||
|
|
c7ef6025af | ||
|
|
f0dea9a7c4 | ||
|
|
f7c7d72857 | ||
|
|
81b8267e85 | ||
|
|
f939646364 | ||
|
|
4f8f349c8f | ||
|
|
bbdb098b87 | ||
|
|
646b3ca7f6 | ||
|
|
a196b9dc26 | ||
|
|
b08fc297cf | ||
|
|
5f60b30320 | ||
|
|
088e54c4fa | ||
|
|
baafdb752c | ||
|
|
d6f13bee5e | ||
|
|
6555cf9d7a | ||
|
|
1547235d47 | ||
|
|
b9ea6130c7 | ||
|
|
3814bbf7b3 | ||
|
|
34440f95dc | ||
|
|
1abc3847ab | ||
|
|
6186bbf337 | ||
|
|
4119ab261e | ||
|
|
e79f31b9c4 | ||
|
|
1d179399f3 | ||
|
|
294d6ce060 | ||
|
|
3ff5db229b | ||
|
|
27f7df8ad6 | ||
|
|
bd818b4524 | ||
|
|
99b153a132 | ||
|
|
d837e6e579 | ||
|
|
226a7578c6 | ||
|
|
f36f943a54 | ||
|
|
3cdefff0d2 | ||
|
|
e05560a49d | ||
|
|
05aaefd8a5 | ||
|
|
58b5d2b727 | ||
|
|
0134b5eca4 | ||
|
|
3be4fb25cd | ||
|
|
d66285f926 | ||
|
|
35be40c932 | ||
|
|
898b65a653 | ||
|
|
b120637127 | ||
|
|
70968a6caf | ||
|
|
8a169d5c8e | ||
|
|
5f82631ae6 | ||
|
|
fdeded4a20 | ||
|
|
3b70841a7d | ||
|
|
23c679d8db | ||
|
|
8a587f9f84 | ||
|
|
fb26be7dd6 | ||
|
|
8908e834ac | ||
|
|
098091c95e | ||
|
|
e866fba27c | ||
|
|
872b0ac943 | ||
|
|
22b02af703 | ||
|
|
7de0e49dc6 | ||
|
|
c330f06542 | ||
|
|
6a613c2dd6 | ||
|
|
4254469a33 | ||
|
|
52cffef16e | ||
|
|
d50449ea71 | ||
|
|
cd975a1047 | ||
|
|
354dd3caa9 | ||
|
|
c66576805b | ||
|
|
beb1f3f7fd | ||
|
|
c8c4e1642f | ||
|
|
135b57ca8e | ||
|
|
8a63a601de | ||
|
|
f7920df870 | ||
|
|
5261d3d31e | ||
|
|
088243349d | ||
|
|
9d37350b97 | ||
|
|
4f9ce28a15 | ||
|
|
9f936a0c19 | ||
|
|
85724940ee | ||
|
|
a7772b4b35 | ||
|
|
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 | ||
|
|
033e5c1e00 | ||
|
|
e50cde80f3 | ||
|
|
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 | ||
|
|
871efb4ad9 | ||
|
|
da5fbc0e39 | ||
|
|
1ad4f7a244 | ||
|
|
c0f65ec28a | ||
|
|
6fd91e58cf | ||
|
|
b433d30c26 | ||
|
|
1ff7cb7d2f | ||
|
|
9c38183689 | ||
|
|
fd97882fca | ||
|
|
2b4d4d2770 | ||
|
|
dc60bd2816 | ||
|
|
0b84b22b4e | ||
|
|
870c23f69d | ||
|
|
12cfb7b0f0 | ||
|
|
37f1d47a06 | ||
|
|
14a890a0a5 | ||
|
|
f155abc63c | ||
|
|
f6f3360ef7 | ||
|
|
99fe23ef66 | ||
|
|
92c5773682 | ||
|
|
3c5ae1f62e | ||
|
|
543ed432b1 | ||
|
|
bb09245792 | ||
|
|
6dd969129d | ||
|
|
a8b61204b4 | ||
|
|
ec0124b521 | ||
|
|
07b2ecf6aa | ||
|
|
e7b86cc852 | ||
|
|
eb3c080cce | ||
|
|
b3e328ed04 | ||
|
|
6c8202d87d | ||
|
|
936c3efe5e | ||
|
|
0fe82d119f | ||
|
|
4f982e244f | ||
|
|
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 | ||
|
|
23b9354cf6 | ||
|
|
4fa2e136bf | ||
|
|
512d475ecc | ||
|
|
56cefde161 | ||
|
|
f78811b253 | ||
|
|
e6ca7a414b | ||
|
|
367f6fb8f1 | ||
|
|
37e0313c83 | ||
|
|
267889809d | ||
|
|
33c206c15c | ||
|
|
863f227be9 | ||
|
|
abdde10b74 | ||
|
|
26e9cd0b4b | ||
|
|
457a0f318a | ||
|
|
3771d27a52 | ||
|
|
d16ff54895 | ||
|
|
de0f52b686 | ||
|
|
eec7a2afa1 | ||
|
|
9e1400f055 | ||
|
|
aa374823ce | ||
|
|
a20a920ca4 | ||
|
|
827fd6606c | ||
|
|
deb1f6b6d7 | ||
|
|
0f1df43bb2 | ||
|
|
62a5b14642 | ||
|
|
9faf2d9849 | ||
|
|
f24d0085c2 | ||
|
|
463ac99e39 | ||
|
|
e8b23ef0c1 | ||
|
|
a6a7ce6b68 | ||
|
|
8514669053 | ||
|
|
291834ecbe | ||
|
|
360b5b303a | ||
|
|
d8e9b000e3 | ||
|
|
62366ad020 | ||
|
|
2946f5fcce | ||
|
|
f2cf21d64c | ||
|
|
6475ae3b11 | ||
|
|
84a05bec7b | ||
|
|
f716968009 | ||
|
|
18149483f2 | ||
|
|
175bbe27e9 | ||
|
|
d1eaa9d4ee | ||
|
|
9b133a2245 | ||
|
|
0e51b75654 | ||
|
|
1cf9843468 | ||
|
|
dd42a10aa1 | ||
|
|
60c0aa36a2 | ||
|
|
a7cbfeec9f | ||
|
|
c5c98591f9 | ||
|
|
59e52add63 | ||
|
|
9c4a931d22 | ||
|
|
4cfb99248d | ||
|
|
aefd5d9557 | ||
|
|
0f14f34dbe | ||
|
|
d74809853d | ||
|
|
23da63b06a | ||
|
|
116b61c53b | ||
|
|
f43cb87556 | ||
|
|
1af45435eb | ||
|
|
4acd278624 | ||
|
|
1d82a1c8c4 | ||
|
|
52d32e8bf2 | ||
|
|
a74297ed47 | ||
|
|
7de6112c5b | ||
|
|
bfacab5a0c | ||
|
|
bbc1ae0942 | ||
|
|
7fee84c06e | ||
|
|
437804a2f8 | ||
|
|
4801430ad2 | ||
|
|
657162e96d | ||
|
|
cafba3eaff | ||
|
|
7d184c3a14 | ||
|
|
754f631a07 | ||
|
|
c1ddb00377 | ||
|
|
74f160b849 | ||
|
|
11d599c55d | ||
|
|
7b014e44ee | ||
|
|
4a0b2a3afc | ||
|
|
2d5643fb17 | ||
|
|
d522914774 | ||
|
|
3013ce2d1a | ||
|
|
6d81eef425 | ||
|
|
c258272709 | ||
|
|
a0981e01a0 | ||
|
|
10243b0fac | ||
|
|
cc14aacaaa | ||
|
|
ef7acfbf3d | ||
|
|
fb8a7e8549 | ||
|
|
754a3c4cb9 | ||
|
|
725761ad6b | ||
|
|
57cb030a24 | ||
|
|
12626107b6 | ||
|
|
72f985aba7 | ||
|
|
5d5e351937 | ||
|
|
2062d3434e | ||
|
|
f26cafd984 | ||
|
|
aca82d64a0 | ||
|
|
3715919a6b | ||
|
|
0e3b9f9da1 | ||
|
|
f10e7ac81b | ||
|
|
61580e9490 | ||
|
|
6f2d61dc4f | ||
|
|
32257089f9 | ||
|
|
b5cfa2b992 | ||
|
|
c826717343 | ||
|
|
35a64b55a1 | ||
|
|
9695e08411 | ||
|
|
c6d76acf76 | ||
|
|
b6a76dc026 | ||
|
|
079af5fffe | ||
|
|
e57f2c928a | ||
|
|
513d5c8bf5 | ||
|
|
d72e41c563 | ||
|
|
1ca5d532b8 | ||
|
|
f46c7d53b7 | ||
|
|
a7cbf62c68 | ||
|
|
4eaaeb1888 | ||
|
|
753d530cd0 | ||
|
|
10f55d39d2 | ||
|
|
0b84863d76 | ||
|
|
efc163fede | ||
|
|
dc5107a473 | ||
|
|
c5fd40d951 | ||
|
|
317032aa5c | ||
|
|
f3b38e9777 | ||
|
|
336d20fa33 | ||
|
|
dadfa5f043 | ||
|
|
e52e45d613 | ||
|
|
4a6b964a51 | ||
|
|
41904f21e4 | ||
|
|
ef2aeb7c0e | ||
|
|
4c81b6ebc5 | ||
|
|
783d409b1d | ||
|
|
c1fa989cb8 | ||
|
|
6c8a9d000e | ||
|
|
f9d238e850 | ||
|
|
db06a925fe | ||
|
|
eb68b21e0f | ||
|
|
e822984162 | ||
|
|
e80ed32aeb | ||
|
|
c9d3bb35c3 | ||
|
|
da9966aca1 | ||
|
|
4ee5dd58b7 | ||
|
|
e87f2669fa | ||
|
|
390263742c | ||
|
|
8f4be09dd2 | ||
|
|
7b863465a9 | ||
|
|
6ae2d2c4ea | ||
|
|
addf44b21f | ||
|
|
8f31b6d74a | ||
|
|
bc88a9bc8b | ||
|
|
f7f3ae7cc9 | ||
|
|
5a1c4dbe1b | ||
|
|
63e4d1ea41 | ||
|
|
cf2d7de873 | ||
|
|
c9a9e05bb4 | ||
|
|
3ac74a4fcb | ||
|
|
63fe426a52 | ||
|
|
ee8de1ee0b | ||
|
|
791c19acf7 | ||
|
|
a9999833d2 | ||
|
|
c3da9f3026 | ||
|
|
2f5abf7374 | ||
|
|
dedc45183f | ||
|
|
6d59a5f809 | ||
|
|
10855cf5ed | ||
|
|
9398c843d3 | ||
|
|
45ad6776de | ||
|
|
fb1c101dc6 | ||
|
|
a0273aaea8 | ||
|
|
146a08c68f | ||
|
|
7d0a78a43a | ||
|
|
9bfb4ae838 | ||
|
|
ec496de593 | ||
|
|
12fd7c8cb9 | ||
|
|
d9186e138b | ||
|
|
141810039f | ||
|
|
2112067054 | ||
|
|
8a9de0a62f | ||
|
|
4b9b24ae78 | ||
|
|
056a042ef2 | ||
|
|
c51a91583e | ||
|
|
1efa708f83 | ||
|
|
ea16426a8d | ||
|
|
f9b9217e98 | ||
|
|
e6d43d70f3 | ||
|
|
240d91d38d | ||
|
|
0f73b96616 | ||
|
|
1349bc47b1 | ||
|
|
92dbeb1939 | ||
|
|
5f69424215 | ||
|
|
86b9f252c6 | ||
|
|
0b5514f376 | ||
|
|
28ec3069de | ||
|
|
8f7195ceda | ||
|
|
91e758f3ec | ||
|
|
d0a1a0f16b | ||
|
|
732d7aee70 | ||
|
|
3ec6652f99 | ||
|
|
cad9b292bf | ||
|
|
09874ab83d | ||
|
|
c798adb04c | ||
|
|
7baca2b483 | ||
|
|
2b10b378b6 | ||
|
|
2652b91f5a | ||
|
|
afdba443f4 | ||
|
|
ba4f399ace | ||
|
|
176ff56e85 | ||
|
|
f2765bee72 | ||
|
|
497a7b4ded | ||
|
|
d182155fac | ||
|
|
5030041683 | ||
|
|
faa3cac0e4 | ||
|
|
d3e516934c | ||
|
|
2f7b5acdf8 | ||
|
|
7937ed3ee1 | ||
|
|
f0a129482e | ||
|
|
0e0ee3ca9c | ||
|
|
26a2187e6e | ||
|
|
2b4e946f70 | ||
|
|
17a9d11435 | ||
|
|
a21ac3cc25 | ||
|
|
4eb6985c1b | ||
|
|
5f81755b86 | ||
|
|
4340431753 | ||
|
|
f2314596ba | ||
|
|
43efff0fe6 | ||
|
|
4e8cd688bf | ||
|
|
af5efe1580 | ||
|
|
5b9c1de3d1 | ||
|
|
4b451b984b | ||
|
|
bc315bd530 | ||
|
|
1e291aff25 | ||
|
|
913f8a15f9 | ||
|
|
7f85436a94 | ||
|
|
99ce85f20f | ||
|
|
039dec6820 | ||
|
|
e000c56ef7 | ||
|
|
af02708b4c | ||
|
|
5a8e417590 | ||
|
|
1a6e4c2948 | ||
|
|
6fd082d55f | ||
|
|
f4de3b4c45 | ||
|
|
8fcad1df4e | ||
|
|
08a35417f2 | ||
|
|
f7906790e1 | ||
|
|
852d9dcbe9 | ||
|
|
48637893b1 | ||
|
|
463d7fb628 | ||
|
|
332f8579d7 | ||
|
|
ad8c8da438 | ||
|
|
f0447b24ab | ||
|
|
63a887151f | ||
|
|
0aea894e2d | ||
|
|
8507c47459 | ||
|
|
221303eec5 | ||
|
|
87844a8042 | ||
|
|
1c18282d19 | ||
|
|
6101067ea4 | ||
|
|
d2f3c82fd6 | ||
|
|
27936014b0 | ||
|
|
8b9527d887 | ||
|
|
a5476ef7d8 | ||
|
|
62f87c2e38 | ||
|
|
60d84a3aae | ||
|
|
3e7bce0921 | ||
|
|
1dbf4d0461 | ||
|
|
a67e5f725f | ||
|
|
527c5e73dc | ||
|
|
2ecfd3dbed | ||
|
|
5989b6ca12 | ||
|
|
e3af831db1 | ||
|
|
f77f6cd1e2 | ||
|
|
e19554e9a8 | ||
|
|
19b3d88737 | ||
|
|
0d09e46281 | ||
|
|
961869a60c | ||
|
|
b8fb4e528d | ||
|
|
794360addc | ||
|
|
fa8132903e | ||
|
|
a86a932b46 | ||
|
|
14fa7d16f9 | ||
|
|
64a98b2e0e | ||
|
|
8f67ffabbd | ||
|
|
a22be555cb | ||
|
|
eb80719cf0 | ||
|
|
840dd9c89a | ||
|
|
30e84adb51 | ||
|
|
414ebc87ee | ||
|
|
165eb50c22 | ||
|
|
477ac7de86 | ||
|
|
824238a620 | ||
|
|
88f97372d9 | ||
|
|
0577dc2990 | ||
|
|
3b20c57ea8 | ||
|
|
43b9bf17dc | ||
|
|
246ff83864 | ||
|
|
cdf26d8189 | ||
|
|
79bde6fa47 | ||
|
|
3dcbf1acf5 | ||
|
|
b1629d8660 | ||
|
|
9f8b94e45f | ||
|
|
9669cd3454 | ||
|
|
c4edcdb12a | ||
|
|
a6ccc48d91 | ||
|
|
b6caa95fac | ||
|
|
6188c0c5b7 | ||
|
|
dfe50b4e56 | ||
|
|
54c6e64eec | ||
|
|
465e879a78 | ||
|
|
93fd5185f9 | ||
|
|
4ebee592f2 | ||
|
|
6076476a74 | ||
|
|
59918b0f54 | ||
|
|
bb1dea72d4 | ||
|
|
264a87f467 | ||
|
|
07347f518a | ||
|
|
84f5a52923 | ||
|
|
8a50d50038 | ||
|
|
41ef596472 | ||
|
|
b4d0d840d1 | ||
|
|
b1ef53873f | ||
|
|
4f14b17c34 | ||
|
|
36b2052fb0 | ||
|
|
12c7ecf0f4 | ||
|
|
e5cbef0baa | ||
|
|
44e2785495 | ||
|
|
f96c610e21 | ||
|
|
0fc5699533 | ||
|
|
575c12f80c | ||
|
|
7e0e6177e9 | ||
|
|
0d388b4e54 | ||
|
|
a3d950872c | ||
|
|
55d077b52a | ||
|
|
2d7062fc99 | ||
|
|
5fd794612e | ||
|
|
21206bf4b9 | ||
|
|
dcb3f18e1e | ||
|
|
85f8e91288 | ||
|
|
008fec80c1 | ||
|
|
07d8460126 | ||
|
|
e4c7417522 | ||
|
|
91523bbb07 | ||
|
|
49bf3207e8 | ||
|
|
ec730b08c5 | ||
|
|
815d18b57e | ||
|
|
8d33b5e063 | ||
|
|
aa8db40376 | ||
|
|
95fca7b700 | ||
|
|
87aa01a5c2 | ||
|
|
d932fc555d | ||
|
|
ec3b8fab5b | ||
|
|
403295600f | ||
|
|
ceffac87b6 | ||
|
|
e7332fd6fe | ||
|
|
60596c362a | ||
|
|
c749083935 | ||
|
|
70718dda90 | ||
|
|
2fcf5d8578 | ||
|
|
bc2b5d3cc8 | ||
|
|
370f934158 | ||
|
|
22f0365cef | ||
|
|
839ba22c90 | ||
|
|
c70fedb626 | ||
|
|
08bcfdb8cc | ||
|
|
24367d459b | ||
|
|
8158609c93 | ||
|
|
70d9405e64 | ||
|
|
aaefba2777 | ||
|
|
aca37f592d | ||
|
|
888b468576 | ||
|
|
9348c05f1a | ||
|
|
4dafbbccfc | ||
|
|
8b0e565e2c | ||
|
|
2766d7df6a | ||
|
|
8e810e758d | ||
|
|
e7821288f5 | ||
|
|
db9d4ead59 | ||
|
|
1a6eaf6445 | ||
|
|
8d53f1e770 | ||
|
|
c1444cbbba | ||
|
|
40f9747ae4 | ||
|
|
ac8e81834c | ||
|
|
8d62cc1935 | ||
|
|
f3fe82da80 | ||
|
|
fa61065c1e | ||
|
|
5844779384 | ||
|
|
451465151e | ||
|
|
85ef912f6a | ||
|
|
2b8cd4dff4 | ||
|
|
f6c8184a74 | ||
|
|
bde89fd29e | ||
|
|
daabc188e8 | ||
|
|
d5ee39de21 | ||
|
|
cdb4904999 | ||
|
|
577406e00b | ||
|
|
175919c787 | ||
|
|
03e2e5279f | ||
|
|
4ca6247496 | ||
|
|
40a3e7786b | ||
|
|
79dff685ea | ||
|
|
881d81d87f | ||
|
|
db8780fd9c | ||
|
|
0a69db291d | ||
|
|
af0d99b2a3 | ||
|
|
540c27c589 | ||
|
|
4106681889 | ||
|
|
91a455a284 | ||
|
|
77b25ae36a | ||
|
|
48a23ce3fe | ||
|
|
c3497da5dd | ||
|
|
bdef1001ac | ||
|
|
6862d8fba8 | ||
|
|
02ba7aeb95 | ||
|
|
c6755f9151 | ||
|
|
5eac5960ef | ||
|
|
30d02c638c | ||
|
|
c5636ff68c | ||
|
|
c846a550fa | ||
|
|
59dd798156 | ||
|
|
a8bbaa61bd | ||
|
|
5fea0a2221 | ||
|
|
6d63337ee9 | ||
|
|
b97ea72616 | ||
|
|
1921420319 | ||
|
|
a47a958edd | ||
|
|
53df5d3946 | ||
|
|
0f27968ceb | ||
|
|
36ac81b229 | ||
|
|
9a55257c5b | ||
|
|
05aa9c6d9c | ||
|
|
f0efee5de4 | ||
|
|
bf0716e85e | ||
|
|
f9bd49bf56 | ||
|
|
4cbf16c456 | ||
|
|
1eba6a812e | ||
|
|
3f71929a69 | ||
|
|
d8780e4de2 | ||
|
|
c12f8d28b9 | ||
|
|
d4297a3e3e | ||
|
|
fb0ee1329d | ||
|
|
23dcae7054 | ||
|
|
7aab8b9307 | ||
|
|
f10566f3de | ||
|
|
0611eb8ac7 | ||
|
|
a3e477e100 | ||
|
|
b40b50fb2d | ||
|
|
e065073bea | ||
|
|
33b35b797c | ||
|
|
ef14d0b46d | ||
|
|
62ef0bad6f | ||
|
|
63e5200e2f | ||
|
|
1865e29bfc | ||
|
|
f7dc6474ee | ||
|
|
536bc36e36 | ||
|
|
708fcdc6b7 | ||
|
|
de926a060d | ||
|
|
4731e0d0e3 | ||
|
|
3e2a6df1fb | ||
|
|
bc295546cd | ||
|
|
6c9a4d6ce6 | ||
|
|
e27c5989bc | ||
|
|
5f116d69c2 | ||
|
|
a27dcc84a3 | ||
|
|
7cee0073cf | ||
|
|
a5faaf388e | ||
|
|
87af2692e5 | ||
|
|
15fd822916 | ||
|
|
68aea6bf10 | ||
|
|
38d5b03685 | ||
|
|
2e7a01f30a | ||
|
|
a363c0619a | ||
|
|
f49d3d09f5 | ||
|
|
17ea20d3ad | ||
|
|
92611b588c | ||
|
|
de1e9f3c0f | ||
|
|
d8c0a1227a | ||
|
|
84ea9ea011 | ||
|
|
b130b55f5b | ||
|
|
2ff1657a7a | ||
|
|
2575dac4ed | ||
|
|
8c3146f6f8 | ||
|
|
1f12023d85 | ||
|
|
63eab42208 | ||
|
|
60d11c1f6f | ||
|
|
f17befc853 | ||
|
|
65b5c6c0c7 | ||
|
|
2c1d0e3857 | ||
|
|
4c9a791b06 | ||
|
|
f822d89350 | ||
|
|
2337b36609 | ||
|
|
81af20aa9b | ||
|
|
27e8c6fce4 | ||
|
|
d99a883867 | ||
|
|
f5b2867e45 | ||
|
|
760ea3f4af | ||
|
|
337c7caafa | ||
|
|
d50ca0aa43 | ||
|
|
399e7b1670 | ||
|
|
e92d3073bb | ||
|
|
00f58658f7 | ||
|
|
b15adc63fe | ||
|
|
fed47f2e2b | ||
|
|
1c60b8d543 | ||
|
|
8609ca3657 | ||
|
|
e06ff17a70 | ||
|
|
7c1b004687 | ||
|
|
353b104c77 | ||
|
|
b4465987f9 | ||
|
|
66322727e3 | ||
|
|
0e71b96597 | ||
|
|
f3f45209e9 | ||
|
|
e7715f3760 | ||
|
|
7c828015d3 | ||
|
|
4e545d432b | ||
|
|
66bf6d88bc | ||
|
|
e8f02328f4 | ||
|
|
818d24e672 | ||
|
|
1e7a36478b | ||
|
|
d8a7e9c660 | ||
|
|
5c658a4879 | ||
|
|
b81789190e | ||
|
|
f172403049 | ||
|
|
9cb1104f09 | ||
|
|
d1a77a9110 | ||
|
|
1efae95a36 | ||
|
|
54a74b2c0a | ||
|
|
908492702a | ||
|
|
5553ec3e51 | ||
|
|
ddec6cf0fa | ||
|
|
1857f152c2 | ||
|
|
c5deb5cda4 | ||
|
|
d3ed20e907 | ||
|
|
82252cb760 | ||
|
|
9e700dc9a2 | ||
|
|
e1f60feb7f | ||
|
|
aefb3117db | ||
|
|
76d8935d0b | ||
|
|
dbc06084a7 | ||
|
|
8a81f5f188 | ||
|
|
ba66995532 | ||
|
|
96ecdc37b3 | ||
|
|
f187a1c9ef | ||
|
|
9e8b7ac611 | ||
|
|
5379c08b9d | ||
|
|
63533c9e3a | ||
|
|
8e8bc3125e | ||
|
|
2c083f58d9 | ||
|
|
93962f01cc | ||
|
|
40d019ff50 | ||
|
|
3885ea4a15 | ||
|
|
bd28173de6 | ||
|
|
06820e701c | ||
|
|
2be08f27ea | ||
|
|
fabe5876c0 | ||
|
|
f1a4afeb8f | ||
|
|
914eb49767 | ||
|
|
a8bc0d65a0 | ||
|
|
cbe2056587 | ||
|
|
65ed76abe1 | ||
|
|
bc17511a05 | ||
|
|
3413dd8ae1 | ||
|
|
03759e79a3 | ||
|
|
20211a489f | ||
|
|
5d2e725f81 | ||
|
|
2722613cde | ||
|
|
f243e523a6 | ||
|
|
155dbd5a66 | ||
|
|
9825d03602 | ||
|
|
66cd40f959 | ||
|
|
dc2f8ec8c6 | ||
|
|
bf118718b3 | ||
|
|
64a0b28dab | ||
|
|
6751d68034 | ||
|
|
6274f4d0d3 | ||
|
|
624c5250b1 | ||
|
|
c1ff69721a | ||
|
|
1e98ae7608 | ||
|
|
1bde36e072 | ||
|
|
89e7913ff2 | ||
|
|
51bf9eaa88 | ||
|
|
b8cf9b738d | ||
|
|
635c08a5ab | ||
|
|
6323b9f4f0 | ||
|
|
94453839ba | ||
|
|
a506a1a61e | ||
|
|
d266490862 | ||
|
|
ff1d454bdd | ||
|
|
eda3eba084 | ||
|
|
0a2747bc6a | ||
|
|
60c6e00d9f | ||
|
|
246c839ee6 | ||
|
|
2994c58fd9 | ||
|
|
4476060044 | ||
|
|
2729d8aab4 | ||
|
|
26e1bfc772 | ||
|
|
9ea6cea0ba | ||
|
|
71625663c0 | ||
|
|
33451cf66d | ||
|
|
41aca84913 | ||
|
|
5296ee0f71 | ||
|
|
da94835165 | ||
|
|
13dfca4e76 | ||
|
|
d5063f47f2 | ||
|
|
ef787e4a79 | ||
|
|
48d690c55d | ||
|
|
69c68df34b | ||
|
|
b3110ca291 | ||
|
|
cd0a1b4852 | ||
|
|
32f309b41d | ||
|
|
d23f757c1f | ||
|
|
324550423c | ||
|
|
b4277c7d2e | ||
|
|
84788b73ce | ||
|
|
09344bbb80 | ||
|
|
fe4f760b4a | ||
|
|
807b208418 | ||
|
|
ae484e8edf | ||
|
|
56dc7c5640 | ||
|
|
e570a98bf7 | ||
|
|
93bb77ede3 | ||
|
|
66db2e1515 | ||
|
|
c9e9ce931b | ||
|
|
9747a0e1f1 | ||
|
|
61778c8f71 | ||
|
|
579aca65bb | ||
|
|
ee44383c83 | ||
|
|
0c0505e1cd | ||
|
|
1b8557ce86 | ||
|
|
e0da6003f1 | ||
|
|
0f310b3509 | ||
|
|
8cf8121812 | ||
|
|
a2f2203325 | ||
|
|
193a927aba | ||
|
|
4ad10f0c6e | ||
|
|
5e06b6d3f4 | ||
|
|
cb94a87cb0 | ||
|
|
793aa307ef | ||
|
|
7e46cc4562 | ||
|
|
b612af29e9 | ||
|
|
feaf434d4e | ||
|
|
77fa11098a | ||
|
|
a32bb850fa | ||
|
|
64b68b3641 | ||
|
|
3b2b6e183d | ||
|
|
138e985d66 | ||
|
|
ec3435de00 | ||
|
|
a25d87607e | ||
|
|
1c57e3e02c | ||
|
|
41b9ff9e00 | ||
|
|
6d5cb6b04e | ||
|
|
1366822f99 | ||
|
|
2b0d9c26eb | ||
|
|
be20e6dec0 | ||
|
|
a1f3300767 | ||
|
|
dfdde8bb1f | ||
|
|
ba77a72925 | ||
|
|
bcf0a8791e | ||
|
|
100f5a57e7 | ||
|
|
911321878f | ||
|
|
faa68fcdaa | ||
|
|
257ca45456 | ||
|
|
561b2c0b1a | ||
|
|
436e3ff7de | ||
|
|
c0711ba0c9 | ||
|
|
5c5160c294 | ||
|
|
2277566ce1 | ||
|
|
959995c715 | ||
|
|
7a1e10f3a7 | ||
|
|
b15bf0dc70 | ||
|
|
506950b03a | ||
|
|
94353458a5 | ||
|
|
7eea95a48c | ||
|
|
94bf49440d | ||
|
|
0644abe402 | ||
|
|
9036945361 | ||
|
|
dd5bafe8af | ||
|
|
548c7f17d7 | ||
|
|
bb7f4d408a | ||
|
|
6ee30042e7 | ||
|
|
0554bbb1cb | ||
|
|
517a57b7ca | ||
|
|
35ea29b184 | ||
|
|
ee68c9ea55 | ||
|
|
d65471c420 | ||
|
|
de8f94b008 | ||
|
|
0ac00b9256 | ||
|
|
d0db4756a6 | ||
|
|
adaa614986 | ||
|
|
5ab783001d | ||
|
|
2a163996f9 | ||
|
|
1b37bdcec8 | ||
|
|
39b0c06630 | ||
|
|
2b7dd6e399 | ||
|
|
c5a8d2f857 | ||
|
|
93d7702e8c | ||
|
|
0447d90951 | ||
|
|
8f8c34471e | ||
|
|
883ad55f5c | ||
|
|
82e5a6410c | ||
|
|
88b93241db | ||
|
|
b652b8e5f3 | ||
|
|
1ac6879268 | ||
|
|
825becceb0 | ||
|
|
fffa793d6d | ||
|
|
289d49dbe7 | ||
|
|
fa72c277db | ||
|
|
8799ff9575 | ||
|
|
85c8a9bd68 | ||
|
|
15f6e3af12 | ||
|
|
c79fa4214f | ||
|
|
29d5745335 | ||
|
|
70bb0562f3 | ||
|
|
c8210d4043 | ||
|
|
9f470a4ff5 | ||
|
|
cea5c1a63b | ||
|
|
4ce05ad279 | ||
|
|
f3c2614b6c | ||
|
|
79cca06e0c | ||
|
|
b60beb6ff6 | ||
|
|
295c7eb4c7 | ||
|
|
361584b4fd | ||
|
|
abba7c128c | ||
|
|
e0ec2cdeb0 | ||
|
|
bb0e11bb97 | ||
|
|
fc5c8dbe3f | ||
|
|
1d30d8a13a | ||
|
|
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:
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: uv
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: '/backend'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every week
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
|
||||
@@ -70,3 +70,7 @@
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach any relevant screenshots or videos demonstrating the changes]
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -5,10 +5,18 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -17,7 +25,9 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.11]
|
||||
python-version:
|
||||
- 3.11.x
|
||||
- 3.12.x
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -25,7 +35,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: '${{ matrix.python-version }}'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -5,10 +5,18 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -21,10 +29,10 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22' # Or specify any other version you want to use
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: npm install --force
|
||||
|
||||
- name: Format Frontend
|
||||
run: npm run format
|
||||
@@ -51,7 +59,7 @@ jobs:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
run: npm ci --force
|
||||
|
||||
- name: Run vitest
|
||||
run: npm run test:frontend
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -1,3 +1,5 @@
|
||||
x.py
|
||||
yarn.lock
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
@@ -12,7 +14,8 @@ vite.config.ts.timestamp-*
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
.nvmrc
|
||||
CLAUDE.md
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
|
||||
+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"
|
||||
}
|
||||
|
||||
+756
@@ -5,6 +5,762 @@ 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.24] - 2025-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- ♿ **High Contrast Mode in Chat Messages**: Implemented enhanced High Contrast Mode support for chat messages, making text and important details easier to read and improving accessibility for users with visual preferences or requirements.
|
||||
- 🌎 **Localization & Internationalization Improvements**: Enhanced and expanded translations for a more natural and professional user experience for speakers of these languages across the entire interface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🖼️ **ComfyUI Image Generation Restored**: Fixed a critical bug where ComfyUI-based image generation was not functioning, ensuring users can once again effortlessly create and interact with AI-generated visuals in their workflows.
|
||||
- 🛠️ **Tool Server Loading and Visibility Restored**: Resolved an issue where connected tool servers were not loading or visible, restoring seamless integration and uninterrupted access to all external and custom tools directly within the platform.
|
||||
- 🛡️ **Redis User Session Reliability**: Fixed a problem affecting the saving of user sessions in Redis, ensuring reliable login sessions, stable authentication, and secure multi-user environments.
|
||||
|
||||
## [0.6.23] - 2025-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡ **Asynchronous Chat Payload Processing**: Refactored the chat completion pipeline to return a response immediately for streaming requests involving web search or tool calls. This enables users to stop ongoing generations promptly and preventing network timeouts during lengthy preprocessing phases, thus significantly improving user experience and responsiveness.
|
||||
- 📁 **Asynchronous File Upload with Polling**: Implemented an asynchronous file upload process with frontend polling to resolve gateway timeouts and improve reliability when uploading large files. This ensures that even lengthy file processing, such as embedding or transcription, does not block the user interface or lead to connection timeouts, providing a smoother experience for all file operations.
|
||||
- 📈 **Database Performance Indexes and Migration Script**: Introduced new database indexes on the "chat", "tag", and "function" tables to significantly enhance query performance for SQLite and PostgreSQL installations. For existing deployments, a new Alembic migration script is included to seamlessly apply these indexes, ensuring faster filtering and sorting operations across the platform.
|
||||
- ✨ **Enhanced Database Performance Options**: Introduced new configurable options to significantly improve database performance, especially for SQLite. This includes "DATABASE_ENABLE_SQLITE_WAL" to enable SQLite WAL (Write-Ahead Logging) mode for concurrent operations, and "DATABASE_DEDUPLICATE_INTERVAL" which, in conjunction with a new deduplication mechanism, reduces redundant updates to "user.last_active_at", minimizing write conflicts across all database types.
|
||||
- 💾 **Save Temporary Chats Button**: Introduced a new 'Save Chat' button for conversations initiated in temporary mode. This allows users to permanently save valuable temporary conversations to their chat history, providing greater flexibility and ensuring important discussions are not lost.
|
||||
- 📂 **Chat Movement Options in Menu**: Added the ability to move chats directly to folders from the chat menu. This enhances chat organization and allows users to manage their conversations more efficiently by relocating them between folders with ease.
|
||||
- 💬 **Language-Aware Follow-Up Suggestions**: Enhanced the AI's follow-up question generation to dynamically adapt to the primary language of the current chat. Follow-up prompts will now be suggested in the same language the user and AI are conversing in, ensuring more natural and contextually relevant interactions.
|
||||
- 👤 **Expanded User Profile Details**: Introduced new user profile fields including username, bio, gender, and date of birth, allowing for more comprehensive user customization and information management. This enhancement includes corresponding updates to the database schema, API, and user interface for seamless integration.
|
||||
- 👥 **Direct Navigation to User Groups from User Edit**: Enhanced the user edit modal to include a direct link to the associated user group. This allows administrators to quickly navigate from a user's profile to their group settings, streamlining user and group management workflows.
|
||||
- 🔧 **Enhanced External Tool Server Compatibility**: Improved handling of responses from external tool servers, allowing both the backend and frontend to process plain text content in addition to JSON, ensuring greater flexibility and integration with diverse tool outputs.
|
||||
- 🗣️ **Enhanced Audio Transcription Language Fallback and Deepgram Support**: Implemented a robust language fallback mechanism for both OpenAI and Deepgram Speech-to-Text (STT) API calls. If a specified language parameter is not supported by the model or provider, the system will now intelligently retry the transcription without the language parameter or with a default, ensuring greater reliability and preventing failed API calls. This also specifically adds and refines support for the audio language parameter in Deepgram API integrations.
|
||||
- ⚡ **Optimized Hybrid Search Performance for BM25 Weight Configuration**: Enhanced hybrid search to significantly improve performance when the BM25 weight is set to 0 or less. This optimization intelligently disables unnecessary collection retrieval and BM25 ranking calculations, leading to faster search results without impacting accuracy for configurations that do not utilize lexical search contributions.
|
||||
- 🔒 **Configurable Code Interpreter Module Blacklist**: Introduced the "CODE_INTERPRETER_BLACKLISTED_MODULES" environment variable, allowing administrators to specify Python modules that are forbidden from being imported or executed within the code interpreter. This significantly enhances the security posture by mitigating risks associated with arbitrary code execution, such as unauthorized data access, system manipulation, or outbound connections.
|
||||
- 🔐 **Enhanced OAuth Role Claim Handling**: Improved compatibility with diverse OAuth providers by allowing role claims to be supplied as single strings or integers, in addition to arrays. The system now automatically normalizes these single-value claims into arrays for consistent processing, streamlining integration with identity providers that format role data differently.
|
||||
- ⚙️ **Configurable Tool Call Timeout**: Introduced the "AIOHTTP_CLIENT_TIMEOUT" environment variable, allowing administrators to specify custom timeout durations for external tool calls, which is crucial for integrations with tools that have varying or extended response times.
|
||||
- 🛠️ **Improved Tool Callable Generation for Google genai SDK**: Enhanced the creation of tool callables to directly support native function calling within the Google 'genai' SDK. This refactoring ensures proper signature inference and removes extraneous parameters, enabling seamless integration for advanced AI workflows using Google's generative AI models.
|
||||
- ✨ **Dynamic Loading of 'kokoro-js'**: Implemented dynamic loading for the 'kokoro-js' library, preventing failures and improving compatibility on older iOS browsers that may not support direct imports or certain modern JavaScript APIs like 'DecompressionStream'.
|
||||
- 🖥️ **Improved Command List Visibility on Small Screens**: Resolved an issue where the top items in command lists (e.g., Knowledge Base, Models, Prompts) were hidden or overlapped by the header on smaller screen sizes or specific browser zoom levels. The command option lists now dynamically adjust their height, ensuring all items are fully visible and accessible with proper scrolling.
|
||||
- 📦 **Improved Docker Image Compatibility for Arbitrary UIDs**: Fixed issues preventing the Open WebUI container from running in environments with arbitrary User IDs (UIDs), such as OpenShift's restricted Security Context Constraints (SCC). The Dockerfile has been updated to correctly set file system permissions for "/app" and "/root" directories, ensuring they are writable by processes running with a supplemental GID 0, thus resolving permission errors for Python libraries and application caches.
|
||||
- ♿ **Accessibility Enhancements**: Significantly improved the semantic structure of chat messages by using "section", "h2", "ul", and "li" HTML tags, and enhanced screen reader compatibility by explicitly hiding decorative images with "aria-hidden" attributes. This refactoring provides clearer structural context and improves overall accessibility and web standards compliance for the conversation flow.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Significantly expanded internationalization support throughout the user interface, translating numerous user-facing strings in toast messages, placeholders, and other UI elements. This, alongside continuous refinement and expansion of translations for languages including Brazilian Portuguese, Kabyle (Taqbaylit), Czech, Finnish, Chinese (Simplified), Chinese (Traditional), and German, and general fixes for several other translation files, further enhances linguistic coverage and user experience.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Resolved Critical OIDC SSO Login Failure**: Fixed a critical issue where OIDC Single Sign-On (SSO) logins failed due to an error in setting the authentication token as a cookie during the redirect process. This ensures reliable and seamless authentication for users utilizing OIDC providers, restoring full login functionality that was impacted by previous security hardening.
|
||||
- ⚡ **Prevented UI Blocking by Unreachable Webhooks**: Resolved a critical performance and user experience issue where synchronous webhook calls to unreachable or slow endpoints would block the entire user interface for all users. Webhook requests are now processed asynchronously using "aiohttp", ensuring that the UI remains responsive and functional even if webhook delivery encounters delays or failures.
|
||||
- 🔒 **Password Change Option Hidden for Externally Authenticated Users**: Resolved an issue where the password change dialog was visible to users authenticated via external methods (e.g., LDAP, OIDC, Trusted Header). The option to change a password in user settings is now correctly hidden for these users, as their passwords are managed externally, streamlining the user interface and preventing confusion.
|
||||
- 💬 **Resolved Temporary Chat and Permission Enforcement Issues**: Fixed a bug where temporary chats (identified by "chat_id = local") incorrectly triggered database checks, leading to 404 errors. This also resolves the issue where the 'USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED' setting was not functioning as intended, ensuring temporary chat mode now works correctly for user roles.
|
||||
- 🔐 **Admin Model Visibility for Administrators**: Private models remained visible and usable for administrators in the chat model selector, even when the intended privacy setting ("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" - now renamed to "BYPASS_ADMIN_ACCESS_CONTROL") was disabled. This ensures consistent enforcement of model access controls and adherence to the principle of least privilege.
|
||||
- 🔍 **Clarified Web Search Engine Label for DDGS**: Addressed user confusion and inaccurate labeling by renaming "duckduckgo" to "DDGS" (Dux Distributed Global Search) in the web search engine selector. This clarifies that the system utilizes DDGS, a metasearch library that aggregates results from various search providers, accurately reflecting its underlying functionality rather than implying exclusive use of DuckDuckGo's search engine.
|
||||
- 🛠️ **Improved Settings UI Reactivity and Visibility**: Resolved an issue where settings tabs for 'Connections' and 'Tools' did not dynamically update their visibility based on global administrative feature flags (e.g., 'enable_direct_connections'). The UI now reactively shows or hides these sections, ensuring a consistent and clear experience when administrators control feature availability.
|
||||
- 🎚️ **Restored Model and Banner Reordering Functionality**: Fixed a bug that prevented administrators from reordering models in the Admin Panel's 'Models' settings and banners in the 'Interface' settings via drag-and-drop. The sortable functionality has been restored, allowing for proper customization of display order.
|
||||
- 📝 **Restored Custom Pending User Overlay Visibility**: Fixed an issue where the custom title and description configured for pending users were not visible. The application now correctly exposes these UI configuration settings to pending users, ensuring that the custom onboarding messages are displayed as intended.
|
||||
- 📥 **Fixed Community Function Import Compatibility**: Resolved an issue that prevented the successful import of function files downloaded from openwebui.com due to schema differences. The system now correctly processes these files, allowing for seamless integration of community-contributed functions.
|
||||
- 📦 **Fixed Stale Ollama Version in Docker Images**: Resolved an issue where the Ollama installation within Docker images could become stale due to caching during the build process. The Dockerfile now includes a mechanism to invalidate the build cache for the Ollama installation step, ensuring that the latest version of Ollama is always installed.
|
||||
- 🗄️ **Improved Milvus Query Handling for Large Datasets**: Fixed a "MilvusException" that occurred when attempting to query more than 16384 entries from a Milvus collection. The query logic has been refactored to use "query_iterator()", enabling efficient fetching of larger result sets in batches and resolving the previous limitation on the number of entries that could be retrieved.
|
||||
- 🐛 **Restored Message Toolbar Icons for Empty Messages with Files**: Fixed an issue where the edit, copy, and delete icons were not displayed on user messages that contained an attached file but no text content. This ensures full interaction capabilities for all message types, allowing users to manage their messages consistently.
|
||||
- 💬 **Resolved Streaming Interruption for Kimi-Dev Models**: Fixed an issue where streaming responses from Kimi-Dev models would halt prematurely upon encountering specific 'thinking' tokens (◁think▷, ◁/think▷). The system now correctly processes these tokens, ensuring uninterrupted streaming and proper handling of hidden or collapsible thinking sections.
|
||||
- 🔍 **Enhanced Knowledge Base Search Functionality**: Improved the search capability within the 'Knowledge' section of the Workspace. Previously, searching for knowledge bases required exact term matches or starting with the first letter. Now, the search algorithm has been refined to allow broader, less exact matches, making it easier and more intuitive to find relevant knowledge bases.
|
||||
- 📝 **Resolved Chinese Input 'Enter' Key Issue (macOS & iOS Safari)**: Fixed a bug where pressing the 'Enter' key during text composition with Input Method Editors (IMEs) on macOS and iOS Safari browsers would prematurely send the message. The system now robustly handles the composition state by addressing a 'compositionend' event bug specific to Safari, ensuring a smooth and expected typing experience for users of various languages, including Chinese and Korean.
|
||||
- 🔐 **Resolved OAUTH_GROUPS_CLAIM Configuration Issue**: Fixed a bug where the "OAUTH_GROUPS_CLAIM" environment variable was not correctly parsed due to a typo in the configuration file. This ensures that OAuth group management features, including automatic group creation, now correctly utilize the specified claim from the identity provider, allowing for seamless integration with external user directories like Keycloak.
|
||||
- 🗄️ **Resolved Azure PostgreSQL pgvector Extension Permissions**: Fixed an issue preventing the creation of "pgvector" and "pgcrypto" extensions on Azure PostgreSQL Flexible Servers due to permission limitations (e.g., 'Only members of "azure_pg_admin" are allowed to use "CREATE EXTENSION"'). The extension creation process now includes a conditional check, ensuring seamless deployment and compatibility with Azure PostgreSQL environments even with restricted database user permissions.
|
||||
- 🛠️ **Improved Backend Path Resolution and Alembic Stability**: Fixed issues causing Alembic database migrations to fail due to incorrect path resolution within the application. By implementing canonical path resolution for core directories and refining Alembic configuration, the robustness and correctness of internal pathing have been significantly enhanced, ensuring reliable database operations.
|
||||
- 📊 **Resolved Arena Model Identification in Feedback History**: Fixed an issue where the model used for feedback in arena settings was incorrectly reported as 'arena-model' in the evaluation history. The system now correctly logs and displays the actual model ID that received the feedback, restoring clarity and enabling proper analysis of model performance in arena environments.
|
||||
- 🎨 **Resolved Icon Overlap in 'Her' Theme**: Fixed a visual glitch in the 'Her' theme where icons would overlap on the loading screen and certain icons appeared incongruous. The display has been corrected to ensure proper visual presentation and theme consistency.
|
||||
- 🛠️ **Resolved Model Sorting TypeError with Null Names**: Fixed a "TypeError" that occurred in the "/api/models" endpoint when sorting models with null or missing names. The model sorting logic has been improved to gracefully handle such edge cases by ensuring that model IDs and names are treated as empty strings if their values are null or undefined, preventing comparison errors and improving API stability.
|
||||
- 💬 **Resolved Silently Dropped Streaming Response Chunks**: Fixed an issue where the final partial chunks of streaming chat responses could be silently dropped, leading to incomplete message delivery. The system now reliably flush any pending delta data upon stream termination, early breaks (e.g., code interpreter tags), or connection closure, ensuring complete and accurate response delivery.
|
||||
- 📱 **Disabled Overscroll for iOS Frontend**: Fixed an issue where overscrolling was enabled on iOS devices, causing unexpected scrolling behavior over fixed or sticky elements within the PWA. Overscroll has now been disabled, providing a more native application-like experience for iOS users.
|
||||
- 📝 **Resolved Code Block Input Issue with Shift+Enter**: Fixed a bug where typing three backticks followed by a language and then pressing Shift+Enter would cause the code block prefix to disappear, preventing proper code formatting. The system now correctly preserves the code block syntax, ensuring consistent behavior for multi-line code input.
|
||||
- 🛠️ **Improved OpenAI Model List Handling for Null Names**: Fixed an edge case where some OpenAI-compatible API providers might return models with a null value for their 'name' field. This could lead to issues like broken model list sorting. The system now gracefully handles these instances by removing the null 'name' key, ensuring stable model retrieval and display.
|
||||
- 🔍 **Resolved DDGS Concurrent Request Configuration**: Fixed an issue where the configured number of concurrent requests was not being honored for the DDGS (Dux Distributed Global Search) metasearch engine. The system now correctly applies the specified concurrency setting, improving efficiency for web searches.
|
||||
- 🛠️ **Improved Tool List Synchronization in Multi-Replica Deployments**: Resolved an issue where tool updates were not consistently reflected across all instances in multi-replica environments, leading to stale tool lists for users on other replicas. The tool list in the message input menu is now automatically refreshed each time it is accessed, ensuring all users always see the most current set of available tools.
|
||||
- 🛠️ **Resolved Duplicate Tool Name Collision**: Fixed an issue where tools with identical names from different external servers were silently removed, preventing their simultaneous use. The system now correctly handles tool name collisions by internally prefixing tools with their server identifier, allowing multiple instances of similarly named tools from different servers to be active and usable by LLMs.
|
||||
- 🖼️ **Resolved Image Generation API Size Parameter Issue**: Fixed a bug where the "/api/v1/images/generations" API endpoint did not correctly apply the 'size' parameter specified in the request payload for image generation. The system now properly honors the requested image dimensions (e.g., '1980x1080'), ensuring that generated images match the user's explicit size preference rather than defaulting to settings.
|
||||
- 🗄️ **Resolved S3 Vector Upload Limitations**: Fixed an issue that prevented uploading more than 500 vectors to S3 Vector buckets due to API limitations, which resulted in a "ValidationException". S3 vector uploads are now batched in groups of 500, ensuring successful processing of larger datasets.
|
||||
- 🛠️ **Fixed Tool Installation Error During Startup**: Resolved a "NoneType" error that occurred during tool installation at startup when 'tool.user' was unexpectedly null. The system now includes a check to ensure 'tool.user' exists before attempting to access its properties, preventing crashes and ensuring robust tool initialization.
|
||||
- 🛠️ **Improved Azure OpenAI GPT-5 Parameter Handling**: Fixed an issue with Azure OpenAI SDK parameter handling to correctly support GPT-5 models. The 'max_tokens' parameter is now appropriately converted to 'max_completion_tokens' for GPT-5 models, ensuring consistent behavior and proper function execution similar to existing o-series models.
|
||||
- 🐛 **Resolved Exception with Missing Group Permissions**: Fixed an exception that occurred in the access control logic when group permission objects were missing or null. The system now correctly handles cases where groups may not have explicit permission definitions, ensuring that 'None' checks prevent errors and maintain application stability when processing user permissions.
|
||||
- 🛠️ **Improved OpenAI API Base URL Handling**: Fixed an issue where a trailing slash in the 'OPENAI_API_BASE_URL' configuration could lead to models not being detected or the endpoint failing. The system now automatically removes trailing slashes from the configured URL, ensuring robust and consistent connections to OpenAI-compatible APIs.
|
||||
- 🖼️ **Resolved S3-Compatible Storage Upload Failures**: Fixed an issue where uploads to S3-compatible storage providers would fail with an "XAmzContentSHA256Mismatch" error. The system now correctly handles checksum calculations, ensuring reliable file and image uploads to S3-compatible services.
|
||||
- 🌐 **Corrected 'Releases' Link**: Fixed an issue where the 'Releases' button in the user menu directed to an incorrect URL, now correctly linking to the Open WebUI GitHub releases page.
|
||||
- 🛠️ **Resolved Model Sorting Errors with Null or Undefined Names**: Fixed multiple "TypeError" instances that occurred when attempting to sort model lists where model names were null or undefined. The sorting logic across various UI components (including Ollama model selection, leaderboard, and admin model settings) has been made more robust by gracefully handling absent model names, preventing crashes and ensuring consistent alphabetical sorting based on available name or ID.
|
||||
- 🎨 **Resolved Banner Dismissal Issue with Iteration IDs**: Fixed a bug where dismissing banners could lead to unintended multiple banner dismissals or other incorrect behavior, especially when banners lacked unique iteration IDs. Unique IDs are now assigned during banner iteration, ensuring proper individual dismissal and consistent display behavior.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛂 **Environment Variable for Admin Access Control**: The environment variable "ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" has been renamed to "BYPASS_ADMIN_ACCESS_CONTROL". This new name more accurately reflects its function as a control to allow administrators to bypass model access restrictions. Users are encouraged to update their configurations to use the new variable name; existing configurations using the old name will still be honored for backward compatibility.
|
||||
- 🗂️ **Core Directory Path Resolution Updated**: The internal mechanism for resolving core application directory paths ("OPEN_WEBUI_DIR", "BACKEND_DIR", "BASE_DIR") has been updated to use canonical resolution via "Path().resolve()". This change improves path reliability but may require adjustments for any external scripts or configurations that previously relied on specific non-canonical path interpretations.
|
||||
- 🗃️ **Database Performance Options**: New database performance options, "DATABASE_ENABLE_SQLITE_WAL" and "DATABASE_DEDUPLICATE_INTERVAL", are now available. If "DATABASE_ENABLE_SQLITE_WAL" is enabled, SQLite will operate in WAL mode, which may alter SQLite's file locking behavior. If "DATABASE_DEDUPLICATE_INTERVAL" is set to a non-zero value, the "user.last_active_at" timestamp will be updated less frequently, leading to slightly less real-time accuracy for this specific field but significantly reducing database write conflicts and improving overall performance. Both options are disabled by default.
|
||||
- 🌐 **Renamed Web Search Concurrency Setting**: The environment variable "WEB_SEARCH_CONCURRENT_REQUESTS" has been renamed to "WEB_LOADER_CONCURRENT_REQUESTS". This change clarifies its scope, explicitly applying to the concurrency of the web loader component (which fetches content from search results) rather than the initial search engine query. Users relying on the old environment variable name for configuring web search concurrency must update their configurations to use "WEB_LOADER_CONCURRENT_REQUESTS".
|
||||
|
||||
## [0.6.22] - 2025-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- 🔗 **OpenAI API '/v1' Endpoint Compatibility**: Enhanced API compatibility by supporting requests to paths like '/v1/models', '/v1/embeddings', and '/v1/chat/completions'. This allows Open WebUI to integrate more seamlessly with tools that expect OpenAI's '/v1' API structure.
|
||||
- 🪄 **Toggle for Guided Response Regeneration Menu**: Introduced a new setting in 'Interface' settings, providing the ability to enable or disable the expanded guided response regeneration menu. This offers users more control over their chat workflow and interface preferences.
|
||||
- ✨ **General UI/UX Enhancements**: Implemented various user interface and experience improvements, including more rounded corners for cards in the Knowledge, Prompts, and Tools sections, and minor layout adjustments within the chat Navbar for improved visual consistency.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Introduced support for the Kabyle (Taqbaylit) language, refined and expanded translations for Chinese, expanding the platform's linguistic coverage.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐞 **OpenAI Error Message Propagation**: Resolved an issue where specific OpenAI API errors (e.g., 'Organization Not Verified') were obscured by generic 'JSONResponse' iterable errors. The system now correctly propagates detailed and actionable error messages from OpenAI to the user.
|
||||
- 🌲 **Pinecone Insert Issue**: Fixed a bug that prevented proper insertion of items into Pinecone vector databases.
|
||||
- 📦 **S3 Vector Issue**: Resolved a bug where s3vector functionality failed due to incorrect import paths.
|
||||
- 🏠 **Landing Page Option Setting Not Working**: Fixed an issue where the landing page option in settings was not functioning as intended.
|
||||
|
||||
## [0.6.21] - 2025-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- 👥 **User Groups in Edit Modal**: Added display of user groups information in the user edit modal, allowing administrators to view and manage group memberships directly when editing a user.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐞 **Chat Completion 'model_id' Error**: Resolved a critical issue where chat completions failed with an "undefined model_id" error after upgrading to version 0.6.20, ensuring all models now function correctly and reliably.
|
||||
- 🛠️ **Audit Log User Information Logging**: Fixed an issue where user information was not being correctly logged in the audit trail due to an unreflected function prototype change, ensuring complete logging for administrative oversight.
|
||||
- 🛠️ **OpenTelemetry Configuration Consistency**: Fixed an issue where OpenTelemetry metric and log exporters' 'insecure' settings did not correctly default to the general OpenTelemetry 'insecure' flag, ensuring consistent security configurations across all OpenTelemetry exports.
|
||||
- 📝 **Reply Input Content Display**: Fixed an issue where replying to a message incorrectly displayed '{{INPUT_CONTENT}}' instead of the actual message content, ensuring proper content display in replies.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Korean, Spanish and Irish, ensuring a more fluent and native experience for global users.
|
||||
|
||||
## [0.6.20] - 2025-08-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ **Quick Actions "Add" Behavior**: Fixed a bug where using the "Add" button in Quick Actions would add the resulting message as the very first message in the chat, instead of appending it to the latest message.
|
||||
|
||||
## [0.6.19] - 2025-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- ✨ **Modernized Sidebar and Major UI Refinements**: The main navigation sidebar has been completely redesigned with a modern, cleaner aesthetic, featuring a sticky header and footer to keep key controls accessible. Core sidebar logic, like the pinned models list, was also refactored into dedicated components for better performance and maintainability.
|
||||
- 🪄 **Guided Response Regeneration**: The "Regenerate" button has been transformed into a powerful new menu. You can now guide the AI's next attempt by suggesting changes in a text prompt, or use one-click options like "Try Again," "Add Details," or "More Concise" to instantly refine and reshape the response to better fit your needs.
|
||||
- 🛠️ **Improved Tool Call Handling for GPT-OSS Models**: Implemented robust handling for tool calls specifically for GPT-OSS models, ensuring proper function execution and integration.
|
||||
- 🛑 **Stop Button for Merge Responses**: Added a dedicated stop button to immediately halt the generation of merged AI responses, providing users with more control over ongoing outputs.
|
||||
- 🔄 **Experimental SCIM 2.0 Support**: Implemented SCIM 2.0 (System for Cross-domain Identity Management) protocol support, enabling enterprise-grade automated user and group provisioning from identity providers like Okta, Azure AD, and Google Workspace for seamless user lifecycle management. Configuration is managed securely via environment variables.
|
||||
- 🗂️ **Amazon S3 Vector Support**: You can now use Amazon S3 Vector as a high-performance vector database for your Retrieval-Augmented Generation (RAG) workflows. This provides a scalable, cloud-native storage option for users deeply integrated into the AWS ecosystem, simplifying infrastructure and enabling enterprise-scale knowledge management.
|
||||
- 🗄️ **Oracle 23ai Vector Search Support**: Added support for Oracle 23ai's new vector search capabilities as a supported vector database, providing a robust and scalable option for managing large-scale documents and integrating vector search with existing business data at the database level.
|
||||
- ⚡ **Qdrant Performance and Configuration Enhancements**: The Qdrant client has been significantly improved with faster data retrieval logic for 'get' and 'query' operations. New environment variables ('QDRANT_TIMEOUT', 'QDRANT_HNSW_M') provide administrators with finer control over query timeouts and HNSW index parameters, enabling better performance tuning for large-scale deployments.
|
||||
- 🔐 **Encrypted SQLite Database with SQLCipher**: You can now encrypt your entire SQLite database at rest using SQLCipher. By setting the 'DATABASE_TYPE' to 'sqlite+sqlcipher' and providing a 'DATABASE_PASSWORD', all data is transparently encrypted, providing an essential security layer for protecting sensitive information in self-hosted deployments. Note that this requires additional system libraries and the 'sqlcipher3-wheels' Python package.
|
||||
- 🚀 **Efficient Redis Connection Management**: Implemented a shared connection pool cache to reuse Redis connections, dramatically reducing the number of active clients. This prevents connection exhaustion errors, improves performance, and ensures greater stability in high-concurrency deployments and those using Redis Sentinel.
|
||||
- ⚡ **Batched Response Streaming for High Performance**: Dramatically improve performance and stability during high-speed response streaming by batching multiple tokens together before sending them to the client. A new 'Stream Delta Chunk Size' advanced parameter can be set per-model or in user/chat settings, significantly reducing CPU load on the server, Redis, and client, and preventing connection issues in high-concurrency environments.
|
||||
- ⚙️ **Global Batched Streaming Configuration**: Administrators can now set a system-wide default for response streaming using the new 'CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE' environment variable. This allows for global performance tuning, while still letting per-model or per-chat settings override the default for more granular control.
|
||||
- 🔎 **Advanced Chat Search with Status Filters**: Quickly find any conversation with powerful new search filters. You can now instantly narrow down your chats using prefixes like 'pinned:true', 'shared:true', and 'archived:true' directly in the search bar. An intelligent dropdown menu assists you by suggesting available filter options as you type, streamlining your workflow and making chat management more efficient than ever.
|
||||
- 🛂 **Granular Chat Controls Permissions**: Administrators can now manage chat settings with greater detail. The main "Chat Controls" permission now acts as a master switch, while new granular toggles for "Valves", "System Prompts", and "Advanced Parameters" allow for more specific control over which sections are visible to users inside the panel.
|
||||
- ✍️ **Formatting Toolbar for Chat Input**: Introduced a dedicated formatting toolbar for the rich text chat input field, providing users with more accessible options for text styling and editing, configurable via interface settings.
|
||||
- 📑 **Tabbed View for Multi-Model Responses**: You can now enable a new tabbed interface to view responses from multiple models. Instead of side-scrolling cards, this compact view organizes each model's response into its own tab, making it easier to compare outputs and saving vertical space. This feature can be toggled on or off in Interface settings.
|
||||
- ↕️ **Reorder Pinned Models via Drag-and-Drop**: You can now organize your pinned models in the sidebar by simply dragging and dropping them into your preferred order. This custom layout is saved automatically, giving you more flexible control over your workspace.
|
||||
- 📌 **Quick Model Unpin Shortcut**: You can now quickly unpin a model by holding the Shift key and hovering over it to reveal an instant unpin button, streamlining your workspace customization.
|
||||
- ⚡ **Improved Chat Input Performance**: The chat input is now significantly more responsive, especially when pasting or typing large amounts of text. This was achieved by implementing a debounce mechanism for the auto-save feature, which prevents UI lag and ensures a smooth, uninterrupted typing experience.
|
||||
- ✍️ **Customizable Floating Quick Actions with Tool Support**: Take full control of your text interaction workflow with new customizable floating quick actions. In Settings, you can create, edit, or disable these actions and even integrate tools using the '{{TOOL:tool_id}}' syntax in your prompts, enabling powerful one-click automations on selected text. This is in addition to using placeholders like '{{CONTENT}}' and '{{INPUT_CONTENT}}' for custom text transformations.
|
||||
- 🔒 **Admin Workspace Privacy Control**: Introduced the 'ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS' environment variable (defaults to 'True') allowing administrators to control their access privileges to workspace items (Knowledge, Models, Prompts, Tools). When disabled, administrators adhere to the same access control rules as regular users, enhancing data separation for multi-tenant deployments.
|
||||
- 🗄️ **Comprehensive Model Configuration Management**: Administrators can now export the entire model configuration to a file and use a new declarative sync endpoint to manage models in bulk. This powerful feature enables seamless backups, migrations, and state replication across multiple instances.
|
||||
- 📦 **Native Redis Cluster Mode Support**: Added full support for connecting to Redis in cluster mode, allowing for scalable and highly available Redis deployments beyond Sentinel-managed setups. New environment variables 'REDIS_CLUSTER' and 'WEBSOCKET_REDIS_CLUSTER' enable the use of 'redis.cluster.RedisCluster' clients.
|
||||
- 📊 **Granular OpenTelemetry Metrics Configuration**: Introduced dedicated environment variables and enhanced configuration options for OpenTelemetry metrics, allowing for separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for metrics export, independent of trace settings. This provides greater flexibility for integrating with diverse observability stacks.
|
||||
- 🪵 **Granular OpenTelemetry Logging Configuration**: Enhanced the OpenTelemetry logging integration by introducing dedicated environment variables for logs, allowing separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for log export, independent of general OTel settings. The application's default Python logger now leverages this configuration to automatically send logs to your OTel endpoint when enabled via 'ENABLE_OTEL_LOGS'.
|
||||
- 📁 **Enhanced Folder Chat Management with Sorting and Time Blocks**: The chat list within folders now supports comprehensive sorting options by title and updated time, along with intelligent time-based grouping (e.g., "Today," "Yesterday") similar to the main chat view, making navigation and organization of project-specific conversations significantly easier.
|
||||
- ⚙️ **Configurable Datalab Marker API & Advanced Processing Options**: Enhanced Datalab Marker API integration, allowing administrators to configure custom API base URLs for self-hosting and to specify comprehensive processing options via a new 'additional_config' JSON parameter. This replaces the deprecated language selection feature and provides granular control over document extraction, with streamlined API endpoint resolution for more robust self-hosted deployments.
|
||||
- 🧑💼 **Export All Users to CSV**: Administrators can now export a complete list of all users to a CSV file directly from the Admin Panel's database settings. This provides a simple, one-click way to generate user data for auditing, reporting, or management purposes.
|
||||
- 🛂 **Customizable OAuth 'sub' Claim**: Administrators can now use the 'OAUTH_SUB_CLAIM_OVERRIDE' environment variable to specify which claim from the identity provider should be used as the unique user identifier ('sub'). This provides greater flexibility and control for complex enterprise authentication setups where modifying the IDP's default claims is not possible.
|
||||
- 👁️ **Password Visibility Toggle for Input Fields**: Password fields across the application (login, registration, user management, and account settings) now utilize a new 'SensitiveInput' component, providing a consistent toggle to reveal/hide passwords for improved usability and security.
|
||||
- 🛂 **Optional "Confirm Password" on Sign-Up**: To help prevent password typos during account creation, administrators can now enable a "Confirm Password" field on the sign-up page. This feature is disabled by default and can be activated via an environment variable for enhanced user experience.
|
||||
- 💬 **View Full Chat from User Feedback**: Administrators can now easily navigate to the full conversation associated with a user feedback entry directly from the feedback modal, streamlining the review and troubleshooting process.
|
||||
- 🎚️ **Intuitive Hybrid Search BM25-Weight Slider**: The numerical input for the BM25-Weight parameter in Hybrid Search has been replaced with an interactive slider, offering a more intuitive way to adjust the balance between lexical and semantic search. A "Default/Custom" toggle and clearer labels enhance usability and understanding of this key parameter.
|
||||
- ⚙️ **Enhanced Bulk Function Synchronization**: The API endpoint for synchronizing functions has been significantly improved to reliably handle bulk updates. This ensures that importing and managing large libraries of functions is more robust and error-free for administrators.
|
||||
- 🖼️ **Option to Disable Image Compression in Channels**: Introduced a new setting under Interface options to allow users to force-disable image compression specifically for images posted in channels, ensuring higher resolution for critical visual content.
|
||||
- 🔗 **Custom CORS Scheme Support**: Introduced a new environment variable 'CORS_ALLOW_CUSTOM_SCHEME' that allows administrators to define custom URL schemes (e.g., 'app://') for CORS origins, enabling greater flexibility for local development or desktop client integrations.
|
||||
- ♿ **Translatable and Accessible Banners**: Enhanced banner elements with translatable badge text and proper ARIA attributes (aria-label, aria-hidden) for SVG icons, significantly improving accessibility and screen reader compatibility.
|
||||
- ⚠️ **OAuth Configuration Warning for Missing OPENID_PROVIDER_URL**: Added a proactive startup warning that notifies administrators when OAuth providers (Google, Microsoft, or GitHub) are configured but the essential 'OPENID_PROVIDER_URL' environment variable is missing. This prevents silent OAuth logout failures and guides administrators to complete their setup correctly.
|
||||
- ♿ **Major Accessibility Enhancements**: Key parts of the interface have been made significantly more accessible. The user profile menu is now fully navigable via keyboard, essential controls in the Playground now include proper ARIA labels for screen readers, and decorative images have been hidden from assistive technologies to reduce audio clutter. Menu buttons also feature enhanced accessibility with 'aria-label', 'aria-hidden' for SVGs, and 'aria-pressed' for toggle buttons.
|
||||
- ⚙️ **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users, including refining logging output to be cleaner and more efficient by conditionally including 'extra_json' fields and improving consistent metadata handling in vector database operations, and laying preliminary scaffolding for future analytics features.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Danish, Korean, Persian, Polish, Simplified Chinese, and Spanish, ensuring a more fluent and native experience for global users across all supported languages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Hardened Channel Message Security**: Fixed a key permission flaw that allowed users with channel access to edit or delete messages belonging to others. The system now correctly enforces that users can only modify their own messages, protecting data integrity in shared channels.
|
||||
- 🛡️ **Hardened OAuth Security by Removing JWT from URL**: Fixed a critical security vulnerability where the authentication token was exposed in the URL after a successful OAuth login. The token is now transferred via a browser cookie, preventing potential leaks through browser history or server logs and protecting user sessions.
|
||||
- 🛡️ **Hardened Chat Completion API Security**: The chat completion API endpoint now includes an explicit ownership check, ensuring non-admin users cannot access chats that do not belong to them and preventing potential unauthorized access.
|
||||
- 🛠️ **Resilient Model Loading**: Fixed an issue where a failure in loading the model list (e.g., from a misconfigured provider) would prevent the entire user interface, including the admin panel, from loading. The application now gracefully handles these errors, ensuring the UI remains accessible.
|
||||
- 🔒 **Resolved FIPS Self-Test Failure**: Fixed a critical issue that prevented Open WebUI from running on FIPS-compliant systems, specifically resolving the "FATAL FIPS SELFTEST FAILURE" error related to OpenSSL and SentenceTransformers, restoring compatibility with secure environments.
|
||||
- 📦 **Redis Cluster Connection Restored**: Fixed an issue where the backend was unable to connect to Redis in cluster mode, now ensuring seamless integration with scalable Redis cluster deployments.
|
||||
- 📦 **PGVector Connection Stability**: Fixed an issue where read-only operations could leave database transactions idle, preventing potential connection errors and improving overall database stability and resource management.
|
||||
- 🛠️ **OpenAPI Tool Integration for Array Parameters Fixed**: Resolved a critical bug where external tools using array parameters (e.g., for tags) would fail when used with OpenAI models. The system now correctly generates the required 'items' property in the function schema, restoring functionality and preventing '400 Bad Request' errors.
|
||||
- 🛠️ **Tool Creation for Users Restored**: Fixed a bug in the code editor where status messages were incorrectly prepended to tool scripts, causing a syntax error upon saving. All authorized users can now reliably create and save new tools.
|
||||
- 📁 **Folder Knowledge Processing Restored**: Fixed a bug where files uploaded to folder and model knowledge bases were not being extracted or analyzed for Retrieval-Augmented Generation (RAG) when the 'Max Upload Count' setting was empty, ensuring seamless document processing and knowledge augmentation.
|
||||
- 🧠 **Custom Model Knowledge Base Updates Recognized**: Fixed a bug where custom models linked to to knowledge bases did not automatically recognize newly added files to those knowledge bases. Models now correctly incorporate the latest information from updated knowledge collections.
|
||||
- 📦 **Comprehensive Redis Key Prefixing**: Corrected hardcoded prefixes to ensure the REDIS_KEY_PREFIX is now respected across all WebSocket and task management keys. This prevents data collisions in multi-instance deployments and improves compatibility with Redis cluster mode.
|
||||
- ✨ **More Descriptive OpenAI Router Errors**: The OpenAI-compatible API router now propagates detailed upstream error messages instead of returning a generic 'Bad Request'. This provides clear, actionable feedback for developers and API users, making it significantly easier to debug and resolve issues with model requests.
|
||||
- 🔐 **Hardened OIDC Signout Flow**: The OpenID Connect signout process now verifies that the 'OPENID_PROVIDER_URL' is configured before attempting to communicate with it, preventing potential errors and ensuring a more reliable logout experience.
|
||||
- 🍓 **Raspberry Pi Compatibility Restored**: Pinned the pyarrow library to version 20.0.0, resolving an "Illegal Instruction" crash on ARM-based devices like the Raspberry Pi and ensuring stable operation on this hardware.
|
||||
- 📁 **Folder System Prompt Variables Restored**: Fixed a bug where prompt variables (e.g., '{{CURRENT_DATETIME}}') were not being rendered in Folder-level System Prompts. This restores an important capability for creating dynamic, context-aware instructions for all chats within a project folder.
|
||||
- 📝 **Note Access in Knowledge Retrieval Fixed**: Corrected a permission oversight in knowledge retrieval, ensuring users can always use their own notes as a source for RAG without needing explicit sharing permissions.
|
||||
- 🤖 **Title Generation Compatibility for GPT-5 Models**: Added support for 'gpt-5' models in the payload handler, which correctly converts the deprecated 'max_tokens' parameter to 'max_completion_tokens'. This resolves title generation failures and ensures seamless operation with the latest generation of models.
|
||||
- ⚙️ **Correct API 'finish_reason' in Streaming Responses**: Fixed an issue where intermediate 'reasoning_content' chunks in streaming API responses incorrectly reported a 'finish_reason' of 'stop'. The 'finish_reason' is now correctly set to 'null' for these chunks, ensuring compatibility with third-party applications that rely on this field.
|
||||
- 📈 **Evaluation Pages Stability**: Resolved a crash on the Leaderboard and Feedbacks pages when processing legacy feedback entries that were missing a 'rating' field. The system now gracefully handles this older data, ensuring both pages load reliably for all users.
|
||||
- 🤝 **Reliable Collaborative Session Cleanup**: Fixed an asynchronous bug in the real-time collaboration engine that prevented document sessions from being properly cleaned up after all users had left. This ensures greater stability and resource management for features like Collaborative Notes.
|
||||
- 🧠 **Enhanced Memory Stability and Security**: Refactored memory update and delete operations to strictly enforce user ownership, preventing potential data integrity issues. Additionally, improved error handling for memory queries now provides clearer feedback when no memories exists.
|
||||
- 🧑⚖️ **Restored Admin Access to User Feedback**: Fixed a permission issue that blocked administrators from viewing or editing user feedback they didn't create, ensuring they can properly manage all evaluations across the platform.
|
||||
- 🔐 **PGVector Encryption Fix for Metadata**: Corrected a SQL syntax error in the experimental 'PGVECTOR_PGCRYPTO' feature that prevented encrypted metadata from being saved. Document uploads to encrypted PGVector collections now work as intended.
|
||||
- 🔍 **Serply Web Search Integration Restored**: Fixed an issue where incorrect parameters were passed to the Serply web search engine, restoring its functionality for RAG and web search workflows.
|
||||
- 🔍 **Resilient Web Search Processing**: Web search retrieval now gracefully handles search results that are missing a 'snippet', preventing crashes and ensuring that RAG workflows complete successfully even with incomplete data from search engines.
|
||||
- 🖼️ **Table Pasting in Rich Text Input Displayed Correctly**: Fixed an issue where pasting table text into the rich text input would incorrectly display it as code. Tables are now properly rendered as expected, improving content formatting and user experience.
|
||||
- ✍️ **Rich Text Input TypeError Resolution**: Addressed a potential 'TypeError: ue.getWordAtDocPos is not a function' in 'MessageInput.svelte' by refactoring how the 'getWordAtDocPos' function is accessed and referenced from 'RichTextInput.svelte', ensuring stable rich text input behavior, especially after production restarts.
|
||||
- ✏️ **Manual Code Block Creation in Chat Restored**: Fixed an issue where typing three backticks and then pressing Shift+Enter would incorrectly remove the backticks when "Enter to Send" mode was active. This ensures users can reliably create multi-line code blocks manually.
|
||||
- 🎨 **Consistent Dark Mode Background**: Fixed an issue where the application background could incorrectly flash or remain white during page loads and refreshes in dark mode, ensuring a seamless and consistent visual experience.
|
||||
- 🎨 **'Her' Theme Rendering Fixed**: Corrected a bug that caused the "Her" theme to incorrectly render as a dark theme in some situations. The theme now reliably applies its intended light appearance across all sessions.
|
||||
- 📜 **Corrected Markdown Table Line Break Rendering**: Fixed an issue where line breaks ('<br>') within Markdown tables were displayed as raw HTML instead of being rendered correctly. This ensures that tables with multi-line cell content are now displayed as intended.
|
||||
- 🚦 **Corrected App Configuration for Pending Users**: Fixed an issue where users awaiting approval could incorrectly load the full application interface, leading to a confusing or broken UI. This ensures that only fully approved users receive the standard app 'config', resulting in a smoother and more reliable onboarding experience.
|
||||
- 🔄 **Chat Cloning Now Includes Tags, Folder Status, and Pinned Status**: When cloning a chat or shared chat, its associated tags, folder organization, and pinned status are now correctly replicated, ensuring consistent chat management.
|
||||
- ⚙️ **Enhanced Backend Reliability**: Resolved a potential crash in knowledge base retrieval when referencing a deleted note. Additionally, chat processing was refactored to ensure model information is saved more reliably, enhancing overall system stability.
|
||||
- ⚙️ **Floating 'Ask/Explain' Modal Stability**: Fixed an issue that spammed the console with errors when navigating away while a model was generating a response in the floating 'Ask' or 'Explain' modals. In-flight requests are now properly cancelled, improving application stability.
|
||||
- ⚡ **Optimized User Count Checks**: Improved performance for user count and existence checks across the application by replacing resource-intensive 'COUNT' queries with more efficient 'EXISTS' queries, reducing database load.
|
||||
- 🔐 **Hardened OpenTelemetry Exporter Configuration**: The OTLP HTTP exporter no longer uses a potentially insecure explicit flag, improving security by relying on the connection URL's protocol (HTTP/HTTPS) to ensure transport safety.
|
||||
- 📱 **Mobile User Menu Closing Behavior Fixed**: Resolved an issue where the user menu would remain open on mobile devices after selecting an option, ensuring the menu correctly closes and returns focus to the main interface for a smoother mobile experience.
|
||||
- 📱 **OnBoarding Page Display Fixed on Mobile**: Resolved an issue where buttons on the OnBoarding page were not consistently visible on certain mobile browsers, ensuring a functional and complete user experience across devices.
|
||||
- ↕️ **Improved Pinned Models Drag-and-Drop Behavior**: The drag-and-drop functionality for reordering pinned models is now explicitly disabled on mobile devices, ensuring better usability and preventing potential UI conflicts or unexpected behavior.
|
||||
- 📱 **PWA Rotation Behavior Corrected**: The Progressive Web App now correctly respects the device's screen orientation lock, preventing unwanted rotation and ensuring a more native mobile experience.
|
||||
- ✏️ **Improved Chat Title Editing Behavior**: Changes to a chat title are now reliably saved when the user clicks away or presses Enter, replacing a less intuitive behavior that could accidentally discard edits. This makes renaming chats a smoother and more predictable experience.
|
||||
- ✏️ **Underscores Allowed in Prompt Commands**: Fixed the validation for prompt commands to correctly allow the use of underscores ('\_'), aligning with documentation examples and improving flexibility in naming custom prompts.
|
||||
- 💡 **Title Generation Button Behavior Fixed**: Resolved an issue where clicking the "Generate Title" button while editing a chat or note title would incorrectly save the title before generation could start. The focus is now managed correctly, ensuring a smooth and predictable user experience.
|
||||
- ✏️ **Consistent Chat Input Height**: Fixed a minor visual bug where the chat input field's height would change slightly when toggling the "Rich Text Input for Chat" setting, ensuring a more stable and consistent layout.
|
||||
- 🙈 **Admin UI Toggle Stability**: Fixed a visual glitch in the Admin settings where toggle switches could briefly display an incorrect state on page load, ensuring the UI always accurately reflects the saved settings.
|
||||
- 🙈 **Community Sharing Button Visibility**: The "Share to Community" button on the feedback page is now correctly hidden when the Enable Community Sharing feature is disabled in the admin settings, ensuring the UI respects the configured sharing policy.
|
||||
- 🙈 **"Help Us Translate" Link Visibility**: The "Help us translate" link in settings is now correctly hidden in deployments with specific license configurations, ensuring a cleaner interface for enterprise users.
|
||||
- 🔗 **Robust Tool Server URL Handling**: Fixed an issue where providing a full URL for a tool server's OpenAPI specification resulted in an invalid path. The system now correctly handles both absolute URLs and relative paths, improving configuration flexibility.
|
||||
- 🔧 **Improved Azure URL Detection**: The logic for identifying Azure OpenAI endpoints has been made more robust, ensuring all valid Azure URLs are now correctly detected for a smoother connection setup.
|
||||
- ⚙️ **Corrected Direct Connection Save Logic**: Fixed a bug in the Admin Connections settings page by removing a redundant save action for 'Direct Connections', leading to more reliable and predictable behavior when updating settings.
|
||||
- 🔗 **Corrected "Discover" Links**: The "Discover" links for models, prompts, tools, and functions now point to their specific, relevant pages on openwebui.com, improving content discovery for users.
|
||||
- ⏱️ **Refined Display of AI Thought Duration**: Adjusted the display logic for AI thought (reasoning) durations to more accurately show very short thought times as "less than a second," improving clarity in AI process feedback.
|
||||
- 📜 **Markdown Line Break Rendering Refinement**: Improved handling of line breaks within Markdown rendering for better visual consistency.
|
||||
- 🛠️ **Corrected OpenTelemetry Docker Compose Example**: The docker-compose.otel.yaml file has been fixed and enhanced by removing duplicates, adding necessary environment variables, and hardening security settings, ensuring a more reliable out-of-box observability setup.
|
||||
- 🛠️ **Development Script CORS Fix**: Corrected the CORS origin URL in the local development script (dev.sh) by removing the trailing slash, ensuring a more reliable and consistent setup for developers.
|
||||
- ⬆️ **OpenTelemetry Libraries Updated**: Upgraded all OpenTelemetry-related libraries to their latest versions, ensuring better performance, stability, and compatibility for observability.
|
||||
|
||||
### Changed
|
||||
|
||||
- ❗ **Docling Integration Upgraded to v1 API (Breaking Change)**: The integration with the Docling document processing engine has been updated to its new, stable '/v1' API. This is required for compatibility with Docling version 1.0.0 and newer. As a result, older versions of Docling are no longer supported. Users who rely on Docling for document ingestion **must upgrade** their docling-serve instance to ensure continued functionality.
|
||||
- 🗣️ **Admin-First Whisper Language Priority**: The global WHISPER_LANGUAGE setting now acts as a strict override for audio transcriptions. If set, it will be used for all speech-to-text tasks, ignoring any language specified by the user on a per-request basis. This gives administrators more control over transcription consistency.
|
||||
- ✂️ **Datalab Marker API Language Selection Removed**: The separate language selection option for the Datalab Marker API has been removed, as its functionality is now integrated and superseded by the more comprehensive 'additional_config' parameter. Users should transition to using 'additional_config' for relevant language and processing settings.
|
||||
- 📄 **Documentation and Releases Links Visibility**: The "Documentation" and "Releases" links in the user menu are now visible only to admin users, streamlining the user interface for non-admin roles.
|
||||
|
||||
## [0.6.18] - 2025-07-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑 **Users Not Loading in Groups**: Resolved an issue where user list was not displaying within user groups, restoring full visibility and management of group memberships for teams and admins.
|
||||
|
||||
## [0.6.17] - 2025-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- 📂 **Dedicated Folder View with Chat List**: Clicking a folder now reveals a brand-new landing page showcasing a list of all chats within that folder, making navigation simpler and giving teams immediate visibility into project-specific conversations.
|
||||
- 🆕 **Streamlined Folder Creation Modal**: Creating a new folder is now a seamless, unified experience with a dedicated modal that visually and functionally matches the edit folder flow, making workspace organization more intuitive and error-free for all users.
|
||||
- 🗃️ **Direct File Uploads to Folder Knowledge**: You can now upload files straight to a folder’s knowledge—empowering you to enrich project spaces by adding resources and documents directly, without the need to pre-create knowledge bases beforehand.
|
||||
- 🔎 **Chat Preview in Search**: When searching chats, instantly preview results in context without having to open them—making discovery, auditing, and recall dramatically quicker, especially in large, active teams.
|
||||
- 🖼️ **Image Upload and Inline Insertion in Notes**: Notes now support inserting images directly among your text, letting you create rich, visually structured documentation, brainstorms, or reports in a more natural and engaging way—no more images just as attachments.
|
||||
- 📱 **Enhanced Note Selection Editing and Q&A**: Select any portion of your notes to either edit just the highlighted part or ask focused questions about that content—streamlining workflows, boosting productivity, and making reviews or AI-powered enhancements more targeted.
|
||||
- 📝 **Copy Notes as Rich Text**: Copy entire notes—including all formatting, images, and structure—directly as rich text for seamless pasting into emails, reports, or other tools, maintaining clarity and consistency outside the WebUI.
|
||||
- ⚡ **Fade-In Streaming Text Experience**: Live-generated responses now elegantly fade in as the AI streams them, creating a more natural and visually engaging reading experience; easily toggled off in Interface settings if you prefer static displays.
|
||||
- 🔄 **Settings for Follow-Up Prompts**: Fine-tune your follow-up prompt experience—with new controls, you can choose to keep them visible or have them inserted directly into the message input instead of auto-submitting, giving you more flexibility and control over your workflow.
|
||||
- 🔗 **Prompt Variable Documentation Quick Link**: Access documentation for prompt variables in one click from the prompt editor modal—shortening the learning curve and making advanced prompt-building more accessible.
|
||||
- 📈 **Active and Total User Metrics for Telemetry**: Gain valuable insights into usage patterns and platform engagement with new metrics tracking active and total users—enhancing auditability and planning for large organizations.
|
||||
- 🏷️ **Traceability with Log Trace and Span IDs**: Each log entry now carries detailed trace and span IDs, making it much easier for admins to pinpoint and resolve issues across distributed systems or in complex troubleshooting.
|
||||
- 👥 **User Group Add/Remove Endpoints**: Effortlessly add or remove users from groups with new, improved endpoints—giving admins and team leads faster, clearer control over collaboration and permissions.
|
||||
- ⚙️ **Note Settings and Controls Streamlined**: The main “Settings” for notes are now simply called “Controls”, and note files now reside in a dedicated controls section, decluttering navigation and making it easier to find and configure note-related options.
|
||||
- 🚀 **Faster Admin User Page Loads**: The user list endpoint for admins has been optimized to exclude heavy profile images, speeding up load times for large teams and reducing waiting during administrative tasks.
|
||||
- 📡 **Chat ID Header Forwarding**: Ollama and OpenAI router requests now include the chat ID in request headers, enabling better request correlation and debugging capabilities across AI model integrations.
|
||||
- 🧠 **Enhanced Reasoning Tag Processing**: Improved and expanded reasoning tag parsing to handle various tag formats more robustly, including standard XML-style tags and custom delimiters, ensuring better AI reasoning transparency and debugging capabilities.
|
||||
- 🔐 **OAuth Token Endpoint Authentication Method**: Added configurable OAuth token endpoint authentication method support, providing enhanced flexibility and security options for enterprise OAuth integrations and identity provider compatibility.
|
||||
- 🛡️ **Redis Sentinel High Availability Support**: Comprehensive Redis Sentinel failover implementation with automatic master discovery, intelligent retry logic for connection failures, and seamless operation during master node outages—eliminating single points of failure and ensuring continuous service availability in production deployments.
|
||||
- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Simplified Chinese, Traditional Chinese, French, German, Korean, and Polish, ensuring a more fluent and native experience for global users across all supported languages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🏷️ **Hybrid Search Functionality Restored**: Hybrid search now works seamlessly again—enabling more accurate, relevant, and comprehensive knowledge discovery across all RAG-powered workflows.
|
||||
- 🚦 **Note Chat - Edit Button Disabled During AI Generation**: The edit button when chatting with a note is now disabled while the AI is responding—preventing accidental edits and ensuring workflow clarity during chat sessions.
|
||||
- 🧹 **Cleaner Database Credentials**: Database connection no longer duplicates ‘@’ in credentials, preventing potential connection issues and ensuring smoother, more reliable integrations.
|
||||
- 🧑💻 **File Deletion Now Removes Related Vector Data**: When files are deleted from storage, they are now purged from the vector database as well, ensuring clean data management and preventing clutter or stale search results.
|
||||
- 📁 **Files Modal Translation Issues Fixed**: All modal dialog strings—including “Using Entire Document” and “Using Focused Retrieval”—are now fully translated for a more consistent and localized UI experience.
|
||||
- 🚫 **Drag-and-Drop File Upload Disabled for Unsupported Models**: File upload by drag-and-drop is disabled when using models that do not support attachments—removing confusion and preventing workflow interruptions.
|
||||
- 🔑 **Ollama Tool Calls Now Reliable**: Fixed issues with Ollama-based tool calls, ensuring uninterrupted AI augmentation and tool use for every chat.
|
||||
- 📄 **MIME Type Help String Correction**: Cleaned up mimetype help text by removing extraneous characters, providing clearer guidance for file upload configurations.
|
||||
- 📝 **Note Editor Permission Fix**: Removed unnecessary admin-only restriction from note chat functionality, allowing all authorized users to access note editing features as intended.
|
||||
- 📋 **Chat Sources Handling Improved**: Fixed sources handling logic to prevent duplicate source assignments in chat messages, ensuring cleaner and more accurate source attribution during conversations.
|
||||
- 😀 **Emoji Generation Error Handling**: Improved error handling in audio router and fixed metadata structure for emoji generation tasks, preventing crashes and ensuring more reliable emoji generation functionality.
|
||||
- 🔒 **Folder System Prompt Permission Enforcement**: System prompt fields in folder edit modal are now properly hidden for users without system prompt permissions, ensuring consistent security policy enforcement across all folder management interfaces.
|
||||
- 🌐 **WebSocket Redis Lock Timeout Type Conversion**: Fixed proper integer type conversion for WebSocket Redis lock timeout configuration with robust error handling, preventing potential configuration errors and ensuring stable WebSocket connections.
|
||||
- 📦 **PostHog Dependency Added**: Added PostHog 5.4.0 library to resolve ChromaDB compatibility issues, ensuring stable vector database operations and preventing library version conflicts during deployment.
|
||||
|
||||
### Changed
|
||||
|
||||
- 👀 **Tiptap Editor Upgraded to v3**: The underlying rich text editor has been updated for future-proofing, though some supporting libraries remain on v2 for compatibility. For now, please install dependencies using 'npm install --force' to avoid installation errors.
|
||||
- 🚫 **Removed Redundant or Unused Strings and Elements**: Miscellaneous unused, duplicate, or obsolete code and translations have been cleaned up to maintain a streamlined and high-performance experience.
|
||||
|
||||
## [0.6.16] - 2025-07-14
|
||||
|
||||
### Added
|
||||
|
||||
- 🗂️ **Folders as Projects**: Organize your workflow with folder-based projects—set folder-level system prompts and associate custom knowledge, bringing seamless, context-rich management to teams and users handling multiple initiatives or clients.
|
||||
- 📁 **Instant Folder-Based Chat Creation**: Start a new chat directly from any folder; just click and your new conversation is automatically embedded in the right project context—no more manual dragging or setup, saving time and eliminating mistakes.
|
||||
- 🧩 **Prompt Variables with Automatic Input Modal**: Prompts containing variables now display a clean, auto-generated input modal that **autofocuses on the first field** for instant value entry—just select the prompt and fill in exactly what’s needed, reducing friction and guesswork.
|
||||
- 🔡 **Variable Input Typing in Prompts**: Define input types for prompt variables (e.g., text, textarea, number, select, color, date, map and more), giving everyone a clearer and more precise prompt-building experience for advanced automation or workflows.
|
||||
- 🚀 **Base Model List Caching**: Cache your base model list to speed up model selection and reduce repeated API calls; toggle this in Admin Settings > Connections for responsive model management even in large or multi-provider setups.
|
||||
- ⏱️ **Configurable Model List Cache TTL**: Take control over model list caching with the new MODEL_LIST_CACHE_TTL environment variable. Set a custom cache duration in seconds to balance performance and freshness, reducing API requests in stable environments or ensuring rapid updates when models change frequently.
|
||||
- 🔖 **Reference Notes as Knowledge or in Chats**: Use any note as knowledge for a model or folder, or reference it directly from chat—integrate living documentation into your Retrieval Augmented Generation workflows or discussions, bridging knowledge and action.
|
||||
- 📝 **Chat Directly with Notes (Experimental)**: Ask questions about any note, and directly edit or update notes from within a chat—unlock direct AI-powered brainstorming, summarization, and cleanup, like having your own collaborative AI canvas.
|
||||
- 🤝 **Collaborative Notes with Multi-User Editing**: Share notes with others and collaborate live—multiple users can edit a note in real-time, boosting cooperative knowledge building and workflow documentation.
|
||||
- 🛡️ **Collaborative Note Permissions**: Control who can view or edit each note with robust sharing permissions, ensuring privacy or collaboration per your organizational needs.
|
||||
- 🔗 **Copy Link to Notes**: Quickly copy and share direct links to notes for easier knowledge transfer within your team or external collaborators.
|
||||
- 📋 **Task List Support in Notes**: Add, organize, and manage checklists or tasks inside your notes—plan projects, track to-dos, and keep everything actionable in a single space.
|
||||
- 🧠 **AI-Generated Note Titles**: Instantly generate relevant and concise titles for your notes using AI—keep your knowledge library organized without tedious manual editing.
|
||||
- 🔄 **Full Undo/Redo Support in Notes**: Effortlessly undo or redo your latest note changes—never fear mistakes or accidental edits while collaborating or writing.
|
||||
- 📝 **Enhanced Note Word/Character Counter**: Always know the size of your notes with built-in counters, making it easier to adhere to length guidelines for shared or published content.
|
||||
- 🖊️ **Floating & Bubble Formatting Menus in Note Editor**: Access text formatting tools through both a floating menu and an intuitive bubble menu directly in the note editor—making rich text editing faster, more discoverable, and easier than ever.
|
||||
- ✍️ **Rich Text Prompt Insertion**: A new setting allows prompts to be inserted directly into the chat box as fully-formatted rich text, preserving Markdown elements like headings, lists, and bold text for a more intuitive and visually consistent editing experience.
|
||||
- 🌐 **Configurable Database URL**: WebUI now supports more flexible database configuration via new environment variables—making deployment and scaling simpler across various infrastructure setups.
|
||||
- 🎛️ **Completely Frontend-Handled File Upload in Temporary Chats**: When using temporary chats, file extraction now occurs fully in your browser with zero files sent to the backend, further strengthening privacy and giving you instant feedback.
|
||||
- 🔄 **Enhanced Banner and Chat Command Visibility**: Banner handling and command feedback in chat are now clearer and more contextually visible, making alerts, suggestions, and automation easier to spot and interact with for all users.
|
||||
- 📱 **Mobile Experience Polished**: The "new chat" button is back in mobile, plus core navigation and input controls have been smoothed out for better usability on phones and tablets.
|
||||
- 📄 **OpenDocument Text (.odt) Support**: Seamlessly upload and process .odt files from open-source office suites like LibreOffice and OpenOffice, expanding your ability to build knowledge from a wider range of document formats.
|
||||
- 📑 **Enhanced Markdown Document Splitting**: Improve knowledge retrieval from Markdown files with a new header-aware splitting strategy. This method intelligently chunks documents based on their header structure, preserving the original context and hierarchy for more accurate and relevant RAG results.
|
||||
- 📚 **Full Context Mode for Knowledge Bases**: When adding a knowledge base to a folder or custom model, you can now toggle full context mode for the entire knowledge base. This bypasses the usual chunking and retrieval process, making it perfect for leaner knowledge bases.
|
||||
- 🕰️ **Configurable OAuth Timeout**: Enhance login reliability by setting a custom timeout (OAUTH_TIMEOUT) for all OAuth providers (Google, Microsoft, GitHub, OIDC), preventing authentication failures on slow or restricted networks.
|
||||
- 🎨 **Accessibility & High-Contrast Theme Enhancements**: Major accessibility overhaul with significant updates to the high-contrast theme. Improved focus visibility, ARIA labels, and semantic HTML ensure core components like the chat interface and model selector are fully compliant and readable for visually impaired users.
|
||||
- ↕️ **Resizable System Prompt Fields**: Conveniently resize system prompt input fields to comfortably view and edit lengthy or complex instructions, improving the user experience for advanced model configuration.
|
||||
- 🔧 **Granular Update Check Control**: Gain finer control over outbound connections with the new ENABLE_VERSION_UPDATE_CHECK flag. This allows administrators to disable version update checks independently of the full OFFLINE_MODE, perfect for environments with restricted internet access that still need to download embedding models.
|
||||
- 🗃️ **Configurable Qdrant Collection Prefix**: Enhance scalability by setting a custom QDRANT_COLLECTION_PREFIX. This allows multiple Open WebUI instances to share a single Qdrant cluster safely, ensuring complete data isolation between separate deployments without conflicts.
|
||||
- ⚙️ **Improved Default Database Performance**: Enhanced out-of-the-box performance by setting smarter database connection pooling defaults, reducing API response times for users on non-SQLite databases without requiring manual configuration.
|
||||
- 🔧 **Configurable Redis Key Prefix**: Added support for the REDIS_KEY_PREFIX environment variable, allowing multiple Open WebUI instances to share a Redis cluster with isolated key namespaces for improved multi-tenancy.
|
||||
- ➡️ **Forward User Context to Reranker**: For advanced RAG integrations, user information (ID, name, email, role) can now be forwarded as HTTP headers to external reranking services, enabling personalized results or per-user access control.
|
||||
- ⚙️ **PGVector Connection Pooling**: Enhance performance and stability for PGVector-based RAG by enabling and configuring the database connection pool. New environment variables allow fine-tuning of pool size, timeout, and overflow settings to handle high-concurrency workloads efficiently.
|
||||
- ⚙️ **General Backend Refactoring**: Extensive refactoring delivers a faster, more reliable, and robust backend experience—improving chat speed, model management, and day-to-day reliability.
|
||||
- 🌍 **Expanded & Improved Translations**: Enjoy a more accessible and intuitive experience thanks to comprehensive updates and enhancements for Chinese (Simplified and Traditional), German, French, Catalan, Irish, and Spanish translations throughout the interface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ **Rich Text Input Stability and Performance**: Multiple improvements ensure faster, cleaner text editing and rendering with reduced glitches—especially supporting links, color picking, checkbox controls, and code blocks in notes and chats.
|
||||
- 📷 **Seamless iPhone Image Uploads**: Effortlessly upload photos from iPhones and other devices using HEIC format—images are now correctly recognized and processed, eliminating compatibility issues.
|
||||
- 🔄 **Audio MIME Type Registration**: Issues with audio file content types have been resolved, guaranteeing smoother, error-free uploads and playback for transcription or note attachments.
|
||||
- 🖍️ **Input Commands Now Always Visible**: Input commands (like prompts or knowledge) dynamically adjust their height on small screens, ensuring nothing is cut off and every tool remains easily accessible.
|
||||
- 🛑 **Tool Result Rendering**: Fixed display problems with tool results, providing fast, clear feedback when using external or internal tools.
|
||||
- 🗂️ **Table Alignment in Markdown**: Markdown tables are now rendered and aligned as expected, keeping reports and documentation readable.
|
||||
- 🖼️ **Thread Image Handling**: Fixed an issue where messages containing only images in threads weren’t displayed correctly.
|
||||
- 🗝️ **Note Access Control Security**: Tightened access control logic for notes to guarantee that shared or collaborative notes respect all user permissions and privacy safeguards.
|
||||
- 🧾 **Ollama API Compatibility**: Fixed model parameter naming in the API to ensure uninterrupted compatibility for all Ollama endpoints.
|
||||
- 🛠️ **Detection for 'text/html' Files**: Files loaded with docling/tika are now reliably detected as the correct type, improving knowledge ingestion and document parsing.
|
||||
- 🔐 **OAuth Login Stability**: Resolved a critical OAuth bug that caused login failures on subsequent attempts after logging out. The user session is now completely cleared on logout, ensuring reliable and secure authentication across all supported providers (Google, Microsoft, GitHub, OIDC).
|
||||
- 🚪 **OAuth Logout and Redirect Reliability**: The OAuth logout process has been made more robust. Logout requests now correctly use proxy environment variables, ensuring they succeed in corporate networks. Additionally, the custom WEBUI_AUTH_SIGNOUT_REDIRECT_URL is now properly respected for all OAuth/OIDC configurations, ensuring a seamless sign-out experience.
|
||||
- 📜 **Banner Newline Rendering**: Banners now correctly render newline characters, ensuring that multi-line announcements and messages are displayed with their intended formatting.
|
||||
- ℹ️ **Consistent Model Description Rendering**: Model descriptions now render Markdown correctly in the main chat interface, matching the formatting seen in the model selection dropdown for a consistent user experience.
|
||||
- 🔄 **Offline Mode Update Check Display**: Corrected a UI bug where the "Checking for Updates..." message would display indefinitely when the application was set to offline mode.
|
||||
- 🛠️ **Tool Result Encoding**: Fixed a bug where tool calls returning non-ASCII characters would fail, ensuring robust handling of international text and special characters in tool outputs.
|
||||
|
||||
## [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
|
||||
|
||||
- 📝 **AI-Enhanced Notes (With Audio Transcription)**: Effortlessly create notes, attach meeting or voice audio, and let the AI instantly enhance, summarize, or refine your notes using audio transcriptions—making your documentation smarter, cleaner, and more insightful with minimal effort.
|
||||
- 🔊 **Meeting Audio Recording & Import**: Seamlessly record audio from your meetings or capture screen audio and attach it to your notes—making it easier to revisit, annotate, and extract insights from important discussions.
|
||||
- 📁 **Import Markdown Notes Effortlessly**: Bring your existing knowledge library into Open WebUI by importing your Markdown notes, so you can leverage all advanced note management and AI features right away.
|
||||
- 👥 **Notes Permissions by User Group**: Fine-tune access and editing rights for notes based on user roles or groups, so you can delegate writing or restrict sensitive information as needed.
|
||||
- ☁️ **OneDrive & SharePoint Integration**: Keep your content in sync by connecting notes and files directly with OneDrive or SharePoint—unlocking fast enterprise import/export and seamless collaboration with your existing workflows.
|
||||
- 🗂️ **Paginated User List in Admin Panel**: Effortlessly manage and search through large teams via the new paginated user list—saving time and streamlining user administration in big organizations.
|
||||
- 🕹️ **Granular Chat Share & Export Permissions**: Enjoy enhanced control over who can share or export chats, enabling tighter governance and privacy in team and enterprise settings.
|
||||
- 🛑 **User Role Change Confirmation Dialog**: Reduce accidental privilege changes with a required confirmation step before updating user roles—improving security and preventing costly mistakes in team management.
|
||||
- 🚨 **Audit Log for Failed Login Attempts**: Quickly detect unauthorized access attempts or troubleshoot user login problems with detailed logs of failed authentication right in the audit trail.
|
||||
- 💡 **Dedicated 'Generate Title' Button for Chats**: Swiftly organize every conversation—tap the new button to let AI create relevant, clear titles for all your chats, saving time and reducing clutter.
|
||||
- 💬 **Notification Sound Always-On Option**: Take control of your notifications by setting sound alerts to always play—helping you stay on top of important updates in busy environments.
|
||||
- 🆔 **S3 File Tagging Support**: Uploaded files to S3 now include tags for better organization, searching, and integration with your file management policies.
|
||||
- 🛡️ **OAuth Blocked Groups Support**: Gain more control over group-based access by explicitly blocking specified OAuth groups—ideal for complex identity or security requirements.
|
||||
- 🚀 **Optimized Faster Web Search & Multi-Threaded Queries**: Enjoy dramatically faster web search and RAG (retrieval augmented generation) with revamped multi-threaded search—get richer, more accurate results in less time.
|
||||
- 🔍 **All-Knowledge Parallel Search**: Searches across your entire knowledge base now happen in parallel even in non-hybrid mode, speeding up responses and improving knowledge accuracy for every question.
|
||||
- 🌐 **New Firecrawl & Yacy Web Search Integrations**: Expand your world of information with two new advanced search engines—Firecrawl for deeper web insight and Yacy for decentralized, privacy-friendly search capabilities.
|
||||
- 🧠 **Configurable Docling OCR Engine & Language**: Use environment variables to fine-tune Docling OCR engine and supported languages for smarter, more tailored document extraction and RAG workflows.
|
||||
- 🗝️ **Enhanced Sentence Transformers Configuration**: Added new environment variables for easier set up and advanced customization of Sentence Transformers—ensuring best fit for your embedding needs.
|
||||
- 🌲 **Pinecone Vector Database Integration**: Index, search, and manage knowledge at enterprise scale with full native support for Pinecone as your vector database—effortlessly handle even the biggest document sets.
|
||||
- 🔄 **Automatic Requirements Installation for Tools & Functions**: Never worry about lost dependencies on restart—external function and tool requirements are now auto-installed at boot, ensuring tools always “just work.”
|
||||
- 🔒 **Automatic Sign-Out on Token Expiry**: Security is smarter—users are now automatically logged out if their authentication token expires, protecting sensitive content and ensuring compliance without disruption.
|
||||
- 🎬 **Automatic YouTube Embed Detection**: Paste YouTube links and see instant in-chat video embeds—no more manual embedding, making knowledge sharing and media consumption even easier for every team.
|
||||
- 🔄 **Expanded Language & Locale Support**: Translations for Danish, French, Russian, Traditional Chinese, Simplified Chinese, Thai, Catalan, German, and Korean have been upgraded, offering smoother, more natural user experiences across the platform.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒 **Tighter HTML Token Security**: HTML rendering is now restricted to admin-uploaded tokens only, reducing any risk of XSS and keeping your data safe.
|
||||
- 🔐 **Refined HTML Security and Token Handling**: Further hardened how HTML tokens and content are handled, guaranteeing even stronger resistance to security vulnerabilities and attacks.
|
||||
- 🔏 **Correct Model Usage with Ollama Proxy Prefixes**: Enhanced model reference handling so proxied models in Ollama always download and run correctly—even when using custom prefixes.
|
||||
- 📥 **Video File Upload Handling**: Prevented video files from being misclassified as text, fixing bugs with uploads and ensuring media files work as expected.
|
||||
- 🔄 **No More Dependent WebSocket Sequential Delays**: Streamlined WebSocket operation to prevent delays and maintain snappy real-time collaboration, especially in multi-user environments.
|
||||
- 🛠️ **More Robust Action Module Execution**: Multiple actions in a module now trigger as designed, increasing automation and scripting flexibility.
|
||||
- 📧 **Notification Webhooks**: Ensured that notification webhooks are always sent for user events, even when the user isn’t currently active.
|
||||
- 🗂️ **Smarter Knowledge Base Reindexing**: Knowledge reindexing continues even when corrupt or missing collections are encountered, keeping your search features running reliably.
|
||||
- 🏷️ **User Import with Profile Images**: When importing users, their profile images now come along—making onboarding and collaboration visually clearer from day one.
|
||||
- 💬 **OpenAI o-Series Universal Support**: All OpenAI o-series models are now seamlessly recognized and supported, unlocking more advanced capabilities and model choices for every workflow.
|
||||
|
||||
### Changed
|
||||
|
||||
- 📜 **Custom License Update & Contributor Agreement**: Open WebUI now operates under a custom license with Contributor License Agreement required by default—see https://docs.openwebui.com/license/ for details, ensuring sustainable open innovation for the community.
|
||||
- 🔨 **CUDA Docker Images Updated to 12.8**: Upgraded CUDA image support for faster, more compatible model inference and futureproof GPU performance in your AI infrastructure.
|
||||
- 🧱 **General Backend Refactoring for Reliability**: Continuous stability improvements streamline backend logic, reduce errors, and lay a stronger foundation for the next wave of feature releases—all under the hood for a more dependable WebUI.
|
||||
|
||||
## [0.6.5] - 2025-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- 🛂 **Granular Voice Feature Permissions Per User Group**: Admins can now separately manage access to Speech-to-Text (record voice), Text-to-Speech (read aloud), and Tool Calls for each user group—giving teams tighter control over voice features and enhanced governance across roles.
|
||||
- 🗣️ **Toggle Voice Activity Detection (VAD) for Whisper STT**: New environment variable lets you enable/disable VAD filtering with built-in Whisper speech-to-text, giving you flexibility to optimize for different audio quality and response accuracy levels.
|
||||
- 📋 **Copy Formatted Response Mode**: You can now enable “Copy Formatted” in Settings > Interface to copy AI responses exactly as styled (with rich formatting, links, and structure preserved), making it faster and cleaner to paste into documents, emails, or reports.
|
||||
- ⚙️ **Backend Stability and Performance Enhancements**: General backend refactoring improves system resilience, consistency, and overall reliability—offering smoother performance across workflows whether chatting, generating media, or using external tools.
|
||||
- 🌎 **Translation Refinements Across Multiple Languages**: Updated translations deliver smoother language localization, clearer labels, and improved international usability throughout the UI—ensuring a better experience for non-English speakers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ **LDAP Login Reliability Restored**: Resolved a critical issue where some LDAP setups failed due to attribute parsing—ensuring consistent, secure, and seamless user authentication across enterprise deployments.
|
||||
- 🖼️ **Image Generation in Temporary Chats Now Works Properly**: Fixed a bug where image outputs weren’t generated during temporary chats—visual content can now be used reliably in all chat modes without interruptions.
|
||||
|
||||
## [0.6.4] - 2025-04-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ **RAG_TEMPLATE Display Issue Resolved**: Fixed a formatting problem where the custom RAG_TEMPLATE wasn't correctly rendered in the interface—ensuring that custom retrieval prompts now appear exactly as intended for more reliable prompt engineering.
|
||||
|
||||
## [0.6.3] - 2025-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- 🧪 **Auto-Artifact Detection Toggle**: Automatically detects artifacts in results—but now you can disable this behavior under advanced settings for full control.
|
||||
- 🖼️ **Widescreen Mode for Shared Chats**: Shared link conversations now support widescreen layouts—perfect for presentations or easier review across wider displays.
|
||||
- 🔁 **Reindex Knowledge Files on Demand**: Admins can now trigger reindexing of all knowledge files after changing embeddings—ensuring immediate alignment with new models for optimal RAG performance.
|
||||
- 📄 **OpenAPI YAML Format Support**: External tools can now use YAML-format OpenAPI specs—making integration simpler for developers familiar with YAML-based configurations.
|
||||
- 💬 **Message Content Copy Behavior**: Copy action now excludes 'details' tags—streamlining clipboard content when sharing or pasting summaries elsewhere.
|
||||
- 🧭 **Sougou Web Search Integration**: New search engine option added—enhancing global relevance and diversity of search sources for multilingual users.
|
||||
- 🧰 **Frontend Web Loader Engine Configuration**: Admins can now set preferred web loader engine for RAG workflows directly from the frontend—offering more control across setups.
|
||||
- 👥 **Multi-Model Chat Permission Control**: Admins can manage access to multi-model chats per user group—allowing tighter governance in team environments.
|
||||
- 🧱 **Persistent Configuration Can Be Disabled**: New environment variable lets advanced users and hosts turn off persistent configs—ideal for volatile or stateless deployments.
|
||||
- 🧠 **Elixir Code Highlighting Support**: Elixir syntax is now beautifully rendered in code blocks—perfect for developers using this language in AI or automation projects.
|
||||
- 🌐 **PWA External Manifest URL Support**: You can now define an external manifest.json—integrate Open WebUI seamlessly in managed or proxy-based PWA environments like Cloudflare Zero Trust.
|
||||
- 🧪 **Azure AI Speech-to-Text Provider Integration**: Easily transcribe large audio files (up to 200MB) with high accuracy using Microsoft's Azure STT—fully configurable in Audio Settings.
|
||||
- 🔏 **PKCE (Code Challenge Method) Support for OIDC**: Enhance your OIDC login security with Proof Key for Code Exchange—ideal for zero-trust and native client apps.
|
||||
- ✨ **General UI/UX Enhancements**: Numerous refinements across layout, styling, and tool interactions—reducing visual noise and improving overall usability across key workflows.
|
||||
- 🌍 **Translation Updates Across Multiple Languages**: Refined Catalan, Russian, Chinese (Simplified & Traditional), Hungarian, and Spanish translations for clearer navigation and instructions globally.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💥 **Chat Completion Error with Missing Models Resolved**: Fixed internal server error when referencing a model that doesn’t exist—ensuring graceful fallback and clear error guidance.
|
||||
- 🔧 **Correct Knowledge Base Citations Restored**: Citations generated by RAG workflows now show accurate references—ensuring verifiability in outputs from sourced content.
|
||||
- 🎙️ **Broken OGG/WebM Audio Upload Handling for OpenAI Fixed**: Uploading OGG or WebM files now converts properly to WAV before transcription—restoring accurate AI speech recognition workflows.
|
||||
- 🔐 **Tool Server 'Session' Authentication Restored**: Previously broken session auth on external tool servers is now fully functional—ensuring secure and seamless access to connected tools.
|
||||
- 🌐 **Folder-Based Chat Rename Now Updates Correctly**: Renaming chats in folders now reflects instantly everywhere—improving chat organization and clarity.
|
||||
- 📜 **KaTeX Overflow Displays Fixed**: Math expressions now stay neatly within message bounds—preserving layout consistency even with long formulas.
|
||||
- 🚫 **Stopping Ongoing Chat Fixed**: You can now return to an active (ongoing) chat and stop generation at any time—ensuring full control over sessions.
|
||||
- 🔧 **TOOL_SERVERS / TOOL_SERVER_CONNECTIONS Indexing Issue Fixed**: Fixed a mismatch between tool lists and their access paths—restoring full function and preventing confusion in tool management.
|
||||
- 🔐 **LDAP Login Handles Multiple Emails**: When LDAP returns multiple email attributes, the first valid one is now used—ensuring login success and account consistency.
|
||||
- 🧩 **Model Visibility Toggle Fix**: Toggling model visibility now works even for untouched models—letting admins smoothly manage user access across base models.
|
||||
- ⚙️ **Cross-Origin manifest.json Now Loads Properly**: Compatibility issues with Cloudflare Zero Trust (and others) resolved, allowing manifest.json to load behind authenticated proxies.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒 **Default Access Scopes Set to Private for All Resources**: Models, tools, and knowledge are now private by default when created—ensuring better baseline security and visibility controls.
|
||||
- 🧱 **General Backend Refactoring for Stability**: Numerous invisible improvements enhance backend scalability, security, and maintainability—powering upcoming features with a stronger foundation.
|
||||
- 🧩 **Stable Dependency Upgrades**: Updated key platform libraries—Chromadb (0.6.3), pgvector (0.4.0), Azure Identity (1.21.0), and Youtube Transcript API (1.0.3)—for improved compatibility, functionality, and security.
|
||||
|
||||
## [0.6.2] - 2025-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- 🌍 **Improved Global Language Support**: Expanded and refined translations across multiple languages to enhance clarity and consistency for international users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ **Accurate Tool Descriptions from OpenAPI Servers**: External tools now use full endpoint descriptions instead of summaries when generating tool specifications—helping AI models understand tool purpose more precisely and choose the right tool more accurately in tool workflows.
|
||||
- 🔧 **Precise Web Results Source Attribution**: Fixed a key issue where all web search results showed the same source ID—now each result gets its correct and distinct source, ensuring accurate citations and traceability.
|
||||
- 🔍 **Clean Web Search Retrieval**: Web search now retains only results from URLs where real content was successfully fetched—improving accuracy and removing empty or broken links from citations.
|
||||
- 🎵 **Audio File Upload Response Restored**: Resolved an issue where uploading audio files did not return valid responses, restoring smooth file handling for transcription and audio-based workflows.
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧰 **General Backend Refactoring**: Multiple behind-the-scenes improvements streamline backend performance, reduce complexity, and ensure a more stable, maintainable system overall—making everything smoother without changing your workflow.
|
||||
|
||||
## [0.6.1] - 2025-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- 🛠️ **Global Tool Servers Configuration**: Admins can now centrally configure global external tool servers from Admin Settings > Tools, allowing seamless sharing of tool integrations across all users without manual setup per user.
|
||||
- 🔐 **Direct Tool Usage Permission for Users**: Introduced a new user-level permission toggle that grants non-admin users access to direct external tools, empowering broader team collaboration while maintaining control.
|
||||
- 🧠 **Mistral OCR Content Extraction Support**: Added native support for Mistral OCR as a high-accuracy document loader, drastically improving text extraction from scanned documents in RAG workflows.
|
||||
- 🖼️ **Tools Indicator UI Redesign**: Enhanced message input now smartly displays both built-in and external tools via a unified dropdown, making it simpler and more intuitive to activate tools during conversations.
|
||||
- 📄 **RAG Prompt Improved and More Coherent**: Default RAG system prompt has been revised to be more clear and citation-focused—admins can leave the template field empty to use this new gold-standard prompt.
|
||||
- 🧰 **Performance & Developer Improvements**: Major internal restructuring of several tool-related components, simplifying styling and merging external/internal handling logic, resulting in better maintainability and performance.
|
||||
- 🌍 **Improved Translations**: Updated translations for Tibetan, Polish, Chinese (Simplified & Traditional), Arabic, Russian, Ukrainian, Dutch, Finnish, and French to improve clarity and consistency across the interface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔑 **External Tool Server API Key Bug Resolved**: Fixed a critical issue where authentication headers were not being sent when calling tools from external OpenAPI tool servers, ensuring full security and smooth tool operations.
|
||||
- 🚫 **Conditional Export Button Visibility**: UI now gracefully hides export buttons when there's nothing to export in models, prompts, tools, or functions, improving visual clarity and reducing confusion.
|
||||
- 🧪 **Hybrid Search Failure Recovery**: Resolved edge case in parallel hybrid search where empty or unindexed collections caused backend crashes—these are now cleanly skipped to ensure system stability.
|
||||
- 📂 **Admin Folder Deletion Fix**: Addressed an issue where folders created in the admin workspace couldn't be deleted, restoring full organizational flexibility for admins.
|
||||
- 🔐 **Improved Generic Error Feedback on Login**: Authentication errors now show simplified, non-revealing messages for privacy and improved UX, especially with federated logins.
|
||||
- 📝 **Tool Message with Images Improved**: Enhanced how tool-generated messages with image outputs are shown in chat, making them more readable and consistent with the overall UI design.
|
||||
- ⚙️ **Auto-Exclusion for Broken RAG Collections**: Auto-skips document collections that fail to fetch data or return "None", preventing silent errors and streamlining retrieval workflows.
|
||||
- 📝 **Docling Text File Handling Fix**: Fixed file parsing inconsistency that broke docling-based RAG functionality for certain plain text files, ensuring wider file compatibility.
|
||||
|
||||
## [0.6.0] - 2025-03-31
|
||||
|
||||
### Added
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Open WebUI Contributor License Agreement
|
||||
|
||||
By submitting my contributions to Open WebUI, I grant Open WebUI full freedom to use my work in any way they choose, under any terms they like, both now and in the future. This approach helps ensure the project remains unified, flexible, and easy to maintain, while empowering Open WebUI to respond quickly to the needs of its users and the wider community.
|
||||
|
||||
Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the Open WebUI project. This collaborative approach strengthens the project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
|
||||
|
||||
**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._**
|
||||
@@ -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/
|
||||
}
|
||||
}
|
||||
+28
-26
@@ -4,7 +4,7 @@
|
||||
ARG USE_CUDA=false
|
||||
ARG USE_OLLAMA=false
|
||||
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
|
||||
ARG USE_CUDA_VER=cu121
|
||||
ARG USE_CUDA_VER=cu128
|
||||
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
|
||||
# Leaderboard: https://huggingface.co/spaces/mteb/leaderboard
|
||||
# for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB)
|
||||
@@ -26,8 +26,11 @@ 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
|
||||
RUN npm ci --force
|
||||
|
||||
COPY . .
|
||||
ENV APP_BUILD_HASH=${BUILD_HASH}
|
||||
@@ -105,29 +108,13 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
|
||||
# Make sure the user has access to the app and root directory
|
||||
RUN chown -R $UID:$GID /app $HOME
|
||||
|
||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||
apt-get update && \
|
||||
# Install pandoc and netcat
|
||||
apt-get install -y --no-install-recommends git build-essential pandoc netcat-openbsd curl && \
|
||||
apt-get install -y --no-install-recommends gcc python3-dev && \
|
||||
# for RAG OCR
|
||||
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
|
||||
# install helper tools
|
||||
apt-get install -y --no-install-recommends curl jq && \
|
||||
# install ollama
|
||||
curl -fsSL https://ollama.com/install.sh | sh && \
|
||||
# cleanup
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
else \
|
||||
apt-get update && \
|
||||
# Install pandoc, netcat and gcc
|
||||
apt-get install -y --no-install-recommends git build-essential pandoc gcc netcat-openbsd curl jq && \
|
||||
apt-get install -y --no-install-recommends gcc python3-dev && \
|
||||
# for RAG OCR
|
||||
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
|
||||
# cleanup
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
# Install common system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install python dependencies
|
||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
@@ -149,7 +136,13 @@ RUN pip3 install --no-cache-dir uv && \
|
||||
fi; \
|
||||
chown -R $UID:$GID /app/backend/data/
|
||||
|
||||
|
||||
# Install Ollama if requested
|
||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||
date +%s > /tmp/ollama_build_hash && \
|
||||
echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \
|
||||
curl -fsSL https://ollama.com/install.sh | sh && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# copy embedding weight from build
|
||||
# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
|
||||
@@ -167,6 +160,15 @@ EXPOSE 8080
|
||||
|
||||
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
|
||||
|
||||
# Minimal, atomic permission hardening for OpenShift (arbitrary UID):
|
||||
# - Group 0 owns /app and /root
|
||||
# - Directories are group-writable and have SGID so new files inherit GID 0
|
||||
RUN set -eux; \
|
||||
chgrp -R 0 /app /root || true; \
|
||||
chmod -R g+rwX /app /root || true; \
|
||||
find /app -type d -exec chmod g+s {} + || true; \
|
||||
find /root -type d -exec chmod g+s {} + || true
|
||||
|
||||
USER $UID:$GID
|
||||
|
||||
ARG BUILD_HASH
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2023-2025 Timothy Jaeryang Baek
|
||||
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
@@ -15,6 +15,12 @@ modification, are permitted provided that the following conditions are met:
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
|
||||
|
||||
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
|
||||
|
||||
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
All code and materials created before commit `60d84a3aae9802339705826e9095e272e3c83623` are subject to the following copyright and license:
|
||||
|
||||
Copyright (c) 2023-2025 Timothy Jaeryang Baek
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
All code and materials created before commit `a76068d69cd59568b920dfab85dc573dbbb8f131` are subject to the following copyright and license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Timothy Jaeryang Baek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -7,12 +7,13 @@
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/5rJgQTnV4s)
|
||||
[](https://github.com/sponsors/tjbck)
|
||||
|
||||
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
|
||||
|
||||
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
|
||||
|
||||

|
||||
|
||||
> [!TIP]
|
||||
@@ -30,6 +31,8 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
||||
|
||||
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
|
||||
|
||||
- 🔄 **SCIM 2.0 Support**: Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
|
||||
|
||||
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
|
||||
|
||||
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
|
||||
@@ -62,9 +65,46 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
||||
|
||||
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
|
||||
|
||||
## 🔗 Also Check Out Open WebUI Community!
|
||||
## Sponsors 🙌
|
||||
|
||||
Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀
|
||||
#### Emerald
|
||||
|
||||
<table>
|
||||
<!-- <tr>
|
||||
<td>
|
||||
<a href="https://n8n.io/" target="_blank">
|
||||
<img src="https://docs.openwebui.com/sponsors/logos/n8n.png" alt="n8n" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<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://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>
|
||||
<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>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
|
||||
|
||||
## How to Install 🚀
|
||||
|
||||
@@ -155,6 +195,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).
|
||||
@@ -206,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
|
||||
|
||||
## License 📜
|
||||
|
||||
This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
|
||||
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
|
||||
|
||||
## Support 💬
|
||||
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
export CORS_ALLOW_ORIGIN="http://localhost:5173"
|
||||
PORT="${PORT:-8080}"
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
|
||||
|
||||
@@ -73,8 +73,15 @@ def serve(
|
||||
os.environ["LD_LIBRARY_PATH"] = ":".join(LD_LIBRARY_PATH)
|
||||
|
||||
import open_webui.main # we need set environment variables before importing main
|
||||
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||
|
||||
uvicorn.run(open_webui.main.app, host=host, port=port, forwarded_allow_ips="*")
|
||||
uvicorn.run(
|
||||
"open_webui.main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
forwarded_allow_ips="*",
|
||||
workers=UVICORN_WORKERS,
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
|
||||
@@ -10,7 +10,7 @@ script_location = migrations
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
prepend_sys_path = ..
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
|
||||
+941
-152
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ class ERROR_MESSAGES(str, Enum):
|
||||
USERNAME_TAKEN = (
|
||||
"Uh-oh! This username is already registered. Please choose another username."
|
||||
)
|
||||
PASSWORD_TOO_LONG = "Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long."
|
||||
COMMAND_TAKEN = "Uh-oh! This command is already registered. Please choose another command string."
|
||||
FILE_EXISTS = "Uh-oh! This file is already registered. Please choose another file."
|
||||
|
||||
@@ -110,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"
|
||||
|
||||
+324
-13
@@ -5,7 +5,9 @@ import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import shutil
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -15,14 +17,17 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
# Load .env file
|
||||
####################################
|
||||
|
||||
OPEN_WEBUI_DIR = Path(__file__).parent # the path containing this file
|
||||
print(OPEN_WEBUI_DIR)
|
||||
# Use .resolve() to get the canonical path, removing any '..' or '.' components
|
||||
ENV_FILE_PATH = Path(__file__).resolve()
|
||||
|
||||
BACKEND_DIR = OPEN_WEBUI_DIR.parent # the path containing this file
|
||||
BASE_DIR = BACKEND_DIR.parent # the path containing the backend/
|
||||
# OPEN_WEBUI_DIR should be the directory where env.py resides (open_webui/)
|
||||
OPEN_WEBUI_DIR = ENV_FILE_PATH.parent
|
||||
|
||||
print(BACKEND_DIR)
|
||||
print(BASE_DIR)
|
||||
# BACKEND_DIR is the parent of OPEN_WEBUI_DIR (backend/)
|
||||
BACKEND_DIR = OPEN_WEBUI_DIR.parent
|
||||
|
||||
# BASE_DIR is the parent of BACKEND_DIR (open-webui-dev/)
|
||||
BASE_DIR = BACKEND_DIR.parent
|
||||
|
||||
try:
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
@@ -130,6 +135,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
|
||||
@@ -197,6 +203,7 @@ CHANGELOG = changelog_json
|
||||
|
||||
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
||||
|
||||
|
||||
####################################
|
||||
# ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
####################################
|
||||
@@ -264,21 +271,43 @@ else:
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
||||
|
||||
DATABASE_TYPE = os.environ.get("DATABASE_TYPE")
|
||||
DATABASE_USER = os.environ.get("DATABASE_USER")
|
||||
DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
|
||||
|
||||
DATABASE_CRED = ""
|
||||
if DATABASE_USER:
|
||||
DATABASE_CRED += f"{DATABASE_USER}"
|
||||
if DATABASE_PASSWORD:
|
||||
DATABASE_CRED += f":{DATABASE_PASSWORD}"
|
||||
|
||||
DB_VARS = {
|
||||
"db_type": DATABASE_TYPE,
|
||||
"db_cred": DATABASE_CRED,
|
||||
"db_host": os.environ.get("DATABASE_HOST"),
|
||||
"db_port": os.environ.get("DATABASE_PORT"),
|
||||
"db_name": os.environ.get("DATABASE_NAME"),
|
||||
}
|
||||
|
||||
if all(DB_VARS.values()):
|
||||
DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
|
||||
elif DATABASE_TYPE == "sqlite+sqlcipher" and not os.environ.get("DATABASE_URL"):
|
||||
# Handle SQLCipher with local file when DATABASE_URL wasn't explicitly set
|
||||
DATABASE_URL = f"sqlite+sqlcipher:///{DATA_DIR}/webui.db"
|
||||
|
||||
# Replace the postgres:// with postgresql://
|
||||
if "postgres://" in DATABASE_URL:
|
||||
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
||||
|
||||
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
||||
|
||||
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
|
||||
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None)
|
||||
|
||||
if DATABASE_POOL_SIZE == "":
|
||||
DATABASE_POOL_SIZE = 0
|
||||
else:
|
||||
if DATABASE_POOL_SIZE != None:
|
||||
try:
|
||||
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
||||
except Exception:
|
||||
DATABASE_POOL_SIZE = 0
|
||||
DATABASE_POOL_SIZE = None
|
||||
|
||||
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
||||
|
||||
@@ -310,6 +339,21 @@ else:
|
||||
except Exception:
|
||||
DATABASE_POOL_RECYCLE = 3600
|
||||
|
||||
DATABASE_ENABLE_SQLITE_WAL = (
|
||||
os.environ.get("DATABASE_ENABLE_SQLITE_WAL", "False").lower() == "true"
|
||||
)
|
||||
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get(
|
||||
"DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL", None
|
||||
)
|
||||
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
|
||||
try:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||
)
|
||||
except Exception:
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
|
||||
|
||||
RESET_CONFIG_ON_START = (
|
||||
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
|
||||
)
|
||||
@@ -323,23 +367,62 @@ ENABLE_REALTIME_CHAT_SAVE = (
|
||||
####################################
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
REDIS_CLUSTER = os.environ.get("REDIS_CLUSTER", "False").lower() == "true"
|
||||
|
||||
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
|
||||
|
||||
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
|
||||
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
|
||||
|
||||
# Maximum number of retries for Redis operations when using Sentinel fail-over
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT = os.environ.get("REDIS_SENTINEL_MAX_RETRY_COUNT", "2")
|
||||
try:
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT = int(REDIS_SENTINEL_MAX_RETRY_COUNT)
|
||||
if REDIS_SENTINEL_MAX_RETRY_COUNT < 1:
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
|
||||
except ValueError:
|
||||
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
|
||||
|
||||
####################################
|
||||
# UVICORN WORKERS
|
||||
####################################
|
||||
|
||||
# Number of uvicorn worker processes for handling requests
|
||||
UVICORN_WORKERS = os.environ.get("UVICORN_WORKERS", "1")
|
||||
try:
|
||||
UVICORN_WORKERS = int(UVICORN_WORKERS)
|
||||
if UVICORN_WORKERS < 1:
|
||||
UVICORN_WORKERS = 1
|
||||
except ValueError:
|
||||
UVICORN_WORKERS = 1
|
||||
log.info(f"Invalid UVICORN_WORKERS value, defaulting to {UVICORN_WORKERS}")
|
||||
|
||||
####################################
|
||||
# WEBUI_AUTH (Required for security)
|
||||
####################################
|
||||
|
||||
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
|
||||
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
|
||||
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get(
|
||||
"WEBUI_AUTH_SIGNOUT_REDIRECT_URL", None
|
||||
)
|
||||
|
||||
####################################
|
||||
# WEBUI_SECRET_KEY
|
||||
####################################
|
||||
@@ -372,19 +455,105 @@ WEBUI_AUTH_COOKIE_SECURE = (
|
||||
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
||||
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
||||
|
||||
ENABLE_COMPRESSION_MIDDLEWARE = (
|
||||
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# SCIM Configuration
|
||||
####################################
|
||||
|
||||
SCIM_ENABLED = os.environ.get("SCIM_ENABLED", "False").lower() == "true"
|
||||
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
|
||||
|
||||
####################################
|
||||
# LICENSE_KEY
|
||||
####################################
|
||||
|
||||
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
|
||||
|
||||
LICENSE_BLOB = None
|
||||
LICENSE_BLOB_PATH = os.environ.get("LICENSE_BLOB_PATH", DATA_DIR / "l.data")
|
||||
if LICENSE_BLOB_PATH and os.path.exists(LICENSE_BLOB_PATH):
|
||||
with open(LICENSE_BLOB_PATH, "rb") as f:
|
||||
LICENSE_BLOB = f.read()
|
||||
|
||||
LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
|
||||
|
||||
pk = None
|
||||
if LICENSE_PUBLIC_KEY:
|
||||
pk = serialization.load_pem_public_key(
|
||||
f"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
{LICENSE_PUBLIC_KEY}
|
||||
-----END PUBLIC KEY-----
|
||||
""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# MODELS
|
||||
####################################
|
||||
|
||||
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
|
||||
if MODELS_CACHE_TTL == "":
|
||||
MODELS_CACHE_TTL = None
|
||||
else:
|
||||
try:
|
||||
MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
|
||||
except Exception:
|
||||
MODELS_CACHE_TTL = 1
|
||||
|
||||
|
||||
####################################
|
||||
# CHAT
|
||||
####################################
|
||||
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get(
|
||||
"CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE", "1"
|
||||
)
|
||||
|
||||
if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == "":
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
|
||||
else:
|
||||
try:
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int(
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE
|
||||
)
|
||||
except Exception:
|
||||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
|
||||
|
||||
|
||||
####################################
|
||||
# WEBSOCKET SUPPORT
|
||||
####################################
|
||||
|
||||
ENABLE_WEBSOCKET_SUPPORT = (
|
||||
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
||||
|
||||
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
|
||||
WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60)
|
||||
WEBSOCKET_REDIS_CLUSTER = (
|
||||
os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true"
|
||||
)
|
||||
|
||||
websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
|
||||
|
||||
try:
|
||||
WEBSOCKET_REDIS_LOCK_TIMEOUT = int(websocket_redis_lock_timeout)
|
||||
except ValueError:
|
||||
WEBSOCKET_REDIS_LOCK_TIMEOUT = 60
|
||||
|
||||
WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
|
||||
|
||||
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
|
||||
|
||||
|
||||
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
|
||||
|
||||
if AIOHTTP_CLIENT_TIMEOUT == "":
|
||||
@@ -395,6 +564,11 @@ else:
|
||||
except Exception:
|
||||
AIOHTTP_CLIENT_TIMEOUT = 300
|
||||
|
||||
|
||||
AIOHTTP_CLIENT_SESSION_SSL = (
|
||||
os.environ.get("AIOHTTP_CLIENT_SESSION_SSL", "True").lower() == "true"
|
||||
)
|
||||
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
|
||||
"AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
|
||||
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
|
||||
@@ -408,14 +582,83 @@ else:
|
||||
except Exception:
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10
|
||||
|
||||
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = os.environ.get(
|
||||
"AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA", "10"
|
||||
)
|
||||
|
||||
if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA == "":
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = None
|
||||
else:
|
||||
try:
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = int(
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA
|
||||
)
|
||||
except Exception:
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
|
||||
|
||||
|
||||
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
|
||||
os.environ.get("AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL", "True").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# SENTENCE TRANSFORMERS
|
||||
####################################
|
||||
|
||||
|
||||
SENTENCE_TRANSFORMERS_BACKEND = os.environ.get("SENTENCE_TRANSFORMERS_BACKEND", "")
|
||||
if SENTENCE_TRANSFORMERS_BACKEND == "":
|
||||
SENTENCE_TRANSFORMERS_BACKEND = "torch"
|
||||
|
||||
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get(
|
||||
"SENTENCE_TRANSFORMERS_MODEL_KWARGS", ""
|
||||
)
|
||||
if SENTENCE_TRANSFORMERS_MODEL_KWARGS == "":
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS = None
|
||||
else:
|
||||
try:
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads(
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS
|
||||
)
|
||||
except Exception:
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS = None
|
||||
|
||||
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get(
|
||||
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND", ""
|
||||
)
|
||||
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == "":
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = "torch"
|
||||
|
||||
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = os.environ.get(
|
||||
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS", ""
|
||||
)
|
||||
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == "":
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
|
||||
else:
|
||||
try:
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads(
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS
|
||||
)
|
||||
except Exception:
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
|
||||
|
||||
####################################
|
||||
# OFFLINE_MODE
|
||||
####################################
|
||||
|
||||
ENABLE_VERSION_UPDATE_CHECK = (
|
||||
os.environ.get("ENABLE_VERSION_UPDATE_CHECK", "true").lower() == "true"
|
||||
)
|
||||
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
||||
|
||||
if OFFLINE_MODE:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
ENABLE_VERSION_UPDATE_CHECK = False
|
||||
|
||||
####################################
|
||||
# AUDIT LOGGING
|
||||
@@ -424,6 +667,14 @@ if OFFLINE_MODE:
|
||||
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
|
||||
# Maximum size of a file before rotating into a new log file
|
||||
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
|
||||
|
||||
# Comma separated list of logger names to use for audit logging
|
||||
# Default is "uvicorn.access" which is the access log for Uvicorn
|
||||
# You can add more logger names to this list if you want to capture more logs
|
||||
AUDIT_UVICORN_LOGGER_NAMES = os.getenv(
|
||||
"AUDIT_UVICORN_LOGGER_NAMES", "uvicorn.access"
|
||||
).split(",")
|
||||
|
||||
# METADATA | REQUEST | REQUEST_RESPONSE
|
||||
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
||||
try:
|
||||
@@ -438,14 +689,40 @@ AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders"
|
||||
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
|
||||
AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
|
||||
|
||||
|
||||
####################################
|
||||
# OPENTELEMETRY
|
||||
####################################
|
||||
|
||||
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
|
||||
ENABLE_OTEL_TRACES = os.environ.get("ENABLE_OTEL_TRACES", "False").lower() == "true"
|
||||
ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true"
|
||||
ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true"
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
||||
)
|
||||
OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
||||
"OTEL_METRICS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
)
|
||||
OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
||||
"OTEL_LOGS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
)
|
||||
OTEL_EXPORTER_OTLP_INSECURE = (
|
||||
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
|
||||
)
|
||||
OTEL_METRICS_EXPORTER_OTLP_INSECURE = (
|
||||
os.environ.get(
|
||||
"OTEL_METRICS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
OTEL_LOGS_EXPORTER_OTLP_INSECURE = (
|
||||
os.environ.get(
|
||||
"OTEL_LOGS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
|
||||
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||
"OTEL_RESOURCE_ATTRIBUTES", ""
|
||||
@@ -453,6 +730,33 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||
OTEL_TRACES_SAMPLER = os.environ.get(
|
||||
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
||||
).lower()
|
||||
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
|
||||
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
|
||||
|
||||
OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get(
|
||||
"OTEL_METRICS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
|
||||
)
|
||||
OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get(
|
||||
"OTEL_METRICS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
|
||||
)
|
||||
OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get(
|
||||
"OTEL_LOGS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
|
||||
)
|
||||
OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get(
|
||||
"OTEL_LOGS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
|
||||
)
|
||||
|
||||
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
|
||||
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
|
||||
).lower() # grpc or http
|
||||
|
||||
OTEL_METRICS_OTLP_SPAN_EXPORTER = os.environ.get(
|
||||
"OTEL_METRICS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
|
||||
).lower() # grpc or http
|
||||
|
||||
OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
|
||||
"OTEL_LOGS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
|
||||
).lower() # grpc or http
|
||||
|
||||
####################################
|
||||
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||
@@ -460,3 +764,10 @@ OTEL_TRACES_SAMPLER = os.environ.get(
|
||||
|
||||
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
|
||||
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
|
||||
|
||||
|
||||
####################################
|
||||
# PROGRESSIVE WEB APP OPTIONS
|
||||
####################################
|
||||
|
||||
EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,7 +47,7 @@ from open_webui.utils.misc import (
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_model_system_prompt_to_body,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,11 @@ 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_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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
@@ -13,9 +14,10 @@ from open_webui.env import (
|
||||
DATABASE_POOL_RECYCLE,
|
||||
DATABASE_POOL_SIZE,
|
||||
DATABASE_POOL_TIMEOUT,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
)
|
||||
from peewee_migrate import Router
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, types
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool, NullPool
|
||||
@@ -62,6 +64,9 @@ def handle_peewee_migration(DATABASE_URL):
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize the database connection: {e}")
|
||||
log.warning(
|
||||
"Hint: If your database password contains special characters, you may need to URL-encode it."
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Properly closing the database connection
|
||||
@@ -76,25 +81,68 @@ handle_peewee_migration(DATABASE_URL)
|
||||
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = DATABASE_URL
|
||||
if "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||
|
||||
# Handle SQLCipher URLs
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
|
||||
database_password = os.environ.get("DATABASE_PASSWORD")
|
||||
if not database_password or database_password.strip() == "":
|
||||
raise ValueError(
|
||||
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
|
||||
)
|
||||
|
||||
# Extract database path from SQLCipher URL
|
||||
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
|
||||
if db_path.startswith("/"):
|
||||
db_path = db_path[1:] # Remove leading slash for relative paths
|
||||
|
||||
# Create a custom creator function that uses sqlcipher3
|
||||
def create_sqlcipher_connection():
|
||||
import sqlcipher3
|
||||
|
||||
conn = sqlcipher3.connect(db_path, check_same_thread=False)
|
||||
conn.execute(f"PRAGMA key = '{database_password}'")
|
||||
return conn
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://", # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
log.info("Connected to encrypted SQLite database using SQLCipher")
|
||||
|
||||
elif "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
def on_connect(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
else:
|
||||
cursor.execute("PRAGMA journal_mode=DELETE")
|
||||
cursor.close()
|
||||
|
||||
event.listen(engine, "connect", on_connect)
|
||||
else:
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
poolclass=QueuePool,
|
||||
)
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
poolclass=QueuePool,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
||||
)
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
@@ -43,24 +44,47 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
|
||||
|
||||
|
||||
def register_connection(db_url):
|
||||
db = connect(db_url, unquote_password=True)
|
||||
if isinstance(db, PostgresqlDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
# Check if using SQLCipher protocol
|
||||
if db_url.startswith("sqlite+sqlcipher://"):
|
||||
database_password = os.environ.get("DATABASE_PASSWORD")
|
||||
if not database_password or database_password.strip() == "":
|
||||
raise ValueError(
|
||||
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
|
||||
)
|
||||
from playhouse.sqlcipher_ext import SqlCipherDatabase
|
||||
|
||||
# Parse the database path from SQLCipher URL
|
||||
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
|
||||
db_path = db_url.replace("sqlite+sqlcipher://", "")
|
||||
if db_path.startswith("/"):
|
||||
db_path = db_path[1:] # Remove leading slash for relative paths
|
||||
|
||||
# Use Peewee's native SqlCipherDatabase with encryption
|
||||
db = SqlCipherDatabase(db_path, passphrase=database_password)
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to PostgreSQL database")
|
||||
log.info("Connected to encrypted SQLite database using SQLCipher")
|
||||
|
||||
# Get the connection details
|
||||
connection = parse(db_url, unquote_password=True)
|
||||
|
||||
# Use our custom database class that supports reconnection
|
||||
db = ReconnectingPostgresqlDatabase(**connection)
|
||||
db.connect(reuse_if_open=True)
|
||||
elif isinstance(db, SqliteDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to SQLite database")
|
||||
else:
|
||||
raise ValueError("Unsupported database connection")
|
||||
# Standard database connection (existing logic)
|
||||
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
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to PostgreSQL database")
|
||||
|
||||
# Get the connection details
|
||||
connection = parse(db_url, unquote_user=True, unquote_password=True)
|
||||
|
||||
# Use our custom database class that supports reconnection
|
||||
db = ReconnectingPostgresqlDatabase(**connection)
|
||||
db.connect(reuse_if_open=True)
|
||||
elif isinstance(db, SqliteDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to SQLite database")
|
||||
else:
|
||||
raise ValueError("Unsupported database connection")
|
||||
return db
|
||||
|
||||
+651
-127
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.env import DATABASE_URL
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
|
||||
from sqlalchemy import engine_from_config, pool, create_engine
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
@@ -62,11 +62,38 @@ def run_migrations_online() -> None:
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
# Handle SQLCipher URLs
|
||||
if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
|
||||
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
|
||||
raise ValueError(
|
||||
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
|
||||
)
|
||||
|
||||
# Extract database path from SQLCipher URL
|
||||
db_path = DB_URL.replace("sqlite+sqlcipher://", "")
|
||||
if db_path.startswith("/"):
|
||||
db_path = db_path[1:] # Remove leading slash for relative paths
|
||||
|
||||
# Create a custom creator function that uses sqlcipher3
|
||||
def create_sqlcipher_connection():
|
||||
import sqlcipher3
|
||||
|
||||
conn = sqlcipher3.connect(db_path, check_same_thread=False)
|
||||
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return conn
|
||||
|
||||
connectable = create_engine(
|
||||
"sqlite://", # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
# Standard database connection (existing logic)
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add indexes
|
||||
|
||||
Revision ID: 018012973d35
|
||||
Revises: d31026856c01
|
||||
Create Date: 2025-08-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "018012973d35"
|
||||
down_revision = "d31026856c01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Chat table indexes
|
||||
op.create_index("folder_id_idx", "chat", ["folder_id"])
|
||||
op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"])
|
||||
op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"])
|
||||
op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"])
|
||||
op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"])
|
||||
|
||||
# Tag table index
|
||||
op.create_index("user_id_idx", "tag", ["user_id"])
|
||||
|
||||
# Function table index
|
||||
op.create_index("is_global_idx", "function", ["is_global"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Chat table indexes
|
||||
op.drop_index("folder_id_idx", table_name="chat")
|
||||
op.drop_index("user_id_pinned_idx", table_name="chat")
|
||||
op.drop_index("user_id_archived_idx", table_name="chat")
|
||||
op.drop_index("updated_at_user_id_idx", table_name="chat")
|
||||
op.drop_index("folder_id_user_id_idx", table_name="chat")
|
||||
|
||||
# Tag table index
|
||||
op.drop_index("user_id_idx", table_name="tag")
|
||||
|
||||
# Function table index
|
||||
|
||||
op.drop_index("is_global_idx", table_name="function")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""update user table
|
||||
|
||||
Revision ID: 3af16a1c9fb6
|
||||
Revises: 018012973d35
|
||||
Create Date: 2025-08-21 02:07:18.078283
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3af16a1c9fb6"
|
||||
down_revision: Union[str, None] = "018012973d35"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user", sa.Column("username", sa.String(length=50), nullable=True))
|
||||
op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
|
||||
op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
|
||||
op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "username")
|
||||
op.drop_column("user", "bio")
|
||||
op.drop_column("user", "gender")
|
||||
op.drop_column("user", "date_of_birth")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add note table
|
||||
|
||||
Revision ID: 9f0c9cd09105
|
||||
Revises: 3781e22d8b01
|
||||
Create Date: 2025-05-03 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "9f0c9cd09105"
|
||||
down_revision = "3781e22d8b01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"note",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("note")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Update folder table data
|
||||
|
||||
Revision ID: d31026856c01
|
||||
Revises: 9f0c9cd09105
|
||||
Create Date: 2025-07-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "d31026856c01"
|
||||
down_revision = "9f0c9cd09105"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("folder", "data")
|
||||
@@ -73,11 +73,6 @@ class ProfileImageUrlForm(BaseModel):
|
||||
profile_image_url: str
|
||||
|
||||
|
||||
class UpdateProfileForm(BaseModel):
|
||||
profile_image_url: str
|
||||
name: str
|
||||
|
||||
|
||||
class UpdatePasswordForm(BaseModel):
|
||||
password: str
|
||||
new_password: str
|
||||
@@ -129,12 +124,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 +154,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()
|
||||
|
||||
@@ -6,12 +6,14 @@ from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.tags import TagModel, Tag, Tags
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, Index
|
||||
from sqlalchemy import or_, func, select, and_, text
|
||||
from sqlalchemy.sql import exists
|
||||
from sqlalchemy.sql.expression import bindparam
|
||||
|
||||
####################
|
||||
# Chat DB Schema
|
||||
@@ -39,6 +41,20 @@ class Chat(Base):
|
||||
meta = Column(JSON, server_default="{}")
|
||||
folder_id = Column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Performance indexes for common queries
|
||||
# WHERE folder_id = ...
|
||||
Index("folder_id_idx", "folder_id"),
|
||||
# WHERE user_id = ... AND pinned = ...
|
||||
Index("user_id_pinned_idx", "user_id", "pinned"),
|
||||
# WHERE user_id = ... AND archived = ...
|
||||
Index("user_id_archived_idx", "user_id", "archived"),
|
||||
# WHERE user_id = ... ORDER BY updated_at DESC
|
||||
Index("updated_at_user_id_idx", "updated_at", "user_id"),
|
||||
# WHERE folder_id = ... AND user_id = ...
|
||||
Index("folder_id_user_id_idx", "folder_id", "user_id"),
|
||||
)
|
||||
|
||||
|
||||
class ChatModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -66,12 +82,14 @@ class ChatModel(BaseModel):
|
||||
|
||||
class ChatForm(BaseModel):
|
||||
chat: dict
|
||||
folder_id: Optional[str] = None
|
||||
|
||||
|
||||
class ChatImportForm(ChatForm):
|
||||
meta: Optional[dict] = {}
|
||||
pinned: Optional[bool] = False
|
||||
folder_id: Optional[str] = None
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
|
||||
class ChatTitleMessagesForm(BaseModel):
|
||||
@@ -118,6 +136,7 @@ class ChatTable:
|
||||
else "New Chat"
|
||||
),
|
||||
"chat": form_data.chat,
|
||||
"folder_id": form_data.folder_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
@@ -147,8 +166,16 @@ class ChatTable:
|
||||
"meta": form_data.meta,
|
||||
"pinned": form_data.pinned,
|
||||
"folder_id": form_data.folder_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": (
|
||||
form_data.created_at
|
||||
if form_data.created_at
|
||||
else int(time.time())
|
||||
),
|
||||
"updated_at": (
|
||||
form_data.updated_at
|
||||
if form_data.updated_at
|
||||
else int(time.time())
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -232,6 +259,10 @@ class ChatTable:
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
# Sanitize message content for null characters before upserting
|
||||
if isinstance(message.get("content"), str):
|
||||
message["content"] = message["content"].replace("\x00", "")
|
||||
|
||||
chat = chat.chat
|
||||
history = chat.get("history", {})
|
||||
|
||||
@@ -280,6 +311,9 @@ class ChatTable:
|
||||
"user_id": f"shared-{chat_id}",
|
||||
"title": chat.title,
|
||||
"chat": chat.chat,
|
||||
"meta": chat.meta,
|
||||
"pinned": chat.pinned,
|
||||
"folder_id": chat.folder_id,
|
||||
"created_at": chat.created_at,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
@@ -311,7 +345,9 @@ class ChatTable:
|
||||
|
||||
shared_chat.title = chat.title
|
||||
shared_chat.chat = chat.chat
|
||||
|
||||
shared_chat.meta = chat.meta
|
||||
shared_chat.pinned = chat.pinned
|
||||
shared_chat.folder_id = chat.folder_id
|
||||
shared_chat.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(shared_chat)
|
||||
@@ -377,22 +413,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 +462,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)
|
||||
@@ -436,7 +513,7 @@ class ChatTable:
|
||||
|
||||
all_chats = query.all()
|
||||
|
||||
# result has to be destrctured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass.
|
||||
# result has to be destructured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass.
|
||||
return [
|
||||
ChatTitleIdResponse.model_validate(
|
||||
{
|
||||
@@ -539,10 +616,12 @@ class ChatTable:
|
||||
"""
|
||||
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
||||
"""
|
||||
search_text = search_text.lower().strip()
|
||||
search_text = search_text.replace("\u0000", "").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(" ")
|
||||
|
||||
@@ -553,8 +632,45 @@ class ChatTable:
|
||||
if word.startswith("tag:")
|
||||
]
|
||||
|
||||
# Extract folder names - handle spaces and case insensitivity
|
||||
folders = Folders.search_folders_by_names(
|
||||
user_id,
|
||||
[
|
||||
word.replace("folder:", "")
|
||||
for word in search_text_words
|
||||
if word.startswith("folder:")
|
||||
],
|
||||
)
|
||||
folder_ids = [folder.id for folder in folders]
|
||||
|
||||
is_pinned = None
|
||||
if "pinned:true" in search_text_words:
|
||||
is_pinned = True
|
||||
elif "pinned:false" in search_text_words:
|
||||
is_pinned = False
|
||||
|
||||
is_archived = None
|
||||
if "archived:true" in search_text_words:
|
||||
is_archived = True
|
||||
elif "archived:false" in search_text_words:
|
||||
is_archived = False
|
||||
|
||||
is_shared = None
|
||||
if "shared:true" in search_text_words:
|
||||
is_shared = True
|
||||
elif "shared:false" in search_text_words:
|
||||
is_shared = False
|
||||
|
||||
search_text_words = [
|
||||
word for word in search_text_words if not word.startswith("tag:")
|
||||
word
|
||||
for word in search_text_words
|
||||
if (
|
||||
not word.startswith("tag:")
|
||||
and not word.startswith("folder:")
|
||||
and not word.startswith("pinned:")
|
||||
and not word.startswith("archived:")
|
||||
and not word.startswith("shared:")
|
||||
)
|
||||
]
|
||||
|
||||
search_text = " ".join(search_text_words)
|
||||
@@ -562,30 +678,41 @@ class ChatTable:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter(Chat.user_id == user_id)
|
||||
|
||||
if not include_archived:
|
||||
if is_archived is not None:
|
||||
query = query.filter(Chat.archived == is_archived)
|
||||
elif not include_archived:
|
||||
query = query.filter(Chat.archived == False)
|
||||
|
||||
if is_pinned is not None:
|
||||
query = query.filter(Chat.pinned == is_pinned)
|
||||
|
||||
if is_shared is not None:
|
||||
if is_shared:
|
||||
query = query.filter(Chat.share_id.isnot(None))
|
||||
else:
|
||||
query = query.filter(Chat.share_id.is_(None))
|
||||
|
||||
if folder_ids:
|
||||
query = query.filter(Chat.folder_id.in_(folder_ids))
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
# Check if the database dialect is either 'sqlite' or 'postgresql'
|
||||
dialect_name = db.bind.dialect.name
|
||||
if dialect_name == "sqlite":
|
||||
# SQLite case: using JSON1 extension for JSON searching
|
||||
sqlite_content_sql = (
|
||||
"EXISTS ("
|
||||
" SELECT 1 "
|
||||
" FROM json_each(Chat.chat, '$.messages') AS message "
|
||||
" WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
|
||||
")"
|
||||
)
|
||||
sqlite_content_clause = text(sqlite_content_sql)
|
||||
query = query.filter(
|
||||
(
|
||||
Chat.title.ilike(
|
||||
f"%{search_text}%"
|
||||
) # Case-insensitive search in title
|
||||
| text(
|
||||
"""
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat, '$.messages') AS message
|
||||
WHERE LOWER(message.value->>'content') LIKE '%' || :search_text || '%'
|
||||
)
|
||||
"""
|
||||
)
|
||||
).params(search_text=search_text)
|
||||
or_(
|
||||
Chat.title.ilike(bindparam("title_key")), sqlite_content_clause
|
||||
).params(title_key=f"%{search_text}%", content_key=search_text)
|
||||
)
|
||||
|
||||
# Check if there are any tags to filter, it should have all the tags
|
||||
@@ -620,21 +747,19 @@ class ChatTable:
|
||||
|
||||
elif dialect_name == "postgresql":
|
||||
# PostgreSQL relies on proper JSON query for search
|
||||
postgres_content_sql = (
|
||||
"EXISTS ("
|
||||
" SELECT 1 "
|
||||
" FROM json_array_elements(Chat.chat->'messages') AS message "
|
||||
" WHERE LOWER(message->>'content') LIKE '%' || :content_key || '%'"
|
||||
")"
|
||||
)
|
||||
postgres_content_clause = text(postgres_content_sql)
|
||||
query = query.filter(
|
||||
(
|
||||
Chat.title.ilike(
|
||||
f"%{search_text}%"
|
||||
) # Case-insensitive search in title
|
||||
| text(
|
||||
"""
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
||||
WHERE LOWER(message->>'content') LIKE '%' || :search_text || '%'
|
||||
)
|
||||
"""
|
||||
)
|
||||
).params(search_text=search_text)
|
||||
or_(
|
||||
Chat.title.ilike(bindparam("title_key")),
|
||||
postgres_content_clause,
|
||||
).params(title_key=f"%{search_text}%", content_key=search_text)
|
||||
)
|
||||
|
||||
# Check if there are any tags to filter, it should have all the tags
|
||||
|
||||
@@ -2,14 +2,14 @@ import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.chats import Chats
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
||||
from open_webui.utils.access_control import get_permissions
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -29,6 +29,7 @@ class Folder(Base):
|
||||
name = Column(Text)
|
||||
items = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
data = Column(JSON, nullable=True)
|
||||
is_expanded = Column(Boolean, default=False)
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
@@ -41,6 +42,7 @@ class FolderModel(BaseModel):
|
||||
name: str
|
||||
items: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
data: Optional[dict] = None
|
||||
is_expanded: bool = False
|
||||
created_at: int
|
||||
updated_at: int
|
||||
@@ -55,12 +57,13 @@ class FolderModel(BaseModel):
|
||||
|
||||
class FolderForm(BaseModel):
|
||||
name: str
|
||||
data: Optional[dict] = None
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class FolderTable:
|
||||
def insert_new_folder(
|
||||
self, user_id: str, name: str, parent_id: Optional[str] = None
|
||||
self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None
|
||||
) -> Optional[FolderModel]:
|
||||
with get_db() as db:
|
||||
id = str(uuid.uuid4())
|
||||
@@ -68,7 +71,7 @@ class FolderTable:
|
||||
**{
|
||||
"id": id,
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
**(form_data.model_dump(exclude_unset=True) or {}),
|
||||
"parent_id": parent_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
@@ -103,7 +106,7 @@ class FolderTable:
|
||||
|
||||
def get_children_folders_by_id_and_user_id(
|
||||
self, id: str, user_id: str
|
||||
) -> Optional[FolderModel]:
|
||||
) -> Optional[list[FolderModel]]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
folders = []
|
||||
@@ -187,8 +190,8 @@ class FolderTable:
|
||||
log.error(f"update_folder: {e}")
|
||||
return
|
||||
|
||||
def update_folder_name_by_id_and_user_id(
|
||||
self, id: str, user_id: str, name: str
|
||||
def update_folder_by_id_and_user_id(
|
||||
self, id: str, user_id: str, form_data: FolderForm
|
||||
) -> Optional[FolderModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
@@ -197,16 +200,28 @@ class FolderTable:
|
||||
if not folder:
|
||||
return None
|
||||
|
||||
form_data = form_data.model_dump(exclude_unset=True)
|
||||
|
||||
existing_folder = (
|
||||
db.query(Folder)
|
||||
.filter_by(name=name, parent_id=folder.parent_id, user_id=user_id)
|
||||
.filter_by(
|
||||
name=form_data.get("name"),
|
||||
parent_id=folder.parent_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_folder:
|
||||
if existing_folder and existing_folder.id != id:
|
||||
return None
|
||||
|
||||
folder.name = name
|
||||
folder.name = form_data.get("name", folder.name)
|
||||
if "data" in form_data:
|
||||
folder.data = {
|
||||
**(folder.data or {}),
|
||||
**form_data["data"],
|
||||
}
|
||||
|
||||
folder.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
@@ -236,18 +251,15 @@ class FolderTable:
|
||||
log.error(f"update_folder: {e}")
|
||||
return
|
||||
|
||||
def delete_folder_by_id_and_user_id(
|
||||
self, id: str, user_id: str, delete_chats=True
|
||||
) -> bool:
|
||||
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
|
||||
try:
|
||||
folder_ids = []
|
||||
with get_db() as db:
|
||||
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
|
||||
if not folder:
|
||||
return False
|
||||
return folder_ids
|
||||
|
||||
if delete_chats:
|
||||
# Delete all chats in the folder
|
||||
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
|
||||
folder_ids.append(folder.id)
|
||||
|
||||
# Delete all children folders
|
||||
def delete_children(folder):
|
||||
@@ -255,12 +267,9 @@ class FolderTable:
|
||||
folder.id, user_id
|
||||
)
|
||||
for folder_child in folder_children:
|
||||
if delete_chats:
|
||||
Chats.delete_chats_by_user_id_and_folder_id(
|
||||
user_id, folder_child.id
|
||||
)
|
||||
|
||||
delete_children(folder_child)
|
||||
folder_ids.append(folder_child.id)
|
||||
|
||||
folder = db.query(Folder).filter_by(id=folder_child.id).first()
|
||||
db.delete(folder)
|
||||
@@ -269,10 +278,62 @@ class FolderTable:
|
||||
delete_children(folder)
|
||||
db.delete(folder)
|
||||
db.commit()
|
||||
return True
|
||||
return folder_ids
|
||||
except Exception as e:
|
||||
log.error(f"delete_folder: {e}")
|
||||
return False
|
||||
return []
|
||||
|
||||
def normalize_folder_name(self, name: str) -> str:
|
||||
# Replace _ and space with a single space, lower case, collapse multiple spaces
|
||||
name = re.sub(r"[\s_]+", " ", name)
|
||||
return name.strip().lower()
|
||||
|
||||
def search_folders_by_names(
|
||||
self, user_id: str, queries: list[str]
|
||||
) -> list[FolderModel]:
|
||||
"""
|
||||
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
|
||||
"""
|
||||
normalized_queries = [self.normalize_folder_name(q) for q in queries]
|
||||
if not normalized_queries:
|
||||
return []
|
||||
|
||||
results = {}
|
||||
with get_db() as db:
|
||||
folders = db.query(Folder).filter_by(user_id=user_id).all()
|
||||
for folder in folders:
|
||||
if self.normalize_folder_name(folder.name) in normalized_queries:
|
||||
results[folder.id] = FolderModel.model_validate(folder)
|
||||
|
||||
# get children folders
|
||||
children = self.get_children_folders_by_id_and_user_id(
|
||||
folder.id, user_id
|
||||
)
|
||||
for child in children:
|
||||
results[child.id] = child
|
||||
|
||||
# Return the results as a list
|
||||
if not results:
|
||||
return []
|
||||
else:
|
||||
results = list(results.values())
|
||||
return results
|
||||
|
||||
def search_folders_by_name_contains(
|
||||
self, user_id: str, query: str
|
||||
) -> list[FolderModel]:
|
||||
"""
|
||||
Partial match: normalized name contains (as substring) the normalized query.
|
||||
"""
|
||||
normalized_query = self.normalize_folder_name(query)
|
||||
results = []
|
||||
with get_db() as db:
|
||||
folders = db.query(Folder).filter_by(user_id=user_id).all()
|
||||
for folder in folders:
|
||||
norm_name = self.normalize_folder_name(folder.name)
|
||||
if normalized_query in norm_name:
|
||||
results.append(FolderModel.model_validate(folder))
|
||||
return results
|
||||
|
||||
|
||||
Folders = FolderTable()
|
||||
|
||||
@@ -6,7 +6,7 @@ from open_webui.internal.db import Base, JSONField, get_db
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
@@ -31,6 +31,8 @@ class Function(Base):
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
__table_args__ = (Index("is_global_idx", "is_global"),)
|
||||
|
||||
|
||||
class FunctionMeta(BaseModel):
|
||||
description: Optional[str] = None
|
||||
@@ -108,6 +110,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:
|
||||
@@ -202,9 +252,7 @@ class FunctionsTable:
|
||||
|
||||
return user_settings["functions"]["valves"].get(id, {})
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error getting user values by id {id} and user id {user_id}: {e}"
|
||||
)
|
||||
log.exception(f"Error getting user values by id {id} and user id {user_id}")
|
||||
return None
|
||||
|
||||
def update_user_valves_by_id_and_user_id(
|
||||
|
||||
@@ -83,10 +83,14 @@ class GroupForm(BaseModel):
|
||||
permissions: Optional[dict] = None
|
||||
|
||||
|
||||
class GroupUpdateForm(GroupForm):
|
||||
class UserIdsForm(BaseModel):
|
||||
user_ids: Optional[list[str]] = None
|
||||
|
||||
|
||||
class GroupUpdateForm(GroupForm, UserIdsForm):
|
||||
pass
|
||||
|
||||
|
||||
class GroupTable:
|
||||
def insert_new_group(
|
||||
self, user_id: str, form_data: GroupForm
|
||||
@@ -207,5 +211,121 @@ 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
|
||||
|
||||
def add_users_to_group(
|
||||
self, id: str, user_ids: Optional[list[str]] = None
|
||||
) -> Optional[GroupModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
group = db.query(Group).filter_by(id=id).first()
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if not group.user_ids:
|
||||
group.user_ids = []
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id not in group.user_ids:
|
||||
group.user_ids.append(user_id)
|
||||
|
||||
group.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return GroupModel.model_validate(group)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
|
||||
def remove_users_from_group(
|
||||
self, id: str, user_ids: Optional[list[str]] = None
|
||||
) -> Optional[GroupModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
group = db.query(Group).filter_by(id=id).first()
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if not group.user_ids:
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id in group.user_ids:
|
||||
group.user_ids.remove(user_id)
|
||||
|
||||
group.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return GroupModel.model_validate(group)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
|
||||
|
||||
Groups = GroupTable()
|
||||
|
||||
@@ -63,16 +63,21 @@ class MemoriesTable:
|
||||
else:
|
||||
return None
|
||||
|
||||
def update_memory_by_id(
|
||||
def update_memory_by_id_and_user_id(
|
||||
self,
|
||||
id: str,
|
||||
user_id: str,
|
||||
content: str,
|
||||
) -> Optional[MemoryModel]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
db.query(Memory).filter_by(id=id).update(
|
||||
{"content": content, "updated_at": int(time.time())}
|
||||
)
|
||||
memory = db.get(Memory, id)
|
||||
if not memory or memory.user_id != user_id:
|
||||
return None
|
||||
|
||||
memory.content = content
|
||||
memory.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
return self.get_memory_by_id(id)
|
||||
except Exception:
|
||||
@@ -126,7 +131,12 @@ class MemoriesTable:
|
||||
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
|
||||
with get_db() as db:
|
||||
try:
|
||||
db.query(Memory).filter_by(id=id, user_id=user_id).delete()
|
||||
memory = db.get(Memory, id)
|
||||
if not memory or memory.user_id != user_id:
|
||||
return None
|
||||
|
||||
# Delete the memory
|
||||
db.delete(memory)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
@@ -269,5 +269,49 @@ class ModelsTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
# Get existing models
|
||||
existing_models = db.query(Model).all()
|
||||
existing_ids = {model.id for model in existing_models}
|
||||
|
||||
# Prepare a set of new model IDs
|
||||
new_model_ids = {model.id for model in models}
|
||||
|
||||
# Update or insert models
|
||||
for model in models:
|
||||
if model.id in existing_ids:
|
||||
db.query(Model).filter_by(id=model.id).update(
|
||||
{
|
||||
**model.model_dump(),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
else:
|
||||
new_model = Model(
|
||||
**{
|
||||
**model.model_dump(),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(new_model)
|
||||
|
||||
# Remove models that are no longer present
|
||||
for model in existing_models:
|
||||
if model.id not in new_model_ids:
|
||||
db.delete(model)
|
||||
|
||||
db.commit()
|
||||
|
||||
return [
|
||||
ModelModel.model_validate(model) for model in db.query(Model).all()
|
||||
]
|
||||
except Exception as e:
|
||||
log.exception(f"Error syncing models for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
Models = ModelsTable()
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy import or_, func, select, and_, text
|
||||
from sqlalchemy.sql import exists
|
||||
|
||||
####################
|
||||
# Note DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Note(Base):
|
||||
__tablename__ = "note"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text)
|
||||
|
||||
title = Column(Text)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
||||
class NoteModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
|
||||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class NoteForm(BaseModel):
|
||||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class NoteUpdateForm(BaseModel):
|
||||
title: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class NoteUserResponse(NoteModel):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class NoteTable:
|
||||
def insert_new_note(
|
||||
self,
|
||||
form_data: NoteForm,
|
||||
user_id: str,
|
||||
) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
note = NoteModel(
|
||||
**{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
**form_data.model_dump(),
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
}
|
||||
)
|
||||
|
||||
new_note = Note(**note.model_dump())
|
||||
|
||||
db.add(new_note)
|
||||
db.commit()
|
||||
return note
|
||||
|
||||
def get_notes(self) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_user_id(
|
||||
self, user_id: str, permission: str = "write"
|
||||
) -> list[NoteModel]:
|
||||
notes = self.get_notes()
|
||||
return [
|
||||
note
|
||||
for note in notes
|
||||
if note.user_id == user_id
|
||||
or has_access(user_id, permission, note.access_control)
|
||||
]
|
||||
|
||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
note = db.query(Note).filter(Note.id == id).first()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
|
||||
def update_note_by_id(
|
||||
self, id: str, form_data: NoteUpdateForm
|
||||
) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
note = db.query(Note).filter(Note.id == id).first()
|
||||
if not note:
|
||||
return None
|
||||
|
||||
form_data = form_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "title" in form_data:
|
||||
note.title = form_data["title"]
|
||||
if "data" in form_data:
|
||||
note.data = {**note.data, **form_data["data"]}
|
||||
if "meta" in form_data:
|
||||
note.meta = {**note.meta, **form_data["meta"]}
|
||||
|
||||
if "access_control" in form_data:
|
||||
note.access_control = form_data["access_control"]
|
||||
|
||||
note.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
|
||||
def delete_note_by_id(self, id: str):
|
||||
with get_db() as db:
|
||||
db.query(Note).filter(Note.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
Notes = NoteTable()
|
||||
@@ -8,7 +8,7 @@ from open_webui.internal.db import Base, get_db
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint
|
||||
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
@@ -24,6 +24,11 @@ class Tag(Base):
|
||||
user_id = Column(String)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),
|
||||
Index("user_id_idx", "user_id"),
|
||||
)
|
||||
|
||||
# Unique constraint ensuring (id, user_id) is unique, not just the `id` column
|
||||
__table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class ToolsTable:
|
||||
tool = db.get(Tool, id)
|
||||
return tool.valves if tool.valves else {}
|
||||
except Exception as e:
|
||||
log.exception(f"Error getting tool valves by id {id}: {e}")
|
||||
log.exception(f"Error getting tool valves by id {id}")
|
||||
return None
|
||||
|
||||
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
|
||||
|
||||
@@ -4,12 +4,17 @@ from typing import Optional
|
||||
from open_webui.internal.db import Base, JSONField, get_db
|
||||
|
||||
|
||||
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.misc import throttle
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text
|
||||
from sqlalchemy import BigInteger, Column, String, Text, Date
|
||||
from sqlalchemy import or_
|
||||
|
||||
import datetime
|
||||
|
||||
####################
|
||||
# User DB Schema
|
||||
@@ -21,20 +26,28 @@ class User(Base):
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String)
|
||||
|
||||
email = Column(String)
|
||||
username = Column(String(50), nullable=True)
|
||||
|
||||
role = Column(String)
|
||||
profile_image_url = Column(Text)
|
||||
|
||||
last_active_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
bio = Column(Text, nullable=True)
|
||||
gender = Column(Text, nullable=True)
|
||||
date_of_birth = Column(Date, nullable=True)
|
||||
|
||||
info = Column(JSONField, nullable=True)
|
||||
settings = Column(JSONField, nullable=True)
|
||||
|
||||
api_key = Column(String, nullable=True, unique=True)
|
||||
settings = Column(JSONField, nullable=True)
|
||||
info = Column(JSONField, nullable=True)
|
||||
|
||||
oauth_sub = Column(Text, unique=True)
|
||||
|
||||
last_active_at = Column(BigInteger)
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
ui: Optional[dict] = {}
|
||||
@@ -45,20 +58,27 @@ class UserSettings(BaseModel):
|
||||
class UserModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
email: str
|
||||
username: Optional[str] = None
|
||||
|
||||
role: str = "pending"
|
||||
profile_image_url: str
|
||||
|
||||
bio: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
info: Optional[dict] = None
|
||||
settings: Optional[UserSettings] = None
|
||||
|
||||
api_key: Optional[str] = None
|
||||
oauth_sub: Optional[str] = None
|
||||
|
||||
last_active_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
api_key: Optional[str] = None
|
||||
settings: Optional[UserSettings] = None
|
||||
info: Optional[dict] = None
|
||||
|
||||
oauth_sub: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -67,6 +87,31 @@ class UserModel(BaseModel):
|
||||
####################
|
||||
|
||||
|
||||
class UpdateProfileForm(BaseModel):
|
||||
profile_image_url: str
|
||||
name: str
|
||||
bio: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserModel]
|
||||
total: int
|
||||
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
|
||||
|
||||
class UserInfoListResponse(BaseModel):
|
||||
users: list[UserInfoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -88,6 +133,7 @@ class UserRoleUpdateForm(BaseModel):
|
||||
|
||||
|
||||
class UserUpdateForm(BaseModel):
|
||||
role: str
|
||||
name: str
|
||||
email: str
|
||||
profile_image_url: str
|
||||
@@ -160,11 +206,63 @@ class UsersTable:
|
||||
return None
|
||||
|
||||
def get_users(
|
||||
self, skip: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> list[UserModel]:
|
||||
self,
|
||||
filter: Optional[dict] = None,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> UserListResponse:
|
||||
with get_db() as db:
|
||||
query = db.query(User)
|
||||
|
||||
query = db.query(User).order_by(User.created_at.desc())
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.name.asc())
|
||||
else:
|
||||
query = query.order_by(User.name.desc())
|
||||
elif order_by == "email":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.email.asc())
|
||||
else:
|
||||
query = query.order_by(User.email.desc())
|
||||
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
elif order_by == "last_active_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.last_active_at.asc())
|
||||
else:
|
||||
query = query.order_by(User.last_active_at.desc())
|
||||
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(User.updated_at.desc())
|
||||
elif order_by == "role":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.role.asc())
|
||||
else:
|
||||
query = query.order_by(User.role.desc())
|
||||
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
@@ -172,8 +270,10 @@ class UsersTable:
|
||||
query = query.limit(limit)
|
||||
|
||||
users = query.all()
|
||||
|
||||
return [UserModel.model_validate(user) for user in users]
|
||||
return {
|
||||
"users": [UserModel.model_validate(user) for user in users],
|
||||
"total": db.query(User).count(),
|
||||
}
|
||||
|
||||
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
|
||||
with get_db() as db:
|
||||
@@ -184,6 +284,10 @@ class UsersTable:
|
||||
with get_db() as db:
|
||||
return db.query(User).count()
|
||||
|
||||
def has_users(self) -> bool:
|
||||
with get_db() as db:
|
||||
return db.query(db.query(User).exists()).scalar()
|
||||
|
||||
def get_first_user(self) -> UserModel:
|
||||
try:
|
||||
with get_db() as db:
|
||||
@@ -233,6 +337,7 @@ class UsersTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
@@ -268,7 +373,8 @@ class UsersTable:
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
return UserModel.model_validate(user)
|
||||
# return UserModel(**user.dict())
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
|
||||
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
|
||||
@@ -308,7 +414,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})
|
||||
@@ -330,5 +436,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,278 @@
|
||||
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,
|
||||
api_base_url: str,
|
||||
additional_config: 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,
|
||||
format_lines: bool = False,
|
||||
output_format: str = None,
|
||||
):
|
||||
self.file_path = file_path
|
||||
self.api_key = api_key
|
||||
self.api_base_url = api_base_url
|
||||
self.additional_config = additional_config
|
||||
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.format_lines = format_lines
|
||||
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"{self.api_base_url}/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]:
|
||||
filename = os.path.basename(self.file_path)
|
||||
mime_type = self._get_mime_type(filename)
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
|
||||
form_data = {
|
||||
"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(),
|
||||
"format_lines": str(self.format_lines).lower(),
|
||||
"output_format": self.output_format,
|
||||
}
|
||||
|
||||
if self.additional_config and self.additional_config.strip():
|
||||
form_data["additional_config"] = self.additional_config
|
||||
|
||||
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(
|
||||
f"{self.api_base_url}/marker",
|
||||
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")
|
||||
|
||||
# Check if this is a direct response (self-hosted) or polling response (DataLab)
|
||||
if check_url:
|
||||
# DataLab polling pattern
|
||||
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}",
|
||||
)
|
||||
|
||||
# DataLab format - content in format-specific fields
|
||||
content_key = self.output_format.lower()
|
||||
raw_content = poll_result.get(content_key)
|
||||
final_result = poll_result
|
||||
else:
|
||||
# Self-hosted direct response - content in "output" field
|
||||
if "output" in result:
|
||||
log.info("Self-hosted Marker returned direct response without polling")
|
||||
raw_content = result.get("output")
|
||||
final_result = result
|
||||
else:
|
||||
available_fields = (
|
||||
list(result.keys())
|
||||
if isinstance(result, dict)
|
||||
else "non-dict response"
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Custom Marker endpoint returned success but no 'output' field found. Available fields: {available_fields}. Expected either 'request_check_url' for polling or 'output' field for direct response.",
|
||||
)
|
||||
|
||||
if self.output_format.lower() == "json":
|
||||
full_text = json.dumps(raw_content, indent=2)
|
||||
elif self.output_format.lower() 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="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(self.output_format.lower(), "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": final_result.get("output_format", self.output_format),
|
||||
"page_count": final_result.get("page_count", 0),
|
||||
"processed_with_llm": self.use_llm,
|
||||
"request_id": request_id or "",
|
||||
}
|
||||
|
||||
images = final_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}"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import requests
|
||||
import logging
|
||||
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 ExternalWebLoader(BaseLoader):
|
||||
def __init__(
|
||||
self,
|
||||
web_paths: Union[str, List[str]],
|
||||
external_url: str,
|
||||
external_api_key: str,
|
||||
continue_on_failure: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.external_url = external_url
|
||||
self.external_api_key = external_api_key
|
||||
self.urls = web_paths if isinstance(web_paths, list) else [web_paths]
|
||||
self.continue_on_failure = continue_on_failure
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
batch_size = 20
|
||||
for i in range(0, len(self.urls), batch_size):
|
||||
urls = self.urls[i : i + batch_size]
|
||||
try:
|
||||
response = requests.post(
|
||||
self.external_url,
|
||||
headers={
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) External Web Loader",
|
||||
"Authorization": f"Bearer {self.external_api_key}",
|
||||
},
|
||||
json={
|
||||
"urls": urls,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
results = response.json()
|
||||
for result in results:
|
||||
yield Document(
|
||||
page_content=result.get("page_content", ""),
|
||||
metadata=result.get("metadata", {}),
|
||||
)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.error(f"Error extracting content from batch {urls}: {e}")
|
||||
else:
|
||||
raise e
|
||||
@@ -2,6 +2,7 @@ import requests
|
||||
import logging
|
||||
import ftfy
|
||||
import sys
|
||||
import json
|
||||
|
||||
from langchain_community.document_loaders import (
|
||||
AzureAIDocumentIntelligenceLoader,
|
||||
@@ -13,13 +14,20 @@ from langchain_community.document_loaders import (
|
||||
TextLoader,
|
||||
UnstructuredEPubLoader,
|
||||
UnstructuredExcelLoader,
|
||||
UnstructuredMarkdownLoader,
|
||||
UnstructuredODTLoader,
|
||||
UnstructuredPowerPointLoader,
|
||||
UnstructuredRSTLoader,
|
||||
UnstructuredXMLLoader,
|
||||
YoutubeLoader,
|
||||
)
|
||||
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
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
@@ -69,7 +77,6 @@ known_source_ext = [
|
||||
"swift",
|
||||
"vue",
|
||||
"svelte",
|
||||
"msg",
|
||||
"ex",
|
||||
"exs",
|
||||
"erl",
|
||||
@@ -82,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()
|
||||
@@ -96,6 +105,9 @@ class TikaLoader:
|
||||
else:
|
||||
headers = {}
|
||||
|
||||
if self.extract_images == True:
|
||||
headers["X-Tika-PDFextractInlineImages"] = "true"
|
||||
|
||||
endpoint = self.url
|
||||
if not endpoint.endswith("/"):
|
||||
endpoint += "/"
|
||||
@@ -118,11 +130,13 @@ class TikaLoader:
|
||||
|
||||
|
||||
class DoclingLoader:
|
||||
def __init__(self, url, file_path=None, mime_type=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.params = params or {}
|
||||
|
||||
def load(self) -> list[Document]:
|
||||
with open(self.file_path, "rb") as f:
|
||||
files = {
|
||||
@@ -133,12 +147,41 @@ class DoclingLoader:
|
||||
)
|
||||
}
|
||||
|
||||
params = {
|
||||
"image_export_mode": "placeholder",
|
||||
"table_mode": "accurate",
|
||||
}
|
||||
params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
|
||||
|
||||
endpoint = f"{self.url}/v1alpha/convert/file"
|
||||
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}/v1/convert/file"
|
||||
r = requests.post(endpoint, files=files, data=params)
|
||||
|
||||
if r.ok:
|
||||
@@ -181,26 +224,106 @@ class Loader:
|
||||
for doc in docs
|
||||
]
|
||||
|
||||
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
||||
return file_ext in known_source_ext or (
|
||||
file_content_type
|
||||
and file_content_type.find("text/") >= 0
|
||||
# Avoid text/html files being detected as text
|
||||
and not file_content_type.find("html") >= 0
|
||||
)
|
||||
|
||||
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
||||
file_ext = filename.split(".")[-1].lower()
|
||||
|
||||
if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
|
||||
if file_ext in known_source_ext or (
|
||||
file_content_type and file_content_type.find("text/") >= 0
|
||||
):
|
||||
if (
|
||||
self.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:
|
||||
loader = TikaLoader(
|
||||
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 == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
|
||||
loader = DoclingLoader(
|
||||
url=self.kwargs.get("DOCLING_SERVER_URL"),
|
||||
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",
|
||||
]
|
||||
):
|
||||
api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "")
|
||||
if not api_base_url or api_base_url.strip() == "":
|
||||
api_base_url = "https://www.datalab.to/api/v1"
|
||||
|
||||
loader = DatalabMarkerLoader(
|
||||
file_path=file_path,
|
||||
mime_type=file_content_type,
|
||||
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
|
||||
api_base_url=api_base_url,
|
||||
additional_config=self.kwargs.get("DATALAB_MARKER_ADDITIONAL_CONFIG"),
|
||||
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
|
||||
),
|
||||
format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", 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,
|
||||
params=params,
|
||||
)
|
||||
elif (
|
||||
self.engine == "document_intelligence"
|
||||
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
|
||||
@@ -222,6 +345,24 @@ class Loader:
|
||||
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
|
||||
)
|
||||
elif (
|
||||
self.engine == "mistral_ocr"
|
||||
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
|
||||
and file_ext
|
||||
in ["pdf"] # Mistral OCR currently only supports PDF and images
|
||||
):
|
||||
loader = MistralLoader(
|
||||
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
|
||||
)
|
||||
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(
|
||||
@@ -257,9 +398,9 @@ class Loader:
|
||||
loader = UnstructuredPowerPointLoader(file_path)
|
||||
elif file_ext == "msg":
|
||||
loader = OutlookMessageLoader(file_path)
|
||||
elif file_ext in known_source_ext or (
|
||||
file_content_type and file_content_type.find("text/") >= 0
|
||||
):
|
||||
elif file_ext == "odt":
|
||||
loader = UnstructuredODTLoader(file_path)
|
||||
elif self._is_text_file(file_ext, file_content_type):
|
||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||
else:
|
||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
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
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
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,
|
||||
timeout: int = 300, # 5 minutes default
|
||||
max_retries: int = 3,
|
||||
enable_debug_logging: bool = False,
|
||||
):
|
||||
"""
|
||||
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.")
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found at {file_path}")
|
||||
|
||||
self.api_key = api_key
|
||||
self.file_path = file_path
|
||||
self.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."""
|
||||
try:
|
||||
response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
|
||||
# Handle potential empty responses for certain successful requests (e.g., DELETE)
|
||||
if response.status_code == 204 or not response.content:
|
||||
return {} # Return empty dict if no content
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
log.error(f"HTTP error occurred: {http_err} - Response: {response.text}")
|
||||
raise
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
log.error(f"Request exception occurred: {req_err}")
|
||||
raise
|
||||
except ValueError as json_err: # Includes JSONDecodeError
|
||||
log.error(f"JSON decode error: {json_err} - Response: {response.text}")
|
||||
raise # Re-raise after logging
|
||||
|
||||
async def _handle_response_async(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of response handling with better error info."""
|
||||
try:
|
||||
response.raise_for_status()
|
||||
|
||||
# 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]}..."
|
||||
)
|
||||
|
||||
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.")
|
||||
log.info(f"File uploaded successfully. File ID: {file_id}")
|
||||
return file_id
|
||||
except Exception as e:
|
||||
log.error(f"Failed to upload file: {e}")
|
||||
raise
|
||||
|
||||
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 (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_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.")
|
||||
log.info("Signed URL received.")
|
||||
return signed_url
|
||||
except Exception as e:
|
||||
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 (sync version)."""
|
||||
log.info("Processing OCR via Mistral API")
|
||||
url = f"{self.BASE_API_URL}/ocr"
|
||||
ocr_headers = {
|
||||
**self.headers,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": "mistral-ocr-latest",
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": signed_url,
|
||||
},
|
||||
"include_image_base64": False,
|
||||
}
|
||||
|
||||
def ocr_request():
|
||||
response = requests.post(
|
||||
url, headers=ocr_headers, json=payload, timeout=self.ocr_timeout
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
try:
|
||||
ocr_response = self._retry_request_sync(ocr_request)
|
||||
log.info("OCR processing done.")
|
||||
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 (sync version)."""
|
||||
log.info(f"Deleting uploaded file ID: {file_id}")
|
||||
url = f"{self.BASE_API_URL}/files/{file_id}"
|
||||
|
||||
try:
|
||||
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}")
|
||||
|
||||
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
|
||||
trust_env=True,
|
||||
) 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()
|
||||
|
||||
# 2. Get Signed URL
|
||||
signed_url = self._get_signed_url(file_id)
|
||||
|
||||
# 3. Process OCR
|
||||
ocr_response = self._process_ocr(signed_url)
|
||||
|
||||
# 4. Process results
|
||||
documents = self._process_results(ocr_response)
|
||||
|
||||
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:
|
||||
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:
|
||||
try:
|
||||
self._delete_file(file_id)
|
||||
except Exception as del_e:
|
||||
# Log deletion error, but don't overwrite original error if one occurred
|
||||
log.error(
|
||||
f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}"
|
||||
)
|
||||
|
||||
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,74 @@
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional, List, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, 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]], user=None
|
||||
) -> 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}",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
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,19 +5,24 @@ from typing import Optional, Union
|
||||
import requests
|
||||
import hashlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import time
|
||||
|
||||
from urllib.parse import quote
|
||||
from huggingface_hub import snapshot_download
|
||||
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
||||
from langchain_community.retrievers import BM25Retriever
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from open_webui.config import VECTOR_DB
|
||||
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.notes import Notes
|
||||
|
||||
from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
from open_webui.env import (
|
||||
@@ -77,6 +82,7 @@ def query_doc(
|
||||
collection_name: str, query_embedding: list[float], k: int, user: UserModel = None
|
||||
):
|
||||
try:
|
||||
log.debug(f"query_doc:doc {collection_name}")
|
||||
result = VECTOR_DB_CLIENT.search(
|
||||
collection_name=collection_name,
|
||||
vectors=[query_embedding],
|
||||
@@ -94,6 +100,7 @@ def query_doc(
|
||||
|
||||
def get_doc(collection_name: str, user: UserModel = None):
|
||||
try:
|
||||
log.debug(f"get_doc:doc {collection_name}")
|
||||
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
|
||||
|
||||
if result:
|
||||
@@ -114,13 +121,17 @@ def query_doc_with_hybrid_search(
|
||||
reranking_function,
|
||||
k_reranker: int,
|
||||
r: float,
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
try:
|
||||
bm25_retriever = BM25Retriever.from_texts(
|
||||
texts=collection_result.documents[0],
|
||||
metadatas=collection_result.metadatas[0],
|
||||
)
|
||||
bm25_retriever.k = k
|
||||
# BM_25 required only if weight is greater than 0
|
||||
if hybrid_bm25_weight > 0:
|
||||
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
|
||||
bm25_retriever = BM25Retriever.from_texts(
|
||||
texts=collection_result.documents[0],
|
||||
metadatas=collection_result.metadatas[0],
|
||||
)
|
||||
bm25_retriever.k = k
|
||||
|
||||
vector_search_retriever = VectorSearchRetriever(
|
||||
collection_name=collection_name,
|
||||
@@ -128,9 +139,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,
|
||||
@@ -168,6 +190,7 @@ def query_doc_with_hybrid_search(
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.exception(f"Error querying doc {collection_name} with hybrid search: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@@ -203,7 +226,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
|
||||
|
||||
for distance, document, metadata in zip(distances, documents, metadatas):
|
||||
if isinstance(document, str):
|
||||
doc_hash = hashlib.md5(
|
||||
doc_hash = hashlib.sha256(
|
||||
document.encode()
|
||||
).hexdigest() # Compute a hash for uniqueness
|
||||
|
||||
@@ -256,22 +279,47 @@ def query_collection(
|
||||
k: int,
|
||||
) -> dict:
|
||||
results = []
|
||||
for query in queries:
|
||||
query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
for collection_name in collection_names:
|
||||
error = False
|
||||
|
||||
def process_query_collection(collection_name, query_embedding):
|
||||
try:
|
||||
if collection_name:
|
||||
try:
|
||||
result = query_doc(
|
||||
collection_name=collection_name,
|
||||
k=k,
|
||||
query_embedding=query_embedding,
|
||||
)
|
||||
if result is not None:
|
||||
results.append(result.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(f"Error when querying the collection: {e}")
|
||||
else:
|
||||
pass
|
||||
result = query_doc(
|
||||
collection_name=collection_name,
|
||||
k=k,
|
||||
query_embedding=query_embedding,
|
||||
)
|
||||
if result is not None:
|
||||
return result.model_dump(), None
|
||||
return None, None
|
||||
except Exception as e:
|
||||
log.exception(f"Error when querying the collection: {e}")
|
||||
return None, e
|
||||
|
||||
# Generate all query embeddings (in one call)
|
||||
query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
log.debug(
|
||||
f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections"
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
future_results = []
|
||||
for query_embedding in query_embeddings:
|
||||
for collection_name in collection_names:
|
||||
result = executor.submit(
|
||||
process_query_collection, collection_name, query_embedding
|
||||
)
|
||||
future_results.append(result)
|
||||
task_results = [future.result() for future in future_results]
|
||||
|
||||
for result, err in task_results:
|
||||
if err is not None:
|
||||
error = True
|
||||
elif result is not None:
|
||||
results.append(result)
|
||||
|
||||
if error and not results:
|
||||
log.warning("All collection queries failed. No results returned.")
|
||||
|
||||
return merge_and_sort_query_results(results, k=k)
|
||||
|
||||
@@ -284,21 +332,29 @@ def query_collection_with_hybrid_search(
|
||||
reranking_function,
|
||||
k_reranker: int,
|
||||
r: float,
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
results = []
|
||||
error = False
|
||||
# Fetch collection data once per collection sequentially
|
||||
# Avoid fetching the same data multiple times later
|
||||
collection_results = {}
|
||||
for collection_name in collection_names:
|
||||
try:
|
||||
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
|
||||
collection_name=collection_name
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch collection {collection_name}: {e}")
|
||||
collection_results[collection_name] = None
|
||||
|
||||
# Only retrieve entire collection if bm_25 calculation is required
|
||||
if hybrid_bm25_weight > 0:
|
||||
for collection_name in collection_names:
|
||||
try:
|
||||
log.debug(
|
||||
f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}"
|
||||
)
|
||||
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
|
||||
collection_name=collection_name
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch collection {collection_name}: {e}")
|
||||
collection_results[collection_name] = None
|
||||
else:
|
||||
for collection_name in collection_names:
|
||||
collection_results[collection_name] = []
|
||||
log.info(
|
||||
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
|
||||
)
|
||||
@@ -314,16 +370,20 @@ 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:
|
||||
log.exception(f"Error when querying the collection with hybrid_search: {e}")
|
||||
return None, e
|
||||
|
||||
# Prepare tasks for all collections and queries
|
||||
# Avoid running any tasks for collections that failed to fetch data (have assigned None)
|
||||
tasks = [
|
||||
(collection_name, query)
|
||||
for collection_name in collection_names
|
||||
for query in queries
|
||||
(cn, q)
|
||||
for cn in collection_names
|
||||
if collection_results[cn] is not None
|
||||
for q in queries
|
||||
]
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
@@ -351,12 +411,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 None
|
||||
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,
|
||||
@@ -365,6 +426,7 @@ def get_embedding_function(
|
||||
url=url,
|
||||
key=key,
|
||||
user=user,
|
||||
azure_api_version=azure_api_version,
|
||||
)
|
||||
|
||||
def generate_multiple(query, prefix, user, func):
|
||||
@@ -389,169 +451,240 @@ def get_embedding_function(
|
||||
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
|
||||
|
||||
|
||||
def get_sources_from_files(
|
||||
def get_reranking_function(reranking_engine, reranking_model, reranking_function):
|
||||
if reranking_function is None:
|
||||
return None
|
||||
if reranking_engine == "external":
|
||||
return lambda sentences, user=None: reranking_function.predict(
|
||||
sentences, user=user
|
||||
)
|
||||
else:
|
||||
return lambda sentences, user=None: reranking_function.predict(sentences)
|
||||
|
||||
|
||||
def get_sources_from_items(
|
||||
request,
|
||||
files,
|
||||
items,
|
||||
queries,
|
||||
embedding_function,
|
||||
k,
|
||||
reranking_function,
|
||||
k_reranker,
|
||||
r,
|
||||
hybrid_bm25_weight,
|
||||
hybrid_search,
|
||||
full_context=False,
|
||||
user: Optional[UserModel] = None,
|
||||
):
|
||||
log.debug(
|
||||
f"files: {files} {queries} {embedding_function} {reranking_function} {full_context}"
|
||||
f"items: {items} {queries} {embedding_function} {reranking_function} {full_context}"
|
||||
)
|
||||
|
||||
extracted_collections = []
|
||||
relevant_contexts = []
|
||||
query_results = []
|
||||
|
||||
for file in files:
|
||||
for item in items:
|
||||
query_result = None
|
||||
collection_names = []
|
||||
|
||||
context = None
|
||||
if file.get("docs"):
|
||||
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||
context = {
|
||||
"documents": [[doc.get("content") for doc in file.get("docs")]],
|
||||
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
|
||||
}
|
||||
elif file.get("context") == "full":
|
||||
# Manual Full Mode Toggle
|
||||
context = {
|
||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
||||
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
|
||||
}
|
||||
elif (
|
||||
file.get("type") != "web_search"
|
||||
and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||
):
|
||||
# BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||
if file.get("type") == "collection":
|
||||
file_ids = file.get("data", {}).get("file_ids", [])
|
||||
if item.get("type") == "text":
|
||||
# Raw Text
|
||||
# Used during temporary chat file uploads
|
||||
|
||||
documents = []
|
||||
metadatas = []
|
||||
for file_id in file_ids:
|
||||
file_object = Files.get_file_by_id(file_id)
|
||||
|
||||
if file_object:
|
||||
documents.append(file_object.data.get("content", ""))
|
||||
metadatas.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"name": file_object.filename,
|
||||
"source": file_object.filename,
|
||||
}
|
||||
)
|
||||
|
||||
context = {
|
||||
"documents": [documents],
|
||||
"metadatas": [metadatas],
|
||||
if item.get("file"):
|
||||
# if item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [
|
||||
[item.get("file", {}).get("data", {}).get("meta", {})]
|
||||
],
|
||||
}
|
||||
else:
|
||||
# Fallback to item content
|
||||
query_result = {
|
||||
"documents": [[item.get("content")]],
|
||||
"metadatas": [
|
||||
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||
],
|
||||
}
|
||||
|
||||
elif file.get("id"):
|
||||
file_object = Files.get_file_by_id(file.get("id"))
|
||||
if file_object:
|
||||
context = {
|
||||
"documents": [[file_object.data.get("content", "")]],
|
||||
elif item.get("type") == "note":
|
||||
# Note Attached
|
||||
note = Notes.get_note_by_id(item.get("id"))
|
||||
|
||||
if note and (
|
||||
user.role == "admin"
|
||||
or note.user_id == user.id
|
||||
or has_access(user.id, "read", note.access_control)
|
||||
):
|
||||
# User has access to the note
|
||||
query_result = {
|
||||
"documents": [[note.data.get("content", {}).get("md", "")]],
|
||||
"metadatas": [[{"file_id": note.id, "name": note.title}]],
|
||||
}
|
||||
|
||||
elif item.get("type") == "file":
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||
):
|
||||
if item.get("file", {}).get("data", {}).get("content", ""):
|
||||
# Manual Full Mode Toggle
|
||||
# Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content", "")]
|
||||
],
|
||||
"metadatas": [
|
||||
[
|
||||
{
|
||||
"file_id": file.get("id"),
|
||||
"name": file_object.filename,
|
||||
"source": file_object.filename,
|
||||
"file_id": item.get("id"),
|
||||
"name": item.get("name"),
|
||||
**item.get("file")
|
||||
.get("data", {})
|
||||
.get("metadata", {}),
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
elif file.get("file").get("data"):
|
||||
context = {
|
||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
||||
"metadatas": [
|
||||
[file.get("file").get("data", {}).get("metadata", {})]
|
||||
],
|
||||
}
|
||||
else:
|
||||
collection_names = []
|
||||
if file.get("type") == "collection":
|
||||
if file.get("legacy"):
|
||||
collection_names = file.get("collection_names", [])
|
||||
elif item.get("id"):
|
||||
file_object = Files.get_file_by_id(item.get("id"))
|
||||
if file_object:
|
||||
query_result = {
|
||||
"documents": [[file_object.data.get("content", "")]],
|
||||
"metadatas": [
|
||||
[
|
||||
{
|
||||
"file_id": item.get("id"),
|
||||
"name": file_object.filename,
|
||||
"source": file_object.filename,
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
else:
|
||||
# Fallback to collection names
|
||||
if item.get("legacy"):
|
||||
collection_names.append(f"{item['id']}")
|
||||
else:
|
||||
collection_names.append(file["id"])
|
||||
elif file.get("collection_name"):
|
||||
collection_names.append(file["collection_name"])
|
||||
elif file.get("id"):
|
||||
if file.get("legacy"):
|
||||
collection_names.append(f"{file['id']}")
|
||||
else:
|
||||
collection_names.append(f"file-{file['id']}")
|
||||
collection_names.append(f"file-{item['id']}")
|
||||
|
||||
elif item.get("type") == "collection":
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||
):
|
||||
# Manual Full Mode Toggle for Collection
|
||||
knowledge_base = Knowledges.get_knowledge_by_id(item.get("id"))
|
||||
|
||||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
):
|
||||
|
||||
file_ids = knowledge_base.data.get("file_ids", [])
|
||||
|
||||
documents = []
|
||||
metadatas = []
|
||||
for file_id in file_ids:
|
||||
file_object = Files.get_file_by_id(file_id)
|
||||
|
||||
if file_object:
|
||||
documents.append(file_object.data.get("content", ""))
|
||||
metadatas.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"name": file_object.filename,
|
||||
"source": file_object.filename,
|
||||
}
|
||||
)
|
||||
|
||||
query_result = {
|
||||
"documents": [documents],
|
||||
"metadatas": [metadatas],
|
||||
}
|
||||
else:
|
||||
# Fallback to collection names
|
||||
if item.get("legacy"):
|
||||
collection_names = item.get("collection_names", [])
|
||||
else:
|
||||
collection_names.append(item["id"])
|
||||
|
||||
elif item.get("docs"):
|
||||
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||
query_result = {
|
||||
"documents": [[doc.get("content") for doc in item.get("docs")]],
|
||||
"metadatas": [[doc.get("metadata") for doc in item.get("docs")]],
|
||||
}
|
||||
elif item.get("collection_name"):
|
||||
# Direct Collection Name
|
||||
collection_names.append(item["collection_name"])
|
||||
elif item.get("collection_names"):
|
||||
# Collection Names List
|
||||
collection_names.extend(item["collection_names"])
|
||||
|
||||
# If query_result is None
|
||||
# Fallback to collection names and vector search the collections
|
||||
if query_result is None and collection_names:
|
||||
collection_names = set(collection_names).difference(extracted_collections)
|
||||
if not collection_names:
|
||||
log.debug(f"skipping {file} as it has already been extracted")
|
||||
log.debug(f"skipping {item} as it has already been extracted")
|
||||
continue
|
||||
|
||||
if full_context:
|
||||
try:
|
||||
context = get_all_items_from_collections(collection_names)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
else:
|
||||
try:
|
||||
context = None
|
||||
if file.get("type") == "text":
|
||||
context = file["content"]
|
||||
else:
|
||||
if hybrid_search:
|
||||
try:
|
||||
context = query_collection_with_hybrid_search(
|
||||
collection_names=collection_names,
|
||||
queries=queries,
|
||||
embedding_function=embedding_function,
|
||||
k=k,
|
||||
reranking_function=reranking_function,
|
||||
k_reranker=k_reranker,
|
||||
r=r,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(
|
||||
"Error when using hybrid search, using"
|
||||
" non hybrid search as fallback."
|
||||
)
|
||||
|
||||
if (not hybrid_search) or (context is None):
|
||||
context = query_collection(
|
||||
try:
|
||||
if full_context:
|
||||
query_result = get_all_items_from_collections(collection_names)
|
||||
else:
|
||||
query_result = None # Initialize to None
|
||||
if hybrid_search:
|
||||
try:
|
||||
query_result = query_collection_with_hybrid_search(
|
||||
collection_names=collection_names,
|
||||
queries=queries,
|
||||
embedding_function=embedding_function,
|
||||
k=k,
|
||||
reranking_function=reranking_function,
|
||||
k_reranker=k_reranker,
|
||||
r=r,
|
||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
except Exception as e:
|
||||
log.debug(
|
||||
"Error when using hybrid search, using non hybrid search as fallback."
|
||||
)
|
||||
|
||||
# fallback to non-hybrid search
|
||||
if not hybrid_search and query_result is None:
|
||||
query_result = query_collection(
|
||||
collection_names=collection_names,
|
||||
queries=queries,
|
||||
embedding_function=embedding_function,
|
||||
k=k,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
extracted_collections.extend(collection_names)
|
||||
|
||||
if context:
|
||||
if "data" in file:
|
||||
del file["data"]
|
||||
|
||||
relevant_contexts.append({**context, "file": file})
|
||||
if query_result:
|
||||
if "data" in item:
|
||||
del item["data"]
|
||||
query_results.append({**query_result, "file": item})
|
||||
|
||||
sources = []
|
||||
for context in relevant_contexts:
|
||||
for query_result in query_results:
|
||||
try:
|
||||
if "documents" in context:
|
||||
if "metadatas" in context:
|
||||
if "documents" in query_result:
|
||||
if "metadatas" in query_result:
|
||||
source = {
|
||||
"source": context["file"],
|
||||
"document": context["documents"][0],
|
||||
"metadata": context["metadatas"][0],
|
||||
"source": query_result["file"],
|
||||
"document": query_result["documents"][0],
|
||||
"metadata": query_result["metadatas"][0],
|
||||
}
|
||||
if "distances" in context and context["distances"]:
|
||||
source["distances"] = context["distances"][0]
|
||||
if "distances" in query_result and query_result["distances"]:
|
||||
source["distances"] = query_result["distances"][0]
|
||||
|
||||
sources.append(source)
|
||||
except Exception as e:
|
||||
@@ -610,6 +743,9 @@ def generate_openai_batch_embeddings(
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(
|
||||
f"generate_openai_batch_embeddings:model {model} batch size: {len(texts)}"
|
||||
)
|
||||
json_data = {"input": texts, "model": model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
@@ -621,7 +757,7 @@ def generate_openai_batch_embeddings(
|
||||
"Authorization": f"Bearer {key}",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -643,6 +779,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": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
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],
|
||||
@@ -652,6 +842,9 @@ def generate_ollama_batch_embeddings(
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(
|
||||
f"generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}"
|
||||
)
|
||||
json_data = {"input": texts, "model": model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
@@ -663,7 +856,7 @@ def generate_ollama_batch_embeddings(
|
||||
"Authorization": f"Bearer {key}",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -704,38 +897,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
|
||||
|
||||
|
||||
@@ -764,8 +952,9 @@ class RerankCompressor(BaseDocumentCompressor):
|
||||
) -> Sequence[Document]:
|
||||
reranking = self.reranking_function is not None
|
||||
|
||||
scores = None
|
||||
if reranking:
|
||||
scores = self.reranking_function.predict(
|
||||
scores = self.reranking_function(
|
||||
[(query, doc.page_content) for doc in documents]
|
||||
)
|
||||
else:
|
||||
@@ -777,20 +966,31 @@ class RerankCompressor(BaseDocumentCompressor):
|
||||
)
|
||||
scores = util.cos_sim(query_embedding, document_embedding)[0]
|
||||
|
||||
docs_with_scores = list(zip(documents, scores.tolist()))
|
||||
if self.r_score:
|
||||
docs_with_scores = [
|
||||
(d, s) for d, s in docs_with_scores if s >= self.r_score
|
||||
]
|
||||
|
||||
result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
|
||||
final_results = []
|
||||
for doc, doc_score in result[: self.top_n]:
|
||||
metadata = doc.metadata
|
||||
metadata["score"] = doc_score
|
||||
doc = Document(
|
||||
page_content=doc.page_content,
|
||||
metadata=metadata,
|
||||
if scores:
|
||||
docs_with_scores = list(
|
||||
zip(
|
||||
documents,
|
||||
scores.tolist() if not isinstance(scores, list) else scores,
|
||||
)
|
||||
)
|
||||
final_results.append(doc)
|
||||
return final_results
|
||||
if self.r_score:
|
||||
docs_with_scores = [
|
||||
(d, s) for d, s in docs_with_scores if s >= self.r_score
|
||||
]
|
||||
|
||||
result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
|
||||
final_results = []
|
||||
for doc, doc_score in result[: self.top_n]:
|
||||
metadata = doc.metadata
|
||||
metadata["score"] = doc_score
|
||||
doc = Document(
|
||||
page_content=doc.page_content,
|
||||
metadata=metadata,
|
||||
)
|
||||
final_results.append(doc)
|
||||
return final_results
|
||||
else:
|
||||
log.warning(
|
||||
"No valid scores found, check your reranking function. Returning original documents."
|
||||
)
|
||||
return documents
|
||||
|
||||
@@ -1,26 +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()
|
||||
else:
|
||||
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
|
||||
|
||||
VECTOR_DB_CLIENT = ChromaClient()
|
||||
@@ -5,7 +5,14 @@ from chromadb.utils.batch_utils import create_batches
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
|
||||
from open_webui.config import (
|
||||
CHROMA_DATA_PATH,
|
||||
CHROMA_HTTP_HOST,
|
||||
@@ -23,7 +30,7 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class ChromaClient:
|
||||
class ChromaClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
settings_dict = {
|
||||
"allow_reset": True,
|
||||
@@ -139,7 +146,7 @@ class ChromaClient:
|
||||
ids = [item["id"] for item in items]
|
||||
documents = [item["text"] for item in items]
|
||||
embeddings = [item["vector"] for item in items]
|
||||
metadatas = [item["metadata"] for item in items]
|
||||
metadatas = [stringify_metadata(item["metadata"]) for item in items]
|
||||
|
||||
for batch in create_batches(
|
||||
api=self.client,
|
||||
@@ -159,7 +166,7 @@ class ChromaClient:
|
||||
ids = [item["id"] for item in items]
|
||||
documents = [item["text"] for item in items]
|
||||
embeddings = [item["vector"] for item in items]
|
||||
metadatas = [item["metadata"] for item in items]
|
||||
metadatas = [stringify_metadata(item["metadata"]) for item in items]
|
||||
|
||||
collection.upsert(
|
||||
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
|
||||
|
||||
@@ -2,7 +2,14 @@ from elasticsearch import Elasticsearch, BadRequestError
|
||||
from typing import Optional
|
||||
import ssl
|
||||
from elasticsearch.helpers import bulk, scan
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.config import (
|
||||
ELASTICSEARCH_URL,
|
||||
ELASTICSEARCH_CA_CERTS,
|
||||
@@ -15,7 +22,7 @@ from open_webui.config import (
|
||||
)
|
||||
|
||||
|
||||
class ElasticsearchClient:
|
||||
class ElasticsearchClient(VectorDBBase):
|
||||
"""
|
||||
Important:
|
||||
in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating
|
||||
@@ -238,7 +245,7 @@ class ElasticsearchClient:
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
@@ -259,7 +266,7 @@ class ElasticsearchClient:
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus import FieldSchema, DataType
|
||||
from pymilvus import connections, Collection
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -16,7 +29,7 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class MilvusClient:
|
||||
class MilvusClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = "open_webui"
|
||||
if MILVUS_TOKEN is None:
|
||||
@@ -28,7 +41,6 @@ class MilvusClient:
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for match in result:
|
||||
_ids = []
|
||||
_documents = []
|
||||
@@ -37,11 +49,9 @@ class MilvusClient:
|
||||
_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,
|
||||
@@ -55,13 +65,11 @@ class MilvusClient:
|
||||
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
|
||||
@@ -70,12 +78,10 @@ class MilvusClient:
|
||||
_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,
|
||||
@@ -108,11 +114,39 @@ class MilvusClient:
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -120,6 +154,9 @@ class MilvusClient:
|
||||
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.
|
||||
@@ -140,21 +177,28 @@ class MilvusClient:
|
||||
) -> 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):
|
||||
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
# Construct the filter string for querying
|
||||
collection_name = collection_name.replace("-", "_")
|
||||
if not self.has_collection(collection_name):
|
||||
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)}'
|
||||
@@ -162,62 +206,50 @@ class MilvusClient:
|
||||
]
|
||||
)
|
||||
|
||||
max_limit = 16383 # The maximum number of records per request
|
||||
collection = Collection(f"{self.collection_prefix}_{collection_name}")
|
||||
collection.load()
|
||||
all_results = []
|
||||
|
||||
if limit is None:
|
||||
limit = float("inf") # Use infinity as a placeholder for no limit
|
||||
|
||||
# Initialize offset and remaining to handle pagination
|
||||
offset = 0
|
||||
remaining = limit
|
||||
|
||||
try:
|
||||
# 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
|
||||
log.info(
|
||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||
)
|
||||
|
||||
results = self.client.query(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
filter=filter_string,
|
||||
output_fields=["*"],
|
||||
limit=current_fetch,
|
||||
offset=offset,
|
||||
)
|
||||
iterator = collection.query_iterator(
|
||||
filter=filter_string,
|
||||
output_fields=[
|
||||
"id",
|
||||
"data",
|
||||
"metadata",
|
||||
],
|
||||
limit=limit, # Pass the limit directly; None means no limit.
|
||||
)
|
||||
|
||||
if not results:
|
||||
while True:
|
||||
result = iterator.next()
|
||||
if not result:
|
||||
iterator.close()
|
||||
break
|
||||
all_results += result
|
||||
|
||||
all_results.extend(results)
|
||||
results_count = len(results)
|
||||
remaining -= (
|
||||
results_count # Decrease remaining by the number of items fetched
|
||||
)
|
||||
offset += results_count
|
||||
|
||||
# Break the loop if the results returned are less than the requested fetch count
|
||||
if results_count < current_fetch:
|
||||
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.
|
||||
@@ -225,10 +257,23 @@ class MilvusClient:
|
||||
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=[
|
||||
@@ -236,7 +281,7 @@ class MilvusClient:
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"data": {"text": item["text"]},
|
||||
"metadata": item["metadata"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
@@ -248,10 +293,23 @@ class MilvusClient:
|
||||
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=[
|
||||
@@ -259,7 +317,7 @@ class MilvusClient:
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"data": {"text": item["text"]},
|
||||
"metadata": item["metadata"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
@@ -271,30 +329,55 @@ class MilvusClient:
|
||||
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}")
|
||||
|
||||
@@ -2,7 +2,13 @@ from opensearchpy import OpenSearch
|
||||
from opensearchpy.helpers import bulk
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.config import (
|
||||
OPENSEARCH_URI,
|
||||
OPENSEARCH_SSL,
|
||||
@@ -12,7 +18,7 @@ from open_webui.config import (
|
||||
)
|
||||
|
||||
|
||||
class OpenSearchClient:
|
||||
class OpenSearchClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.index_prefix = "open_webui"
|
||||
self.client = OpenSearch(
|
||||
@@ -152,10 +158,10 @@ class OpenSearchClient:
|
||||
|
||||
for field, value in filter.items():
|
||||
query_body["query"]["bool"]["filter"].append(
|
||||
{"match": {"metadata." + str(field): value}}
|
||||
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||
)
|
||||
|
||||
size = limit if limit else 10
|
||||
size = limit if limit else 10000
|
||||
|
||||
try:
|
||||
result = self.client.search(
|
||||
@@ -195,12 +201,13 @@ class OpenSearchClient:
|
||||
"_source": {
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
self._create_index_if_not_exists(
|
||||
@@ -216,13 +223,14 @@ class OpenSearchClient:
|
||||
"doc": {
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
|
||||
def delete(
|
||||
self,
|
||||
@@ -246,11 +254,12 @@ class OpenSearchClient:
|
||||
}
|
||||
for field, value in filter.items():
|
||||
query_body["query"]["bool"]["filter"].append(
|
||||
{"match": {"metadata." + str(field): value}}
|
||||
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||
)
|
||||
self.client.delete_by_query(
|
||||
index=self._get_index_name(collection_name), body=query_body
|
||||
)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
|
||||
def reset(self):
|
||||
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
"""
|
||||
Oracle 23ai Vector Database Client - Fixed Version
|
||||
|
||||
# .env
|
||||
VECTOR_DB = "oracle23ai"
|
||||
|
||||
## DBCS or oracle 23ai free
|
||||
ORACLE_DB_USE_WALLET = false
|
||||
ORACLE_DB_USER = "DEMOUSER"
|
||||
ORACLE_DB_PASSWORD = "Welcome123456"
|
||||
ORACLE_DB_DSN = "localhost:1521/FREEPDB1"
|
||||
|
||||
## ADW or ATP
|
||||
# ORACLE_DB_USE_WALLET = true
|
||||
# ORACLE_DB_USER = "DEMOUSER"
|
||||
# ORACLE_DB_PASSWORD = "Welcome123456"
|
||||
# ORACLE_DB_DSN = "medium"
|
||||
# ORACLE_DB_DSN = "(description= (retry_count=3)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=xx.oraclecloud.com))(connect_data=(service_name=yy.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
|
||||
# ORACLE_WALLET_DIR = "/home/opc/adb_wallet"
|
||||
# ORACLE_WALLET_PASSWORD = "Welcome1"
|
||||
|
||||
ORACLE_VECTOR_LENGTH = 768
|
||||
|
||||
ORACLE_DB_POOL_MIN = 2
|
||||
ORACLE_DB_POOL_MAX = 10
|
||||
ORACLE_DB_POOL_INCREMENT = 1
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any, Union
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import array
|
||||
import oracledb
|
||||
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
|
||||
from open_webui.config import (
|
||||
ORACLE_DB_USE_WALLET,
|
||||
ORACLE_DB_USER,
|
||||
ORACLE_DB_PASSWORD,
|
||||
ORACLE_DB_DSN,
|
||||
ORACLE_WALLET_DIR,
|
||||
ORACLE_WALLET_PASSWORD,
|
||||
ORACLE_VECTOR_LENGTH,
|
||||
ORACLE_DB_POOL_MIN,
|
||||
ORACLE_DB_POOL_MAX,
|
||||
ORACLE_DB_POOL_INCREMENT,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class Oracle23aiClient(VectorDBBase):
|
||||
"""
|
||||
Oracle Vector Database Client for vector similarity search using Oracle Database 23ai.
|
||||
|
||||
This client provides an interface to store, retrieve, and search vector embeddings
|
||||
in an Oracle database. It uses connection pooling for efficient database access
|
||||
and supports vector similarity search operations.
|
||||
|
||||
Attributes:
|
||||
pool: Connection pool for Oracle database connections
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize the Oracle23aiClient with a connection pool.
|
||||
|
||||
Creates a connection pool with configurable min/max connections, initializes
|
||||
the database schema if needed, and sets up necessary tables and indexes.
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration parameters are missing
|
||||
Exception: If database initialization fails
|
||||
"""
|
||||
self.pool = None
|
||||
|
||||
try:
|
||||
# Create the appropriate connection pool based on DB type
|
||||
if ORACLE_DB_USE_WALLET:
|
||||
self._create_adb_pool()
|
||||
else: # DBCS
|
||||
self._create_dbcs_pool()
|
||||
|
||||
dsn = ORACLE_DB_DSN
|
||||
log.info(f"Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]")
|
||||
|
||||
with self.get_connection() as connection:
|
||||
log.info(f"Connection version: {connection.version}")
|
||||
self._initialize_database(connection)
|
||||
|
||||
log.info("Oracle Vector Search initialization complete.")
|
||||
except Exception as e:
|
||||
log.exception(f"Error during Oracle Vector Search initialization: {e}")
|
||||
raise
|
||||
|
||||
def _create_adb_pool(self) -> None:
|
||||
"""
|
||||
Create connection pool for Oracle Autonomous Database.
|
||||
|
||||
Uses wallet-based authentication.
|
||||
"""
|
||||
self.pool = oracledb.create_pool(
|
||||
user=ORACLE_DB_USER,
|
||||
password=ORACLE_DB_PASSWORD,
|
||||
dsn=ORACLE_DB_DSN,
|
||||
min=ORACLE_DB_POOL_MIN,
|
||||
max=ORACLE_DB_POOL_MAX,
|
||||
increment=ORACLE_DB_POOL_INCREMENT,
|
||||
config_dir=ORACLE_WALLET_DIR,
|
||||
wallet_location=ORACLE_WALLET_DIR,
|
||||
wallet_password=ORACLE_WALLET_PASSWORD,
|
||||
)
|
||||
log.info("Created ADB connection pool with wallet authentication.")
|
||||
|
||||
def _create_dbcs_pool(self) -> None:
|
||||
"""
|
||||
Create connection pool for Oracle Database Cloud Service.
|
||||
|
||||
Uses basic authentication without wallet.
|
||||
"""
|
||||
self.pool = oracledb.create_pool(
|
||||
user=ORACLE_DB_USER,
|
||||
password=ORACLE_DB_PASSWORD,
|
||||
dsn=ORACLE_DB_DSN,
|
||||
min=ORACLE_DB_POOL_MIN,
|
||||
max=ORACLE_DB_POOL_MAX,
|
||||
increment=ORACLE_DB_POOL_INCREMENT,
|
||||
)
|
||||
log.info("Created DB connection pool with basic authentication.")
|
||||
|
||||
def get_connection(self):
|
||||
"""
|
||||
Acquire a connection from the connection pool with retry logic.
|
||||
|
||||
Returns:
|
||||
connection: A database connection with output type handler configured
|
||||
"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
connection = self.pool.acquire()
|
||||
connection.outputtypehandler = self._output_type_handler
|
||||
return connection
|
||||
except oracledb.DatabaseError as e:
|
||||
(error_obj,) = e.args
|
||||
log.exception(
|
||||
f"Connection attempt {attempt + 1} failed: {error_obj.message}"
|
||||
)
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2**attempt
|
||||
log.info(f"Retrying in {wait_time} seconds...")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise
|
||||
|
||||
def start_health_monitor(self, interval_seconds: int = 60):
|
||||
"""
|
||||
Start a background thread to periodically check the health of the connection pool.
|
||||
|
||||
Args:
|
||||
interval_seconds (int): Number of seconds between health checks
|
||||
"""
|
||||
|
||||
def _monitor():
|
||||
while True:
|
||||
try:
|
||||
log.info("[HealthCheck] Running periodic DB health check...")
|
||||
self.ensure_connection()
|
||||
log.info("[HealthCheck] Connection is healthy.")
|
||||
except Exception as e:
|
||||
log.exception(f"[HealthCheck] Connection health check failed: {e}")
|
||||
time.sleep(interval_seconds)
|
||||
|
||||
thread = threading.Thread(target=_monitor, daemon=True)
|
||||
thread.start()
|
||||
log.info(f"Started DB health monitor every {interval_seconds} seconds.")
|
||||
|
||||
def _reconnect_pool(self):
|
||||
"""
|
||||
Attempt to reinitialize the connection pool if it's been closed or broken.
|
||||
"""
|
||||
try:
|
||||
log.info("Attempting to reinitialize the Oracle connection pool...")
|
||||
|
||||
# Close existing pool if it exists
|
||||
if self.pool:
|
||||
try:
|
||||
self.pool.close()
|
||||
except Exception as close_error:
|
||||
log.warning(f"Error closing existing pool: {close_error}")
|
||||
|
||||
# Re-create the appropriate connection pool based on DB type
|
||||
if ORACLE_DB_USE_WALLET:
|
||||
self._create_adb_pool()
|
||||
else: # DBCS
|
||||
self._create_dbcs_pool()
|
||||
|
||||
log.info("Connection pool reinitialized.")
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to reinitialize the connection pool: {e}")
|
||||
raise
|
||||
|
||||
def ensure_connection(self):
|
||||
"""
|
||||
Ensure the database connection is alive, reconnecting pool if needed.
|
||||
"""
|
||||
try:
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 FROM dual")
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Connection check failed: {e}, attempting to reconnect pool..."
|
||||
)
|
||||
self._reconnect_pool()
|
||||
|
||||
def _output_type_handler(self, cursor, metadata):
|
||||
"""
|
||||
Handle Oracle vector type conversion.
|
||||
|
||||
Args:
|
||||
cursor: Oracle database cursor
|
||||
metadata: Metadata for the column
|
||||
|
||||
Returns:
|
||||
A variable with appropriate conversion for vector types
|
||||
"""
|
||||
if metadata.type_code is oracledb.DB_TYPE_VECTOR:
|
||||
return cursor.var(
|
||||
metadata.type_code, arraysize=cursor.arraysize, outconverter=list
|
||||
)
|
||||
|
||||
def _initialize_database(self, connection) -> None:
|
||||
"""
|
||||
Initialize database schema, tables and indexes.
|
||||
|
||||
Creates the document_chunk table and necessary indexes if they don't exist.
|
||||
|
||||
Args:
|
||||
connection: Oracle database connection
|
||||
|
||||
Raises:
|
||||
Exception: If schema initialization fails
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
log.info("Creating Table document_chunk")
|
||||
cursor.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE IF NOT EXISTS document_chunk (
|
||||
id VARCHAR2(255) PRIMARY KEY,
|
||||
collection_name VARCHAR2(255) NOT NULL,
|
||||
text CLOB,
|
||||
vmetadata JSON,
|
||||
vector vector(*, float32)
|
||||
)
|
||||
';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
log.info("Creating Index document_chunk_collection_name_idx")
|
||||
cursor.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
|
||||
ON document_chunk (collection_name)
|
||||
';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
|
||||
cursor.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
|
||||
ON document_chunk(vector)
|
||||
ORGANIZATION NEIGHBOR PARTITIONS
|
||||
DISTANCE COSINE
|
||||
WITH TARGET ACCURACY 95
|
||||
PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS 100)
|
||||
';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
log.info("Database initialization completed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
log.exception(f"Error during database initialization: {e}")
|
||||
raise
|
||||
|
||||
def check_vector_length(self) -> None:
|
||||
"""
|
||||
Check vector length compatibility (placeholder).
|
||||
|
||||
This method would check if the configured vector length matches the database schema.
|
||||
Currently implemented as a placeholder.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _vector_to_blob(self, vector: List[float]) -> bytes:
|
||||
"""
|
||||
Convert a vector to Oracle BLOB format.
|
||||
|
||||
Args:
|
||||
vector (List[float]): The vector to convert
|
||||
|
||||
Returns:
|
||||
bytes: The vector in Oracle BLOB format
|
||||
"""
|
||||
return array.array("f", vector)
|
||||
|
||||
def adjust_vector_length(self, vector: List[float]) -> List[float]:
|
||||
"""
|
||||
Adjust vector to the expected length if needed.
|
||||
|
||||
Args:
|
||||
vector (List[float]): The vector to adjust
|
||||
|
||||
Returns:
|
||||
List[float]: The adjusted vector
|
||||
"""
|
||||
return vector
|
||||
|
||||
def _decimal_handler(self, obj):
|
||||
"""
|
||||
Handle Decimal objects for JSON serialization.
|
||||
|
||||
Args:
|
||||
obj: Object to serialize
|
||||
|
||||
Returns:
|
||||
float: Converted decimal value
|
||||
|
||||
Raises:
|
||||
TypeError: If object is not JSON serializable
|
||||
"""
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
raise TypeError(f"{obj} is not JSON serializable")
|
||||
|
||||
def _metadata_to_json(self, metadata: Dict) -> str:
|
||||
"""
|
||||
Convert metadata dictionary to JSON string.
|
||||
|
||||
Args:
|
||||
metadata (Dict): Metadata dictionary
|
||||
|
||||
Returns:
|
||||
str: JSON representation of metadata
|
||||
"""
|
||||
return json.dumps(metadata, default=self._decimal_handler) if metadata else "{}"
|
||||
|
||||
def _json_to_metadata(self, json_str: str) -> Dict:
|
||||
"""
|
||||
Convert JSON string to metadata dictionary.
|
||||
|
||||
Args:
|
||||
json_str (str): JSON string
|
||||
|
||||
Returns:
|
||||
Dict: Metadata dictionary
|
||||
"""
|
||||
return json.loads(json_str) if json_str else {}
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""
|
||||
Insert vector items into the database.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection
|
||||
items (List[VectorItem]): List of vector items to insert
|
||||
|
||||
Raises:
|
||||
Exception: If insertion fails
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> items = [
|
||||
... {"id": "1", "text": "Sample text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
|
||||
... {"id": "2", "text": "Another text", "vector": [0.3, 0.4, ...], "metadata": {"source": "doc2"}}
|
||||
... ]
|
||||
>>> client.insert("my_collection", items)
|
||||
"""
|
||||
log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
|
||||
|
||||
with self.get_connection() as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
for item in items:
|
||||
vector_blob = self._vector_to_blob(item["vector"])
|
||||
metadata_json = self._metadata_to_json(item["metadata"])
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO document_chunk
|
||||
(id, collection_name, text, vmetadata, vector)
|
||||
VALUES (:id, :collection_name, :text, :metadata, :vector)
|
||||
""",
|
||||
{
|
||||
"id": item["id"],
|
||||
"collection_name": collection_name,
|
||||
"text": item["text"],
|
||||
"metadata": metadata_json,
|
||||
"vector": vector_blob,
|
||||
},
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
log.info(
|
||||
f"Successfully inserted {len(items)} items into collection '{collection_name}'."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
log.exception(f"Error during insert: {e}")
|
||||
raise
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""
|
||||
Update or insert vector items into the database.
|
||||
|
||||
If an item with the same ID exists, it will be updated;
|
||||
otherwise, it will be inserted.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection
|
||||
items (List[VectorItem]): List of vector items to upsert
|
||||
|
||||
Raises:
|
||||
Exception: If upsert operation fails
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> items = [
|
||||
... {"id": "1", "text": "Updated text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
|
||||
... {"id": "3", "text": "New item", "vector": [0.5, 0.6, ...], "metadata": {"source": "doc3"}}
|
||||
... ]
|
||||
>>> client.upsert("my_collection", items)
|
||||
"""
|
||||
log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
|
||||
|
||||
with self.get_connection() as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
for item in items:
|
||||
vector_blob = self._vector_to_blob(item["vector"])
|
||||
metadata_json = self._metadata_to_json(item["metadata"])
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
MERGE INTO document_chunk d
|
||||
USING (SELECT :merge_id as id FROM dual) s
|
||||
ON (d.id = s.id)
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
collection_name = :upd_collection_name,
|
||||
text = :upd_text,
|
||||
vmetadata = :upd_metadata,
|
||||
vector = :upd_vector
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (id, collection_name, text, vmetadata, vector)
|
||||
VALUES (:ins_id, :ins_collection_name, :ins_text, :ins_metadata, :ins_vector)
|
||||
""",
|
||||
{
|
||||
"merge_id": item["id"],
|
||||
"upd_collection_name": collection_name,
|
||||
"upd_text": item["text"],
|
||||
"upd_metadata": metadata_json,
|
||||
"upd_vector": vector_blob,
|
||||
"ins_id": item["id"],
|
||||
"ins_collection_name": collection_name,
|
||||
"ins_text": item["text"],
|
||||
"ins_metadata": metadata_json,
|
||||
"ins_vector": vector_blob,
|
||||
},
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
log.info(
|
||||
f"Successfully upserted {len(items)} items into collection '{collection_name}'."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
log.exception(f"Error during upsert: {e}")
|
||||
raise
|
||||
|
||||
def search(
|
||||
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
"""
|
||||
Search for similar vectors in the database.
|
||||
|
||||
Performs vector similarity search using cosine distance.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection to search
|
||||
vectors (List[List[Union[float, int]]]): Query vectors to find similar items for
|
||||
limit (int): Maximum number of results to return per query
|
||||
|
||||
Returns:
|
||||
Optional[SearchResult]: Search results containing ids, distances, documents, and metadata
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> query_vector = [0.1, 0.2, 0.3, ...] # Must match VECTOR_LENGTH
|
||||
>>> results = client.search("my_collection", [query_vector], limit=5)
|
||||
>>> if results:
|
||||
... log.info(f"Found {len(results.ids[0])} matches")
|
||||
... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])):
|
||||
... log.info(f"Match {i+1}: id={id}, distance={dist}")
|
||||
"""
|
||||
log.info(
|
||||
f"Searching items from collection '{collection_name}' with limit {limit}."
|
||||
)
|
||||
|
||||
try:
|
||||
if not vectors:
|
||||
log.warning("No vectors provided for search.")
|
||||
return None
|
||||
|
||||
num_queries = len(vectors)
|
||||
|
||||
ids = [[] for _ in range(num_queries)]
|
||||
distances = [[] for _ in range(num_queries)]
|
||||
documents = [[] for _ in range(num_queries)]
|
||||
metadatas = [[] for _ in range(num_queries)]
|
||||
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
for qid, vector in enumerate(vectors):
|
||||
vector_blob = self._vector_to_blob(vector)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dc.id, dc.text,
|
||||
JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata,
|
||||
VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance
|
||||
FROM document_chunk dc
|
||||
WHERE dc.collection_name = :collection_name
|
||||
ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
|
||||
FETCH APPROX FIRST :limit ROWS ONLY
|
||||
""",
|
||||
{
|
||||
"query_vector": vector_blob,
|
||||
"collection_name": collection_name,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
|
||||
results = cursor.fetchall()
|
||||
|
||||
for row in results:
|
||||
ids[qid].append(row[0])
|
||||
documents[qid].append(
|
||||
row[1].read()
|
||||
if isinstance(row[1], oracledb.LOB)
|
||||
else str(row[1])
|
||||
)
|
||||
# 🔧 FIXED: Parse JSON metadata properly
|
||||
metadata_str = (
|
||||
row[2].read()
|
||||
if isinstance(row[2], oracledb.LOB)
|
||||
else row[2]
|
||||
)
|
||||
metadatas[qid].append(self._json_to_metadata(metadata_str))
|
||||
distances[qid].append(float(row[3]))
|
||||
|
||||
log.info(
|
||||
f"Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results."
|
||||
)
|
||||
|
||||
return SearchResult(
|
||||
ids=ids, distances=distances, documents=documents, metadatas=metadatas
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error during search: {e}")
|
||||
return None
|
||||
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict, limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
"""
|
||||
Query items based on metadata filters.
|
||||
|
||||
Retrieves items that match specified metadata criteria.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection to query
|
||||
filter (Dict[str, Any]): Metadata filters to apply
|
||||
limit (Optional[int]): Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
Optional[GetResult]: Query results containing ids, documents, and metadata
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> filter = {"source": "doc1", "category": "finance"}
|
||||
>>> results = client.query("my_collection", filter, limit=20)
|
||||
>>> if results:
|
||||
... print(f"Found {len(results.ids[0])} matching documents")
|
||||
"""
|
||||
log.info(f"Querying items from collection '{collection_name}' with filters.")
|
||||
|
||||
try:
|
||||
limit = limit or 100
|
||||
|
||||
query = """
|
||||
SELECT id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
|
||||
FROM document_chunk
|
||||
WHERE collection_name = :collection_name
|
||||
"""
|
||||
|
||||
params = {"collection_name": collection_name}
|
||||
|
||||
for i, (key, value) in enumerate(filter.items()):
|
||||
param_name = f"value_{i}"
|
||||
query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
|
||||
params[param_name] = str(value)
|
||||
|
||||
query += " FETCH FIRST :limit ROWS ONLY"
|
||||
params["limit"] = limit
|
||||
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(query, params)
|
||||
results = cursor.fetchall()
|
||||
|
||||
if not results:
|
||||
log.info("No results found for query.")
|
||||
return None
|
||||
|
||||
ids = [[row[0] for row in results]]
|
||||
documents = [
|
||||
[
|
||||
row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
|
||||
for row in results
|
||||
]
|
||||
]
|
||||
# 🔧 FIXED: Parse JSON metadata properly
|
||||
metadatas = [
|
||||
[
|
||||
self._json_to_metadata(
|
||||
row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
|
||||
)
|
||||
for row in results
|
||||
]
|
||||
]
|
||||
|
||||
log.info(f"Query completed. Found {len(results)} results.")
|
||||
|
||||
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error during query: {e}")
|
||||
return None
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
"""
|
||||
Get all items in a collection.
|
||||
|
||||
Retrieves items from a specified collection up to the limit.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection to retrieve
|
||||
limit (Optional[int]): Maximum number of items to retrieve
|
||||
|
||||
Returns:
|
||||
Optional[GetResult]: Result containing ids, documents, and metadata
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> results = client.get("my_collection", limit=50)
|
||||
>>> if results:
|
||||
... print(f"Retrieved {len(results.ids[0])} documents from collection")
|
||||
"""
|
||||
log.info(
|
||||
f"Getting items from collection '{collection_name}' with limit {limit}."
|
||||
)
|
||||
|
||||
try:
|
||||
limit = limit or 1000
|
||||
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT /*+ MONITOR */ id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
|
||||
FROM document_chunk
|
||||
WHERE collection_name = :collection_name
|
||||
FETCH FIRST :limit ROWS ONLY
|
||||
""",
|
||||
{"collection_name": collection_name, "limit": limit},
|
||||
)
|
||||
|
||||
results = cursor.fetchall()
|
||||
|
||||
if not results:
|
||||
log.info("No results found.")
|
||||
return None
|
||||
|
||||
ids = [[row[0] for row in results]]
|
||||
documents = [
|
||||
[
|
||||
row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
|
||||
for row in results
|
||||
]
|
||||
]
|
||||
# 🔧 FIXED: Parse JSON metadata properly
|
||||
metadatas = [
|
||||
[
|
||||
self._json_to_metadata(
|
||||
row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
|
||||
)
|
||||
for row in results
|
||||
]
|
||||
]
|
||||
|
||||
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error during get: {e}")
|
||||
return None
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Delete items from the database.
|
||||
|
||||
Deletes items from a collection based on IDs or metadata filters.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection to delete from
|
||||
ids (Optional[List[str]]): Specific item IDs to delete
|
||||
filter (Optional[Dict[str, Any]]): Metadata filters for deletion
|
||||
|
||||
Raises:
|
||||
Exception: If deletion fails
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> # Delete specific items by ID
|
||||
>>> client.delete("my_collection", ids=["1", "3", "5"])
|
||||
>>> # Or delete by metadata filter
|
||||
>>> client.delete("my_collection", filter={"source": "deprecated_source"})
|
||||
"""
|
||||
log.info(f"Deleting items from collection '{collection_name}'.")
|
||||
|
||||
try:
|
||||
query = (
|
||||
"DELETE FROM document_chunk WHERE collection_name = :collection_name"
|
||||
)
|
||||
params = {"collection_name": collection_name}
|
||||
|
||||
if ids:
|
||||
# 🔧 FIXED: Use proper parameterized query to prevent SQL injection
|
||||
placeholders = ",".join([f":id_{i}" for i in range(len(ids))])
|
||||
query += f" AND id IN ({placeholders})"
|
||||
for i, id_val in enumerate(ids):
|
||||
params[f"id_{i}"] = id_val
|
||||
|
||||
if filter:
|
||||
for i, (key, value) in enumerate(filter.items()):
|
||||
param_name = f"value_{i}"
|
||||
query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
|
||||
params[param_name] = str(value)
|
||||
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(query, params)
|
||||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
|
||||
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error during delete: {e}")
|
||||
raise
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the database by deleting all items.
|
||||
|
||||
Deletes all items from the document_chunk table.
|
||||
|
||||
Raises:
|
||||
Exception: If reset fails
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> client.reset() # Warning: Removes all data!
|
||||
"""
|
||||
log.info("Resetting database - deleting all items.")
|
||||
|
||||
try:
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM document_chunk")
|
||||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
|
||||
log.info(
|
||||
f"Reset complete. Deleted {deleted} items from 'document_chunk' table."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error during reset: {e}")
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the database connection pool.
|
||||
|
||||
Properly closes the connection pool and releases all resources.
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> # After finishing all operations
|
||||
>>> client.close()
|
||||
"""
|
||||
try:
|
||||
if hasattr(self, "pool") and self.pool:
|
||||
self.pool.close()
|
||||
log.info("Oracle Vector Search connection pool closed.")
|
||||
except Exception as e:
|
||||
log.exception(f"Error closing connection pool: {e}")
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
"""
|
||||
Check if a collection exists.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection to check
|
||||
|
||||
Returns:
|
||||
bool: True if the collection exists, False otherwise
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> if client.has_collection("my_collection"):
|
||||
... print("Collection exists!")
|
||||
... else:
|
||||
... print("Collection does not exist.")
|
||||
"""
|
||||
try:
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM document_chunk
|
||||
WHERE collection_name = :collection_name
|
||||
FETCH FIRST 1 ROWS ONLY
|
||||
""",
|
||||
{"collection_name": collection_name},
|
||||
)
|
||||
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
return count > 0
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error checking collection existence: {e}")
|
||||
return False
|
||||
|
||||
def delete_collection(self, collection_name: str) -> None:
|
||||
"""
|
||||
Delete an entire collection.
|
||||
|
||||
Removes all items belonging to the specified collection.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the collection to delete
|
||||
|
||||
Example:
|
||||
>>> client = Oracle23aiClient()
|
||||
>>> client.delete_collection("obsolete_collection")
|
||||
"""
|
||||
log.info(f"Deleting collection '{collection_name}'.")
|
||||
|
||||
try:
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM document_chunk
|
||||
WHERE collection_name = :collection_name
|
||||
""",
|
||||
{"collection_name": collection_name},
|
||||
)
|
||||
|
||||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
|
||||
log.info(
|
||||
f"Collection '{collection_name}' deleted. Removed {deleted} items."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting collection '{collection_name}': {e}")
|
||||
raise
|
||||
@@ -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,
|
||||
@@ -14,7 +18,7 @@ from sqlalchemy import (
|
||||
values,
|
||||
)
|
||||
from sqlalchemy.sql import true
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
||||
from sqlalchemy.dialects.postgresql import JSONB, array
|
||||
@@ -22,8 +26,24 @@ from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.ext.mutable import MutableDict
|
||||
from sqlalchemy.exc import NoSuchTableError
|
||||
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.config import (
|
||||
PGVECTOR_DB_URL,
|
||||
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
||||
PGVECTOR_PGCRYPTO,
|
||||
PGVECTOR_PGCRYPTO_KEY,
|
||||
PGVECTOR_POOL_SIZE,
|
||||
PGVECTOR_POOL_MAX_OVERFLOW,
|
||||
PGVECTOR_POOL_TIMEOUT,
|
||||
PGVECTOR_POOL_RECYCLE,
|
||||
)
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
@@ -34,17 +54,30 @@ 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:
|
||||
class PgvectorClient(VectorDBBase):
|
||||
def __init__(self) -> None:
|
||||
|
||||
# if no pgvector uri, use the existing database connection
|
||||
@@ -53,9 +86,24 @@ class PgvectorClient:
|
||||
|
||||
self.session = Session
|
||||
else:
|
||||
engine = create_engine(
|
||||
PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
|
||||
)
|
||||
if isinstance(PGVECTOR_POOL_SIZE, int):
|
||||
if PGVECTOR_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
PGVECTOR_DB_URL,
|
||||
pool_size=PGVECTOR_POOL_SIZE,
|
||||
max_overflow=PGVECTOR_POOL_MAX_OVERFLOW,
|
||||
pool_timeout=PGVECTOR_POOL_TIMEOUT,
|
||||
pool_recycle=PGVECTOR_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
poolclass=QueuePool,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
|
||||
)
|
||||
else:
|
||||
engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
||||
)
|
||||
@@ -63,7 +111,40 @@ class PgvectorClient:
|
||||
|
||||
try:
|
||||
# Ensure the pgvector extension is available
|
||||
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
# Ensure the pgcrypto extension is available for encryption
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if not PGVECTOR_PGCRYPTO_KEY:
|
||||
raise ValueError(
|
||||
"PGVECTOR_PGCRYPTO_KEY must be set when PGVECTOR_PGCRYPTO is enabled."
|
||||
)
|
||||
|
||||
# Check vector length consistency
|
||||
self.check_vector_length()
|
||||
@@ -136,29 +217,60 @@ class PgvectorClient:
|
||||
# Pad the vector with zeros
|
||||
vector += [0.0] * (VECTOR_LENGTH - current_length)
|
||||
elif current_length > VECTOR_LENGTH:
|
||||
raise Exception(
|
||||
f"Vector length {current_length} not supported. Max length must be <= {VECTOR_LENGTH}"
|
||||
)
|
||||
# Truncate the vector to VECTOR_LENGTH
|
||||
vector = vector[:VECTOR_LENGTH]
|
||||
return vector
|
||||
|
||||
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"],
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
# Use raw SQL for BYTEA/pgcrypto
|
||||
# Ensure metadata is converted to its JSON text representation
|
||||
json_metadata = json.dumps(item["metadata"])
|
||||
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_text": json_metadata,
|
||||
"key": PGVECTOR_PGCRYPTO_KEY,
|
||||
},
|
||||
)
|
||||
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,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=stringify_metadata(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}'."
|
||||
)
|
||||
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}")
|
||||
@@ -166,33 +278,66 @@ class PgvectorClient:
|
||||
|
||||
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 PGVECTOR_PGCRYPTO:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
json_metadata = json.dumps(item["metadata"])
|
||||
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_text": json_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 = stringify_metadata(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=stringify_metadata(item["metadata"]),
|
||||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
log.info(
|
||||
f"Upserted {len(items)} items into collection '{collection_name}'."
|
||||
)
|
||||
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}")
|
||||
@@ -226,16 +371,32 @@ class PgvectorClient:
|
||||
.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))
|
||||
@@ -284,10 +445,12 @@ class PgvectorClient:
|
||||
documents[qid].append(row.text)
|
||||
metadatas[qid].append(row.vmetadata)
|
||||
|
||||
self.session.rollback() # read-only transaction
|
||||
return SearchResult(
|
||||
ids=ids, distances=distances, documents=documents, metadatas=metadatas
|
||||
)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error during search: {e}")
|
||||
return None
|
||||
|
||||
@@ -295,17 +458,43 @@ class PgvectorClient:
|
||||
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
|
||||
@@ -314,12 +503,14 @@ class PgvectorClient:
|
||||
documents = [[result.text for result in results]]
|
||||
metadatas = [[result.vmetadata for result in results]]
|
||||
|
||||
self.session.rollback() # read-only transaction
|
||||
return GetResult(
|
||||
ids=ids,
|
||||
documents=documents,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error during query: {e}")
|
||||
return None
|
||||
|
||||
@@ -327,23 +518,43 @@ class PgvectorClient:
|
||||
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]]
|
||||
|
||||
self.session.rollback() # read-only transaction
|
||||
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error during get: {e}")
|
||||
return None
|
||||
|
||||
@@ -354,17 +565,33 @@ class PgvectorClient:
|
||||
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:
|
||||
@@ -395,8 +622,10 @@ class PgvectorClient:
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
self.session.rollback() # read-only transaction
|
||||
return exists
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
log.exception(f"Error checking collection existence: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
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,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.config import (
|
||||
PINECONE_API_KEY,
|
||||
PINECONE_ENVIRONMENT,
|
||||
PINECONE_INDEX_NAME,
|
||||
PINECONE_DIMENSION,
|
||||
PINECONE_METRIC,
|
||||
PINECONE_CLOUD,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
|
||||
|
||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class PineconeClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = "open-webui"
|
||||
|
||||
# Validate required configuration
|
||||
self._validate_config()
|
||||
|
||||
# Store configuration values
|
||||
self.api_key = PINECONE_API_KEY
|
||||
self.environment = PINECONE_ENVIRONMENT
|
||||
self.index_name = PINECONE_INDEX_NAME
|
||||
self.dimension = PINECONE_DIMENSION
|
||||
self.metric = PINECONE_METRIC
|
||||
self.cloud = PINECONE_CLOUD
|
||||
|
||||
# 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()
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate that all required configuration variables are set."""
|
||||
missing_vars = []
|
||||
if not PINECONE_API_KEY:
|
||||
missing_vars.append("PINECONE_API_KEY")
|
||||
if not PINECONE_ENVIRONMENT:
|
||||
missing_vars.append("PINECONE_ENVIRONMENT")
|
||||
if not PINECONE_INDEX_NAME:
|
||||
missing_vars.append("PINECONE_INDEX_NAME")
|
||||
if not PINECONE_DIMENSION:
|
||||
missing_vars.append("PINECONE_DIMENSION")
|
||||
if not PINECONE_CLOUD:
|
||||
missing_vars.append("PINECONE_CLOUD")
|
||||
|
||||
if missing_vars:
|
||||
raise ValueError(
|
||||
f"Required configuration missing: {', '.join(missing_vars)}"
|
||||
)
|
||||
|
||||
def _initialize_index(self) -> None:
|
||||
"""Initialize the Pinecone index."""
|
||||
try:
|
||||
# Check if index exists
|
||||
if self.index_name not in self.client.list_indexes().names():
|
||||
log.info(f"Creating Pinecone index '{self.index_name}'...")
|
||||
self.client.create_index(
|
||||
name=self.index_name,
|
||||
dimension=self.dimension,
|
||||
metric=self.metric,
|
||||
spec=ServerlessSpec(cloud=self.cloud, region=self.environment),
|
||||
)
|
||||
log.info(f"Successfully created Pinecone index '{self.index_name}'")
|
||||
else:
|
||||
log.info(f"Using existing Pinecone index '{self.index_name}'")
|
||||
|
||||
# Connect to the index
|
||||
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]]:
|
||||
"""Convert VectorItem objects to Pinecone point format."""
|
||||
points = []
|
||||
for item in items:
|
||||
# Start with any existing metadata or an empty dict
|
||||
metadata = item.get("metadata", {}).copy() if item.get("metadata") else {}
|
||||
|
||||
# Add text to metadata if available
|
||||
if "text" in item:
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Always add collection_name to metadata for filtering
|
||||
metadata["collection_name"] = collection_name_with_prefix
|
||||
|
||||
point = {
|
||||
"id": item["id"],
|
||||
"values": item["vector"],
|
||||
"metadata": stringify_metadata(metadata),
|
||||
}
|
||||
points.append(point)
|
||||
return points
|
||||
|
||||
def _get_collection_name_with_prefix(self, collection_name: str) -> str:
|
||||
"""Get the collection name with prefix."""
|
||||
return f"{self.collection_prefix}_{collection_name}"
|
||||
|
||||
def _normalize_distance(self, score: float) -> float:
|
||||
"""Normalize distance score based on the metric used."""
|
||||
if self.metric.lower() == "cosine":
|
||||
# Cosine similarity ranges from -1 to 1, normalize to 0 to 1
|
||||
return (score + 1.0) / 2.0
|
||||
elif self.metric.lower() in ["euclidean", "dotproduct"]:
|
||||
# These are already suitable for ranking (smaller is better for Euclidean)
|
||||
return score
|
||||
else:
|
||||
# For other metrics, use as is
|
||||
return score
|
||||
|
||||
def _result_to_get_result(self, matches: list) -> GetResult:
|
||||
"""Convert Pinecone matches to GetResult format."""
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
|
||||
for match in matches:
|
||||
metadata = getattr(match, "metadata", {}) or {}
|
||||
ids.append(match.id if hasattr(match, "id") else match["id"])
|
||||
documents.append(metadata.get("text", ""))
|
||||
metadatas.append(metadata)
|
||||
|
||||
return GetResult(
|
||||
**{
|
||||
"ids": [ids],
|
||||
"documents": [documents],
|
||||
"metadatas": [metadatas],
|
||||
}
|
||||
)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
"""Check if a collection exists by searching for at least one item."""
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
|
||||
try:
|
||||
# Search for at least 1 item with this collection name in metadata
|
||||
response = self.index.query(
|
||||
vector=[0.0] * self.dimension, # dummy vector
|
||||
top_k=1,
|
||||
filter={"collection_name": collection_name_with_prefix},
|
||||
include_metadata=False,
|
||||
)
|
||||
matches = getattr(response, "matches", []) or []
|
||||
return len(matches) > 0
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error checking collection '{collection_name_with_prefix}': {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def delete_collection(self, collection_name: str) -> None:
|
||||
"""Delete a collection by removing all vectors with the collection name in metadata."""
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
try:
|
||||
self.index.delete(filter={"collection_name": collection_name_with_prefix})
|
||||
log.info(
|
||||
f"Collection '{collection_name_with_prefix}' deleted (all vectors removed)."
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"Failed to delete collection '{collection_name_with_prefix}': {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Insert vectors into a collection."""
|
||||
if not items:
|
||||
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)
|
||||
|
||||
# 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:
|
||||
future.result()
|
||||
except Exception as 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(points)} vectors in parallel batches "
|
||||
f"into '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Upsert (insert or update) vectors into a collection."""
|
||||
if not items:
|
||||
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)
|
||||
|
||||
# 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 async upserted {len(points)} vectors in batches "
|
||||
f"into '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
def search(
|
||||
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
"""Search for similar vectors in a collection."""
|
||||
if not vectors or not vectors[0]:
|
||||
log.warning("No vectors provided for search")
|
||||
return None
|
||||
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
|
||||
if limit is None or limit <= 0:
|
||||
limit = NO_LIMIT
|
||||
|
||||
try:
|
||||
# Search using the first vector (assuming this is the intended behavior)
|
||||
query_vector = vectors[0]
|
||||
|
||||
# Perform the search
|
||||
query_response = self.index.query(
|
||||
vector=query_vector,
|
||||
top_k=limit,
|
||||
include_metadata=True,
|
||||
filter={"collection_name": collection_name_with_prefix},
|
||||
)
|
||||
|
||||
matches = getattr(query_response, "matches", []) or []
|
||||
if not matches:
|
||||
# Return empty result if no matches
|
||||
return SearchResult(
|
||||
ids=[[]],
|
||||
documents=[[]],
|
||||
metadatas=[[]],
|
||||
distances=[[]],
|
||||
)
|
||||
|
||||
# Convert to GetResult format
|
||||
get_result = self._result_to_get_result(matches)
|
||||
|
||||
# Calculate normalized distances based on metric
|
||||
distances = [
|
||||
[
|
||||
self._normalize_distance(getattr(match, "score", 0.0))
|
||||
for match in matches
|
||||
]
|
||||
]
|
||||
|
||||
return SearchResult(
|
||||
ids=get_result.ids,
|
||||
documents=get_result.documents,
|
||||
metadatas=get_result.metadatas,
|
||||
distances=distances,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error searching in '{collection_name_with_prefix}': {e}")
|
||||
return None
|
||||
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict, limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
"""Query vectors by metadata filter."""
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
|
||||
if limit is None or limit <= 0:
|
||||
limit = NO_LIMIT
|
||||
|
||||
try:
|
||||
# Create a zero vector for the dimension as Pinecone requires a vector
|
||||
zero_vector = [0.0] * self.dimension
|
||||
|
||||
# Combine user filter with collection_name
|
||||
pinecone_filter = {"collection_name": collection_name_with_prefix}
|
||||
if filter:
|
||||
pinecone_filter.update(filter)
|
||||
|
||||
# Perform metadata-only query
|
||||
query_response = self.index.query(
|
||||
vector=zero_vector,
|
||||
filter=pinecone_filter,
|
||||
top_k=limit,
|
||||
include_metadata=True,
|
||||
)
|
||||
|
||||
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}")
|
||||
return None
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
"""Get all vectors in a collection."""
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
|
||||
try:
|
||||
# Use a zero vector for fetching all entries
|
||||
zero_vector = [0.0] * self.dimension
|
||||
|
||||
# Add filter to only get vectors for this collection
|
||||
query_response = self.index.query(
|
||||
vector=zero_vector,
|
||||
top_k=NO_LIMIT,
|
||||
include_metadata=True,
|
||||
filter={"collection_name": collection_name_with_prefix},
|
||||
)
|
||||
|
||||
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}")
|
||||
return None
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict] = None,
|
||||
) -> None:
|
||||
"""Delete vectors by IDs or filter."""
|
||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
||||
collection_name
|
||||
)
|
||||
|
||||
try:
|
||||
if ids:
|
||||
# Delete by IDs (in batches for large deletions)
|
||||
for i in range(0, len(ids), BATCH_SIZE):
|
||||
batch_ids = ids[i : i + BATCH_SIZE]
|
||||
# Note: When deleting by ID, we can't filter by collection_name
|
||||
# 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 "
|
||||
f"from '{collection_name_with_prefix}'"
|
||||
)
|
||||
log.info(
|
||||
f"Successfully deleted {len(ids)} vectors by ID "
|
||||
f"from '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
elif filter:
|
||||
# Combine user filter with collection_name
|
||||
pinecone_filter = {"collection_name": collection_name_with_prefix}
|
||||
if filter:
|
||||
pinecone_filter.update(filter)
|
||||
# Delete by metadata filter
|
||||
self.index.delete(filter=pinecone_filter)
|
||||
log.info(
|
||||
f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'"
|
||||
)
|
||||
|
||||
else:
|
||||
log.warning("No ids or filter provided for delete operation")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting from collection '{collection_name}': {e}")
|
||||
raise
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the database by deleting all collections."""
|
||||
try:
|
||||
self.index.delete(delete_all=True)
|
||||
log.info("All vectors successfully deleted from the index.")
|
||||
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()
|
||||
@@ -1,12 +1,27 @@
|
||||
from typing import Optional
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from qdrant_client import QdrantClient as Qclient
|
||||
from qdrant_client.http.models import PointStruct
|
||||
from qdrant_client.models import models
|
||||
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.config import QDRANT_URI, QDRANT_API_KEY
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.config import (
|
||||
QDRANT_URI,
|
||||
QDRANT_API_KEY,
|
||||
QDRANT_ON_DISK,
|
||||
QDRANT_GRPC_PORT,
|
||||
QDRANT_PREFER_GRPC,
|
||||
QDRANT_COLLECTION_PREFIX,
|
||||
QDRANT_TIMEOUT,
|
||||
QDRANT_HNSW_M,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
NO_LIMIT = 999999999
|
||||
@@ -15,16 +30,41 @@ log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class QdrantClient:
|
||||
class QdrantClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = "open-webui"
|
||||
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||
self.QDRANT_URI = QDRANT_URI
|
||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||
self.client = (
|
||||
Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
||||
if self.QDRANT_URI
|
||||
else None
|
||||
)
|
||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||
self.PREFER_GRPC = QDRANT_PREFER_GRPC
|
||||
self.GRPC_PORT = QDRANT_GRPC_PORT
|
||||
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
|
||||
self.QDRANT_HNSW_M = QDRANT_HNSW_M
|
||||
|
||||
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,
|
||||
timeout=self.QDRANT_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
self.client = Qclient(
|
||||
url=self.QDRANT_URI,
|
||||
api_key=self.QDRANT_API_KEY,
|
||||
timeout=QDRANT_TIMEOUT,
|
||||
)
|
||||
|
||||
def _result_to_get_result(self, points) -> GetResult:
|
||||
ids = []
|
||||
@@ -50,10 +90,34 @@ class QdrantClient:
|
||||
self.client.create_collection(
|
||||
collection_name=collection_name_with_prefix,
|
||||
vectors_config=models.VectorParams(
|
||||
size=dimension, distance=models.Distance.COSINE
|
||||
size=dimension,
|
||||
distance=models.Distance.COSINE,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
hnsw_config=models.HnswConfigDiff(
|
||||
m=self.QDRANT_HNSW_M,
|
||||
),
|
||||
)
|
||||
|
||||
# Create payload indexes for efficient filtering
|
||||
self.client.create_payload_index(
|
||||
collection_name=collection_name_with_prefix,
|
||||
field_name="metadata.hash",
|
||||
field_schema=models.KeywordIndexParams(
|
||||
type=models.KeywordIndexType.KEYWORD,
|
||||
is_tenant=False,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
)
|
||||
self.client.create_payload_index(
|
||||
collection_name=collection_name_with_prefix,
|
||||
field_name="metadata.file_id",
|
||||
field_schema=models.KeywordIndexParams(
|
||||
type=models.KeywordIndexType.KEYWORD,
|
||||
is_tenant=False,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
)
|
||||
log.info(f"collection {collection_name_with_prefix} successfully created!")
|
||||
|
||||
def _create_collection_if_not_exists(self, collection_name, dimension):
|
||||
@@ -119,23 +183,23 @@ class QdrantClient:
|
||||
)
|
||||
)
|
||||
|
||||
points = self.client.query_points(
|
||||
points = self.client.scroll(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
query_filter=models.Filter(should=field_conditions),
|
||||
scroll_filter=models.Filter(should=field_conditions),
|
||||
limit=limit,
|
||||
)
|
||||
return self._result_to_get_result(points.points)
|
||||
return self._result_to_get_result(points[0])
|
||||
except Exception as e:
|
||||
log.exception(f"Error querying a collection '{collection_name}': {e}")
|
||||
return None
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
# Get all the items in the collection.
|
||||
points = self.client.query_points(
|
||||
points = self.client.scroll(
|
||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||
limit=NO_LIMIT, # otherwise qdrant would set limit to 10!
|
||||
)
|
||||
return self._result_to_get_result(points.points)
|
||||
return self._result_to_get_result(points[0])
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
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,
|
||||
QDRANT_COLLECTION_PREFIX,
|
||||
QDRANT_TIMEOUT,
|
||||
QDRANT_HNSW_M,
|
||||
)
|
||||
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
|
||||
TENANT_ID_FIELD = "tenant_id"
|
||||
DEFAULT_DIMENSION = 384
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def _tenant_filter(tenant_id: str) -> models.FieldCondition:
|
||||
return models.FieldCondition(
|
||||
key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id)
|
||||
)
|
||||
|
||||
|
||||
def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
|
||||
return models.FieldCondition(
|
||||
key=f"metadata.{key}", match=models.MatchValue(value=value)
|
||||
)
|
||||
|
||||
|
||||
class QdrantClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||
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
|
||||
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
|
||||
self.QDRANT_HNSW_M = QDRANT_HNSW_M
|
||||
|
||||
if not self.QDRANT_URI:
|
||||
raise ValueError(
|
||||
"QDRANT_URI is not set. Please configure it in the environment variables."
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
self.client = (
|
||||
Qclient(
|
||||
host=host,
|
||||
port=http_port,
|
||||
grpc_port=self.GRPC_PORT,
|
||||
prefer_grpc=self.PREFER_GRPC,
|
||||
api_key=self.QDRANT_API_KEY,
|
||||
timeout=self.QDRANT_TIMEOUT,
|
||||
)
|
||||
if self.PREFER_GRPC
|
||||
else Qclient(
|
||||
url=self.QDRANT_URI,
|
||||
api_key=self.QDRANT_API_KEY,
|
||||
timeout=self.QDRANT_TIMEOUT,
|
||||
)
|
||||
)
|
||||
|
||||
# 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 _create_multi_tenant_collection(
|
||||
self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
|
||||
):
|
||||
"""
|
||||
Creates a collection with multi-tenancy configuration and payload indexes for tenant_id and metadata fields.
|
||||
"""
|
||||
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,
|
||||
),
|
||||
# Disable global index building due to multitenancy
|
||||
# For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance
|
||||
hnsw_config=models.HnswConfigDiff(
|
||||
payload_m=self.QDRANT_HNSW_M,
|
||||
m=0,
|
||||
),
|
||||
)
|
||||
log.info(
|
||||
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
|
||||
)
|
||||
|
||||
self.client.create_payload_index(
|
||||
collection_name=mt_collection_name,
|
||||
field_name=TENANT_ID_FIELD,
|
||||
field_schema=models.KeywordIndexParams(
|
||||
type=models.KeywordIndexType.KEYWORD,
|
||||
is_tenant=True,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
)
|
||||
|
||||
for field in ("metadata.hash", "metadata.file_id"):
|
||||
self.client.create_payload_index(
|
||||
collection_name=mt_collection_name,
|
||||
field_name=field,
|
||||
field_schema=models.KeywordIndexParams(
|
||||
type=models.KeywordIndexType.KEYWORD,
|
||||
on_disk=self.QDRANT_ON_DISK,
|
||||
),
|
||||
)
|
||||
|
||||
def _create_points(
|
||||
self, items: List[VectorItem], tenant_id: str
|
||||
) -> List[PointStruct]:
|
||||
"""
|
||||
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_FIELD: tenant_id,
|
||||
},
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
def _ensure_collection(
|
||||
self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
|
||||
):
|
||||
"""
|
||||
Ensure the collection exists and payload indexes are created for tenant_id and metadata fields.
|
||||
"""
|
||||
if not self.client.collection_exists(collection_name=mt_collection_name):
|
||||
self._create_multi_tenant_collection(mt_collection_name, dimension)
|
||||
|
||||
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
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
return False
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
count_result = self.client.count(
|
||||
collection_name=mt_collection,
|
||||
count_filter=models.Filter(must=[tenant_filter]),
|
||||
)
|
||||
return count_result.count > 0
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Delete vectors by ID or filter from a collection with tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||
return None
|
||||
|
||||
must_conditions = [_tenant_filter(tenant_id)]
|
||||
should_conditions = []
|
||||
if ids:
|
||||
should_conditions = [_metadata_filter("id", id_value) for id_value in ids]
|
||||
elif filter:
|
||||
must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
|
||||
|
||||
return self.client.delete(
|
||||
collection_name=mt_collection,
|
||||
points_selector=models.FilterSelector(
|
||||
filter=models.Filter(must=must_conditions, should=should_conditions)
|
||||
),
|
||||
)
|
||||
|
||||
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 or not vectors:
|
||||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
|
||||
return None
|
||||
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
query_response = self.client.query_points(
|
||||
collection_name=mt_collection,
|
||||
query=vectors[0],
|
||||
limit=limit,
|
||||
query_filter=models.Filter(must=[tenant_filter]),
|
||||
)
|
||||
get_result = self._result_to_get_result(query_response.points)
|
||||
return SearchResult(
|
||||
ids=get_result.ids,
|
||||
documents=get_result.documents,
|
||||
metadatas=get_result.metadatas,
|
||||
distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
|
||||
)
|
||||
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Query points with filters and tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
|
||||
return None
|
||||
if limit is None:
|
||||
limit = NO_LIMIT
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
|
||||
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
||||
points = self.client.scroll(
|
||||
collection_name=mt_collection,
|
||||
scroll_filter=combined_filter,
|
||||
limit=limit,
|
||||
)
|
||||
return self._result_to_get_result(points[0])
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
"""
|
||||
Get all items in a collection with tenant isolation.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
||||
return None
|
||||
tenant_filter = _tenant_filter(tenant_id)
|
||||
points = self.client.scroll(
|
||||
collection_name=mt_collection,
|
||||
scroll_filter=models.Filter(must=[tenant_filter]),
|
||||
limit=NO_LIMIT,
|
||||
)
|
||||
return self._result_to_get_result(points[0])
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||
"""
|
||||
Upsert items with tenant ID.
|
||||
"""
|
||||
if not self.client or not items:
|
||||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
dimension = len(items[0]["vector"])
|
||||
self._ensure_collection(mt_collection, dimension)
|
||||
points = self._create_points(items, tenant_id)
|
||||
self.client.upload_points(mt_collection, points)
|
||||
return None
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]):
|
||||
"""
|
||||
Insert items with tenant ID.
|
||||
"""
|
||||
return self.upsert(collection_name, items)
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the database by deleting all collections.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
for collection in self.client.get_collections().collections:
|
||||
if collection.name.startswith(self.collection_prefix):
|
||||
self.client.delete_collection(collection_name=collection.name)
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
"""
|
||||
Delete a collection.
|
||||
"""
|
||||
if not self.client:
|
||||
return None
|
||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||
if not self.client.collection_exists(collection_name=mt_collection):
|
||||
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||
return None
|
||||
self.client.delete(
|
||||
collection_name=mt_collection,
|
||||
points_selector=models.FilterSelector(
|
||||
filter=models.Filter(must=[_tenant_filter(tenant_id)])
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,775 @@
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
GetResult,
|
||||
SearchResult,
|
||||
)
|
||||
from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
import logging
|
||||
import boto3
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
class S3VectorClient(VectorDBBase):
|
||||
"""
|
||||
AWS S3 Vector integration for Open WebUI Knowledge.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.bucket_name = S3_VECTOR_BUCKET_NAME
|
||||
self.region = S3_VECTOR_REGION
|
||||
|
||||
# Simple validation - log warnings instead of raising exceptions
|
||||
if not self.bucket_name:
|
||||
log.warning("S3_VECTOR_BUCKET_NAME not set - S3Vector will not work")
|
||||
if not self.region:
|
||||
log.warning("S3_VECTOR_REGION not set - S3Vector will not work")
|
||||
|
||||
if self.bucket_name and self.region:
|
||||
try:
|
||||
self.client = boto3.client("s3vectors", region_name=self.region)
|
||||
log.info(
|
||||
f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize S3Vector client: {e}")
|
||||
self.client = None
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def _create_index(
|
||||
self,
|
||||
index_name: str,
|
||||
dimension: int,
|
||||
data_type: str = "float32",
|
||||
distance_metric: str = "cosine",
|
||||
) -> None:
|
||||
"""
|
||||
Create a new index in the S3 vector bucket for the given collection if it does not exist.
|
||||
"""
|
||||
if self.has_collection(index_name):
|
||||
log.debug(f"Index '{index_name}' already exists, skipping creation")
|
||||
return
|
||||
|
||||
try:
|
||||
self.client.create_index(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=index_name,
|
||||
dataType=data_type,
|
||||
dimension=dimension,
|
||||
distanceMetric=distance_metric,
|
||||
)
|
||||
log.info(
|
||||
f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error creating S3 index '{index_name}': {e}")
|
||||
raise
|
||||
|
||||
def _filter_metadata(
|
||||
self, metadata: Dict[str, Any], item_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Filter vector metadata keys to comply with S3 Vector API limit of 10 keys maximum.
|
||||
"""
|
||||
if not isinstance(metadata, dict) or len(metadata) <= 10:
|
||||
return metadata
|
||||
|
||||
# Keep only the first 10 keys, prioritizing important ones based on actual Open WebUI metadata
|
||||
important_keys = [
|
||||
"text", # The actual document content
|
||||
"file_id", # File ID
|
||||
"source", # Document source file
|
||||
"title", # Document title
|
||||
"page", # Page number
|
||||
"total_pages", # Total pages in document
|
||||
"embedding_config", # Embedding configuration
|
||||
"created_by", # User who created it
|
||||
"name", # Document name
|
||||
"hash", # Content hash
|
||||
]
|
||||
filtered_metadata = {}
|
||||
|
||||
# First, add important keys if they exist
|
||||
for key in important_keys:
|
||||
if key in metadata:
|
||||
filtered_metadata[key] = metadata[key]
|
||||
if len(filtered_metadata) >= 10:
|
||||
break
|
||||
|
||||
# If we still have room, add other keys
|
||||
if len(filtered_metadata) < 10:
|
||||
for key, value in metadata.items():
|
||||
if key not in filtered_metadata:
|
||||
filtered_metadata[key] = value
|
||||
if len(filtered_metadata) >= 10:
|
||||
break
|
||||
|
||||
log.warning(
|
||||
f"Metadata for key '{item_id}' had {len(metadata)} keys, limited to 10 keys"
|
||||
)
|
||||
return filtered_metadata
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
"""
|
||||
Check if a vector index (collection) exists in the S3 vector bucket.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = self.client.list_indexes(vectorBucketName=self.bucket_name)
|
||||
indexes = response.get("indexes", [])
|
||||
return any(idx.get("indexName") == collection_name for idx in indexes)
|
||||
except Exception as e:
|
||||
log.error(f"Error listing indexes: {e}")
|
||||
return False
|
||||
|
||||
def delete_collection(self, collection_name: str) -> None:
|
||||
"""
|
||||
Delete an entire S3 Vector index/collection.
|
||||
"""
|
||||
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(
|
||||
f"Collection '{collection_name}' does not exist, nothing to delete"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
log.info(f"Deleting collection '{collection_name}'")
|
||||
self.client.delete_index(
|
||||
vectorBucketName=self.bucket_name, indexName=collection_name
|
||||
)
|
||||
log.info(f"Successfully deleted collection '{collection_name}'")
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting collection '{collection_name}': {e}")
|
||||
raise
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""
|
||||
Insert vector items into the S3 Vector index. Create index if it does not exist.
|
||||
"""
|
||||
if not items:
|
||||
log.warning("No items to insert")
|
||||
return
|
||||
|
||||
dimension = len(items[0]["vector"])
|
||||
|
||||
try:
|
||||
if not self.has_collection(collection_name):
|
||||
log.info(f"Index '{collection_name}' does not exist. Creating index.")
|
||||
self._create_index(
|
||||
index_name=collection_name,
|
||||
dimension=dimension,
|
||||
data_type="float32",
|
||||
distance_metric="cosine",
|
||||
)
|
||||
|
||||
# Prepare vectors for insertion
|
||||
vectors = []
|
||||
for item in items:
|
||||
# Ensure vector data is in the correct format for S3 Vector API
|
||||
vector_data = item["vector"]
|
||||
if isinstance(vector_data, list):
|
||||
# Convert list to float32 values as required by S3 Vector API
|
||||
vector_data = [float(x) for x in vector_data]
|
||||
|
||||
# Prepare metadata, ensuring the text field is preserved
|
||||
metadata = item.get("metadata", {}).copy()
|
||||
|
||||
# Add the text field to metadata so it's available for retrieval
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Convert metadata to string format for consistency
|
||||
metadata = stringify_metadata(metadata)
|
||||
|
||||
# Filter metadata to comply with S3 Vector API limit of 10 keys
|
||||
metadata = self._filter_metadata(metadata, item["id"])
|
||||
|
||||
vectors.append(
|
||||
{
|
||||
"key": item["id"],
|
||||
"data": {"float32": vector_data},
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
# Insert vectors in batches of 500 (S3 Vector API limit)
|
||||
batch_size = 500
|
||||
for i in range(0, len(vectors), batch_size):
|
||||
batch = vectors[i : i + batch_size]
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
vectors=batch,
|
||||
)
|
||||
log.info(
|
||||
f"Inserted batch {i//batch_size + 1}: {len(batch)} vectors into index '{collection_name}'."
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error inserting vectors: {e}")
|
||||
raise
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""
|
||||
Insert or update vector items in the S3 Vector index. Create index if it does not exist.
|
||||
"""
|
||||
if not items:
|
||||
log.warning("No items to upsert")
|
||||
return
|
||||
|
||||
dimension = len(items[0]["vector"])
|
||||
log.info(f"Upsert dimension: {dimension}")
|
||||
|
||||
try:
|
||||
if not self.has_collection(collection_name):
|
||||
log.info(
|
||||
f"Index '{collection_name}' does not exist. Creating index for upsert."
|
||||
)
|
||||
self._create_index(
|
||||
index_name=collection_name,
|
||||
dimension=dimension,
|
||||
data_type="float32",
|
||||
distance_metric="cosine",
|
||||
)
|
||||
|
||||
# Prepare vectors for upsert
|
||||
vectors = []
|
||||
for item in items:
|
||||
# Ensure vector data is in the correct format for S3 Vector API
|
||||
vector_data = item["vector"]
|
||||
if isinstance(vector_data, list):
|
||||
# Convert list to float32 values as required by S3 Vector API
|
||||
vector_data = [float(x) for x in vector_data]
|
||||
|
||||
# Prepare metadata, ensuring the text field is preserved
|
||||
metadata = item.get("metadata", {}).copy()
|
||||
# Add the text field to metadata so it's available for retrieval
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Convert metadata to string format for consistency
|
||||
metadata = stringify_metadata(metadata)
|
||||
|
||||
# Filter metadata to comply with S3 Vector API limit of 10 keys
|
||||
metadata = self._filter_metadata(metadata, item["id"])
|
||||
|
||||
vectors.append(
|
||||
{
|
||||
"key": item["id"],
|
||||
"data": {"float32": vector_data},
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
# Upsert vectors in batches of 500 (S3 Vector API limit)
|
||||
batch_size = 500
|
||||
for i in range(0, len(vectors), batch_size):
|
||||
batch = vectors[i : i + batch_size]
|
||||
if i == 0: # Log sample info for first batch only
|
||||
log.info(
|
||||
f"Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]['key']}, data_type={type(batch[0]['data']['float32'])}, data_len={len(batch[0]['data']['float32'])}"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
f"Upserting batch {i//batch_size + 1}: {len(batch)} vectors."
|
||||
)
|
||||
|
||||
self.client.put_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
vectors=batch,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error upserting vectors: {e}")
|
||||
raise
|
||||
|
||||
def search(
|
||||
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
"""
|
||||
Search for similar vectors in a collection using multiple query vectors.
|
||||
"""
|
||||
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(f"Collection '{collection_name}' does not exist")
|
||||
return None
|
||||
|
||||
if not vectors:
|
||||
log.warning("No query vectors provided")
|
||||
return None
|
||||
|
||||
try:
|
||||
log.info(
|
||||
f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}"
|
||||
)
|
||||
|
||||
# Initialize result lists
|
||||
all_ids = []
|
||||
all_documents = []
|
||||
all_metadatas = []
|
||||
all_distances = []
|
||||
|
||||
# Process each query vector
|
||||
for i, query_vector in enumerate(vectors):
|
||||
log.debug(f"Processing query vector {i+1}/{len(vectors)}")
|
||||
|
||||
# Prepare the query vector in S3 Vector format
|
||||
query_vector_dict = {"float32": [float(x) for x in query_vector]}
|
||||
|
||||
# Call S3 Vector query API
|
||||
response = self.client.query_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
topK=limit,
|
||||
queryVector=query_vector_dict,
|
||||
returnMetadata=True,
|
||||
returnDistance=True,
|
||||
)
|
||||
|
||||
# Process results for this query
|
||||
query_ids = []
|
||||
query_documents = []
|
||||
query_metadatas = []
|
||||
query_distances = []
|
||||
|
||||
result_vectors = response.get("vectors", [])
|
||||
|
||||
for vector in result_vectors:
|
||||
vector_id = vector.get("key")
|
||||
vector_metadata = vector.get("metadata", {})
|
||||
vector_distance = vector.get("distance", 0.0)
|
||||
|
||||
# Extract document text from metadata
|
||||
document_text = ""
|
||||
if isinstance(vector_metadata, dict):
|
||||
# Get the text field first (highest priority)
|
||||
document_text = vector_metadata.get("text")
|
||||
if not document_text:
|
||||
# Fallback to other possible text fields
|
||||
document_text = (
|
||||
vector_metadata.get("content")
|
||||
or vector_metadata.get("document")
|
||||
or vector_id
|
||||
)
|
||||
else:
|
||||
document_text = vector_id
|
||||
|
||||
query_ids.append(vector_id)
|
||||
query_documents.append(document_text)
|
||||
query_metadatas.append(vector_metadata)
|
||||
query_distances.append(vector_distance)
|
||||
|
||||
# Add this query's results to the overall results
|
||||
all_ids.append(query_ids)
|
||||
all_documents.append(query_documents)
|
||||
all_metadatas.append(query_metadatas)
|
||||
all_distances.append(query_distances)
|
||||
|
||||
log.info(f"Search completed. Found results for {len(all_ids)} queries")
|
||||
|
||||
# Return SearchResult format
|
||||
return SearchResult(
|
||||
ids=all_ids if all_ids else None,
|
||||
documents=all_documents if all_documents else None,
|
||||
metadatas=all_metadatas if all_metadatas else None,
|
||||
distances=all_distances if all_distances else None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error searching collection '{collection_name}': {str(e)}")
|
||||
# Handle specific AWS exceptions
|
||||
if hasattr(e, "response") and "Error" in e.response:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
if error_code == "NotFoundException":
|
||||
log.warning(f"Collection '{collection_name}' not found")
|
||||
return None
|
||||
elif error_code == "ValidationException":
|
||||
log.error(f"Invalid query vector dimensions or parameters")
|
||||
return None
|
||||
elif error_code == "AccessDeniedException":
|
||||
log.error(
|
||||
f"Access denied for collection '{collection_name}'. Check permissions."
|
||||
)
|
||||
return None
|
||||
raise
|
||||
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict, limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
"""
|
||||
Query vectors from a collection using metadata filter.
|
||||
"""
|
||||
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(f"Collection '{collection_name}' does not exist")
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
|
||||
if not filter:
|
||||
log.warning("No filter provided, returning all vectors")
|
||||
return self.get(collection_name)
|
||||
|
||||
try:
|
||||
log.info(f"Querying collection '{collection_name}' with filter: {filter}")
|
||||
|
||||
# For S3 Vector, we need to use list_vectors and then filter results
|
||||
# Since S3 Vector may not support complex server-side filtering,
|
||||
# we'll retrieve all vectors and filter client-side
|
||||
|
||||
# Get all vectors first
|
||||
all_vectors_result = self.get(collection_name)
|
||||
|
||||
if not all_vectors_result or not all_vectors_result.ids:
|
||||
log.warning("No vectors found in collection")
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
|
||||
# Extract the lists from the result
|
||||
all_ids = all_vectors_result.ids[0] if all_vectors_result.ids else []
|
||||
all_documents = (
|
||||
all_vectors_result.documents[0] if all_vectors_result.documents else []
|
||||
)
|
||||
all_metadatas = (
|
||||
all_vectors_result.metadatas[0] if all_vectors_result.metadatas else []
|
||||
)
|
||||
|
||||
# Apply client-side filtering
|
||||
filtered_ids = []
|
||||
filtered_documents = []
|
||||
filtered_metadatas = []
|
||||
|
||||
for i, metadata in enumerate(all_metadatas):
|
||||
if self._matches_filter(metadata, filter):
|
||||
if i < len(all_ids):
|
||||
filtered_ids.append(all_ids[i])
|
||||
if i < len(all_documents):
|
||||
filtered_documents.append(all_documents[i])
|
||||
filtered_metadatas.append(metadata)
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(filtered_ids) >= limit:
|
||||
break
|
||||
|
||||
log.info(
|
||||
f"Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total"
|
||||
)
|
||||
|
||||
# Return GetResult format
|
||||
if filtered_ids:
|
||||
return GetResult(
|
||||
ids=[filtered_ids],
|
||||
documents=[filtered_documents],
|
||||
metadatas=[filtered_metadatas],
|
||||
)
|
||||
else:
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error querying collection '{collection_name}': {str(e)}")
|
||||
# Handle specific AWS exceptions
|
||||
if hasattr(e, "response") and "Error" in e.response:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
if error_code == "NotFoundException":
|
||||
log.warning(f"Collection '{collection_name}' not found")
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
elif error_code == "AccessDeniedException":
|
||||
log.error(
|
||||
f"Access denied for collection '{collection_name}'. Check permissions."
|
||||
)
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
raise
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
"""
|
||||
Retrieve all vectors from a collection.
|
||||
"""
|
||||
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(f"Collection '{collection_name}' does not exist")
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
|
||||
try:
|
||||
log.info(f"Retrieving all vectors from collection '{collection_name}'")
|
||||
|
||||
# Initialize result lists
|
||||
all_ids = []
|
||||
all_documents = []
|
||||
all_metadatas = []
|
||||
|
||||
# Handle pagination
|
||||
next_token = None
|
||||
|
||||
while True:
|
||||
# Prepare request parameters
|
||||
request_params = {
|
||||
"vectorBucketName": self.bucket_name,
|
||||
"indexName": collection_name,
|
||||
"returnData": False, # Don't include vector data (not needed for get)
|
||||
"returnMetadata": True, # Include metadata
|
||||
"maxResults": 500, # Use reasonable page size
|
||||
}
|
||||
|
||||
if next_token:
|
||||
request_params["nextToken"] = next_token
|
||||
|
||||
# Call S3 Vector API
|
||||
response = self.client.list_vectors(**request_params)
|
||||
|
||||
# Process vectors in this page
|
||||
vectors = response.get("vectors", [])
|
||||
|
||||
for vector in vectors:
|
||||
vector_id = vector.get("key")
|
||||
vector_data = vector.get("data", {})
|
||||
vector_metadata = vector.get("metadata", {})
|
||||
|
||||
# Extract the actual vector array
|
||||
vector_array = vector_data.get("float32", [])
|
||||
|
||||
# For documents, we try to extract text from metadata or use the vector ID
|
||||
document_text = ""
|
||||
if isinstance(vector_metadata, dict):
|
||||
# Get the text field first (highest priority)
|
||||
document_text = vector_metadata.get("text")
|
||||
if not document_text:
|
||||
# Fallback to other possible text fields
|
||||
document_text = (
|
||||
vector_metadata.get("content")
|
||||
or vector_metadata.get("document")
|
||||
or vector_id
|
||||
)
|
||||
|
||||
# Log the actual content for debugging
|
||||
log.debug(
|
||||
f"Document text preview (first 200 chars): {str(document_text)[:200]}"
|
||||
)
|
||||
else:
|
||||
document_text = vector_id
|
||||
|
||||
all_ids.append(vector_id)
|
||||
all_documents.append(document_text)
|
||||
all_metadatas.append(vector_metadata)
|
||||
|
||||
# Check if there are more pages
|
||||
next_token = response.get("nextToken")
|
||||
if not next_token:
|
||||
break
|
||||
|
||||
log.info(
|
||||
f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'"
|
||||
)
|
||||
|
||||
# Return in GetResult format
|
||||
# The Open WebUI GetResult expects lists of lists, so we wrap each list
|
||||
if all_ids:
|
||||
return GetResult(
|
||||
ids=[all_ids], documents=[all_documents], metadatas=[all_metadatas]
|
||||
)
|
||||
else:
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error retrieving vectors from collection '{collection_name}': {str(e)}"
|
||||
)
|
||||
# Handle specific AWS exceptions
|
||||
if hasattr(e, "response") and "Error" in e.response:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
if error_code == "NotFoundException":
|
||||
log.warning(f"Collection '{collection_name}' not found")
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
elif error_code == "AccessDeniedException":
|
||||
log.error(
|
||||
f"Access denied for collection '{collection_name}'. Check permissions."
|
||||
)
|
||||
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
|
||||
raise
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Delete vectors by ID or filter from a collection.
|
||||
"""
|
||||
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(
|
||||
f"Collection '{collection_name}' does not exist, nothing to delete"
|
||||
)
|
||||
return
|
||||
|
||||
# Check if this is a knowledge collection (not file-specific)
|
||||
is_knowledge_collection = not collection_name.startswith("file-")
|
||||
|
||||
try:
|
||||
if ids:
|
||||
# Delete by specific vector IDs/keys
|
||||
log.info(
|
||||
f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'"
|
||||
)
|
||||
self.client.delete_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
keys=ids,
|
||||
)
|
||||
log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'")
|
||||
|
||||
elif filter:
|
||||
# Handle filter-based deletion
|
||||
log.info(
|
||||
f"Deleting vectors by filter from collection '{collection_name}': {filter}"
|
||||
)
|
||||
|
||||
# If this is a knowledge collection and we have a file_id filter,
|
||||
# also clean up the corresponding file-specific collection
|
||||
if is_knowledge_collection and "file_id" in filter:
|
||||
file_id = filter["file_id"]
|
||||
file_collection_name = f"file-{file_id}"
|
||||
if self.has_collection(file_collection_name):
|
||||
log.info(
|
||||
f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates"
|
||||
)
|
||||
self.delete_collection(file_collection_name)
|
||||
|
||||
# For the main collection, implement query-then-delete
|
||||
# First, query to get IDs matching the filter
|
||||
query_result = self.query(collection_name, filter)
|
||||
if query_result and query_result.ids and query_result.ids[0]:
|
||||
matching_ids = query_result.ids[0]
|
||||
log.info(
|
||||
f"Found {len(matching_ids)} vectors matching filter, deleting them"
|
||||
)
|
||||
|
||||
# Delete the matching vectors by ID
|
||||
self.client.delete_vectors(
|
||||
vectorBucketName=self.bucket_name,
|
||||
indexName=collection_name,
|
||||
keys=matching_ids,
|
||||
)
|
||||
log.info(
|
||||
f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter"
|
||||
)
|
||||
else:
|
||||
log.warning("No vectors found matching the filter criteria")
|
||||
else:
|
||||
log.warning("No IDs or filter provided for deletion")
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error deleting vectors from collection '{collection_name}': {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset/clear all vector data. For S3 Vector, this deletes all indexes.
|
||||
"""
|
||||
|
||||
try:
|
||||
log.warning(
|
||||
"Reset called - this will delete all vector indexes in the S3 bucket"
|
||||
)
|
||||
|
||||
# List all indexes
|
||||
response = self.client.list_indexes(vectorBucketName=self.bucket_name)
|
||||
indexes = response.get("indexes", [])
|
||||
|
||||
if not indexes:
|
||||
log.warning("No indexes found to delete")
|
||||
return
|
||||
|
||||
# Delete all indexes
|
||||
deleted_count = 0
|
||||
for index in indexes:
|
||||
index_name = index.get("indexName")
|
||||
if index_name:
|
||||
try:
|
||||
self.client.delete_index(
|
||||
vectorBucketName=self.bucket_name, indexName=index_name
|
||||
)
|
||||
deleted_count += 1
|
||||
log.info(f"Deleted index: {index_name}")
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting index '{index_name}': {e}")
|
||||
|
||||
log.info(f"Reset completed: deleted {deleted_count} indexes")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error during reset: {e}")
|
||||
raise
|
||||
|
||||
def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if metadata matches the given filter conditions.
|
||||
"""
|
||||
if not isinstance(metadata, dict) or not isinstance(filter, dict):
|
||||
return False
|
||||
|
||||
# Check each filter condition
|
||||
for key, expected_value in filter.items():
|
||||
# Handle special operators
|
||||
if key.startswith("$"):
|
||||
if key == "$and":
|
||||
# All conditions must match
|
||||
if not isinstance(expected_value, list):
|
||||
continue
|
||||
for condition in expected_value:
|
||||
if not self._matches_filter(metadata, condition):
|
||||
return False
|
||||
elif key == "$or":
|
||||
# At least one condition must match
|
||||
if not isinstance(expected_value, list):
|
||||
continue
|
||||
any_match = False
|
||||
for condition in expected_value:
|
||||
if self._matches_filter(metadata, condition):
|
||||
any_match = True
|
||||
break
|
||||
if not any_match:
|
||||
return False
|
||||
continue
|
||||
|
||||
# Get the actual value from metadata
|
||||
actual_value = metadata.get(key)
|
||||
|
||||
# Handle different types of expected values
|
||||
if isinstance(expected_value, dict):
|
||||
# Handle comparison operators
|
||||
for op, op_value in expected_value.items():
|
||||
if op == "$eq":
|
||||
if actual_value != op_value:
|
||||
return False
|
||||
elif op == "$ne":
|
||||
if actual_value == op_value:
|
||||
return False
|
||||
elif op == "$in":
|
||||
if (
|
||||
not isinstance(op_value, list)
|
||||
or actual_value not in op_value
|
||||
):
|
||||
return False
|
||||
elif op == "$nin":
|
||||
if isinstance(op_value, list) and actual_value in op_value:
|
||||
return False
|
||||
elif op == "$exists":
|
||||
if bool(op_value) != (key in metadata):
|
||||
return False
|
||||
# Add more operators as needed
|
||||
else:
|
||||
# Simple equality check
|
||||
if actual_value != expected_value:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,63 @@
|
||||
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.S3VECTOR:
|
||||
from open_webui.retrieval.vector.dbs.s3vector import S3VectorClient
|
||||
|
||||
return S3VectorClient()
|
||||
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 VectorType.ORACLE23AI:
|
||||
from open_webui.retrieval.vector.dbs.oracle23ai import Oracle23aiClient
|
||||
|
||||
return Oracle23aiClient()
|
||||
case _:
|
||||
raise ValueError(f"Unsupported vector type: {vector_type}")
|
||||
|
||||
|
||||
VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB)
|
||||
@@ -1,5 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
class VectorItem(BaseModel):
|
||||
@@ -17,3 +18,69 @@ class GetResult(BaseModel):
|
||||
|
||||
class SearchResult(GetResult):
|
||||
distances: Optional[List[List[float | int]]]
|
||||
|
||||
|
||||
class VectorDBBase(ABC):
|
||||
"""
|
||||
Abstract base class for all vector database backends.
|
||||
|
||||
Implementations of this class provide methods for collection management,
|
||||
vector insertion, deletion, similarity search, and metadata filtering.
|
||||
|
||||
Any custom vector database integration must inherit from this class and
|
||||
implement all abstract methods.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
"""Check if the collection exists in the vector DB."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_collection(self, collection_name: str) -> None:
|
||||
"""Delete a collection from the vector DB."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Insert a list of vector items into a collection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
"""Insert or update vector items in a collection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search(
|
||||
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
"""Search for similar vectors in a collection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict, limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
"""Query vectors from a collection using metadata filter."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
"""Retrieve all vectors from a collection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict] = None,
|
||||
) -> None:
|
||||
"""Delete vectors by ID or filter from a collection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reset(self) -> None:
|
||||
"""Reset the vector database by removing all collections or those matching a condition."""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class VectorType(StrEnum):
|
||||
MILVUS = "milvus"
|
||||
QDRANT = "qdrant"
|
||||
CHROMA = "chroma"
|
||||
PINECONE = "pinecone"
|
||||
ELASTICSEARCH = "elasticsearch"
|
||||
OPENSEARCH = "opensearch"
|
||||
PGVECTOR = "pgvector"
|
||||
ORACLE23AI = "oracle23ai"
|
||||
S3VECTOR = "s3vector"
|
||||
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def stringify_metadata(
|
||||
metadata: dict[str, any],
|
||||
) -> dict[str, any]:
|
||||
for key, value in metadata.items():
|
||||
if (
|
||||
isinstance(value, datetime)
|
||||
or isinstance(value, list)
|
||||
or isinstance(value, dict)
|
||||
):
|
||||
metadata[key] = str(value)
|
||||
return metadata
|
||||
@@ -36,7 +36,9 @@ def search_brave(
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["url"], title=result.get("title"), snippet=result.get("snippet")
|
||||
link=result["url"],
|
||||
title=result.get("title"),
|
||||
snippet=result.get("description"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
@@ -2,7 +2,8 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from duckduckgo_search import DDGS
|
||||
from ddgs import DDGS
|
||||
from ddgs.exceptions import RatelimitException
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -10,7 +11,10 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_duckduckgo(
|
||||
query: str, count: int, filter_list: Optional[list[str]] = None
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
concurrent_requests: Optional[int] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""
|
||||
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
|
||||
@@ -22,16 +26,18 @@ def search_duckduckgo(
|
||||
list[SearchResult]: A list of search results
|
||||
"""
|
||||
# Use the DDGS context manager to create a DDGS object
|
||||
search_results = []
|
||||
with DDGS() as ddgs:
|
||||
# Use the ddgs.text() method to perform the search
|
||||
ddgs_gen = ddgs.text(
|
||||
query, safesearch="moderate", max_results=count, backend="api"
|
||||
)
|
||||
# Check if there are search results
|
||||
if ddgs_gen:
|
||||
# Convert the search results into a list
|
||||
search_results = [r for r in ddgs_gen]
|
||||
if concurrent_requests:
|
||||
ddgs.threads = concurrent_requests
|
||||
|
||||
# Use the ddgs.text() method to perform the search
|
||||
try:
|
||||
search_results = ddgs.text(
|
||||
query, safesearch="moderate", max_results=count, backend="lite"
|
||||
)
|
||||
except RatelimitException as e:
|
||||
log.error(f"RatelimitException: {e}")
|
||||
if filter_list:
|
||||
search_results = get_filtered_results(search_results, filter_list)
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_external(
|
||||
external_url: str,
|
||||
external_api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
) -> List[SearchResult]:
|
||||
try:
|
||||
response = requests.post(
|
||||
external_url,
|
||||
headers={
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||
"Authorization": f"Bearer {external_api_key}",
|
||||
},
|
||||
json={
|
||||
"query": query,
|
||||
"count": count,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
results = response.json()
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
results = [
|
||||
SearchResult(
|
||||
link=result.get("link"),
|
||||
title=result.get("title"),
|
||||
snippet=result.get("snippet"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
log.info(f"External search results: {results}")
|
||||
return results
|
||||
except Exception as e:
|
||||
log.error(f"Error in External search: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_firecrawl(
|
||||
firecrawl_url: str,
|
||||
firecrawl_api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
) -> List[SearchResult]:
|
||||
try:
|
||||
firecrawl_search_url = urljoin(firecrawl_url, "/v1/search")
|
||||
response = requests.post(
|
||||
firecrawl_search_url,
|
||||
headers={
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||
"Authorization": f"Bearer {firecrawl_api_key}",
|
||||
},
|
||||
json={
|
||||
"query": query,
|
||||
"limit": count,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
results = response.json().get("data", [])
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
results = [
|
||||
SearchResult(
|
||||
link=result.get("url"),
|
||||
title=result.get("title"),
|
||||
snippet=result.get("description"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
log.info(f"External search results: {results}")
|
||||
return results
|
||||
except Exception as e:
|
||||
log.error(f"Error in External search: {e}")
|
||||
return []
|
||||
@@ -11,7 +11,7 @@ def get_filtered_results(results, filter_list):
|
||||
return results
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
url = result.get("url") or result.get("link", "")
|
||||
url = result.get("url") or result.get("link", "") or result.get("href", "")
|
||||
if not validators.url(url):
|
||||
continue
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_sougou(
|
||||
sougou_api_sid: str,
|
||||
sougou_api_sk: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
) -> List[SearchResult]:
|
||||
from tencentcloud.common.common_client import CommonClient
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
||||
TencentCloudSDKException,
|
||||
)
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
|
||||
try:
|
||||
cred = credential.Credential(sougou_api_sid, sougou_api_sk)
|
||||
http_profile = HttpProfile()
|
||||
http_profile.endpoint = "tms.tencentcloudapi.com"
|
||||
client_profile = ClientProfile()
|
||||
client_profile.http_profile = http_profile
|
||||
params = json.dumps({"Query": query, "Cnt": 20})
|
||||
common_client = CommonClient(
|
||||
"tms", "2020-12-29", cred, "", profile=client_profile
|
||||
)
|
||||
results = [
|
||||
json.loads(page)
|
||||
for page in common_client.call_json("SearchPro", json.loads(params))[
|
||||
"Response"
|
||||
]["Pages"]
|
||||
]
|
||||
sorted_results = sorted(
|
||||
results, key=lambda x: x.get("scour", 0.0), reverse=True
|
||||
)
|
||||
if filter_list:
|
||||
sorted_results = get_filtered_results(sorted_results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result.get("url"),
|
||||
title=result.get("title"),
|
||||
snippet=result.get("passage"),
|
||||
)
|
||||
for result in sorted_results[:count]
|
||||
]
|
||||
except TencentCloudSDKException as err:
|
||||
log.error(f"Error in Sougou search: {err}")
|
||||
return []
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -21,18 +21,25 @@ def search_tavily(
|
||||
Args:
|
||||
api_key (str): A Tavily Search API key
|
||||
query (str): The query to search for
|
||||
count (int): The maximum number of results to return
|
||||
|
||||
Returns:
|
||||
list[SearchResult]: A list of search results
|
||||
"""
|
||||
url = "https://api.tavily.com/search"
|
||||
data = {"query": query, "api_key": api_key}
|
||||
response = requests.post(url, json=data)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
data = {"query": query, "max_results": count}
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
json_response = response.json()
|
||||
|
||||
raw_search_results = json_response.get("results", [])
|
||||
results = json_response.get("results", [])
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
@@ -40,5 +47,5 @@ def search_tavily(
|
||||
title=result.get("title", ""),
|
||||
snippet=result.get("content"),
|
||||
)
|
||||
for result in raw_search_results[:count]
|
||||
for result in results
|
||||
]
|
||||
|
||||
@@ -25,18 +25,21 @@ 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_web import ExternalWebLoader
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.config import (
|
||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||
PLAYWRIGHT_WS_URI,
|
||||
PLAYWRIGHT_WS_URL,
|
||||
PLAYWRIGHT_TIMEOUT,
|
||||
RAG_WEB_LOADER_ENGINE,
|
||||
WEB_LOADER_ENGINE,
|
||||
FIRECRAWL_API_BASE_URL,
|
||||
FIRECRAWL_API_KEY,
|
||||
TAVILY_API_KEY,
|
||||
TAVILY_EXTRACT_DEPTH,
|
||||
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"])
|
||||
@@ -167,7 +170,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
||||
continue_on_failure: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
mode: Literal["crawl", "scrape", "map"] = "crawl",
|
||||
mode: Literal["crawl", "scrape", "map"] = "scrape",
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict] = None,
|
||||
):
|
||||
@@ -225,7 +228,10 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
||||
mode=self.mode,
|
||||
params=self.params,
|
||||
)
|
||||
yield from loader.lazy_load()
|
||||
for document in loader.lazy_load():
|
||||
if not document.metadata.get("source"):
|
||||
document.metadata["source"] = document.metadata.get("sourceURL")
|
||||
yield document
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f"Error loading {url}: {e}")
|
||||
@@ -245,6 +251,8 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
||||
params=self.params,
|
||||
)
|
||||
async for document in loader.alazy_load():
|
||||
if not document.metadata.get("source"):
|
||||
document.metadata["source"] = document.metadata.get("sourceURL")
|
||||
yield document
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
@@ -507,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()
|
||||
@@ -584,13 +593,6 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
return [document async for document in self.alazy_load()]
|
||||
|
||||
|
||||
RAG_WEB_LOADER_ENGINES = defaultdict(lambda: SafeWebBaseLoader)
|
||||
RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader
|
||||
RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader
|
||||
RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader
|
||||
RAG_WEB_LOADER_ENGINES["tavily"] = SafeTavilyLoader
|
||||
|
||||
|
||||
def get_web_loader(
|
||||
urls: Union[str, Sequence[str]],
|
||||
verify_ssl: bool = True,
|
||||
@@ -608,27 +610,41 @@ def get_web_loader(
|
||||
"trust_env": trust_env,
|
||||
}
|
||||
|
||||
if RAG_WEB_LOADER_ENGINE.value == "playwright":
|
||||
if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web":
|
||||
WebLoaderClass = SafeWebBaseLoader
|
||||
if WEB_LOADER_ENGINE.value == "playwright":
|
||||
WebLoaderClass = SafePlaywrightURLLoader
|
||||
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
|
||||
if PLAYWRIGHT_WS_URI.value:
|
||||
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
|
||||
if PLAYWRIGHT_WS_URL.value:
|
||||
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
|
||||
|
||||
if RAG_WEB_LOADER_ENGINE.value == "firecrawl":
|
||||
if WEB_LOADER_ENGINE.value == "firecrawl":
|
||||
WebLoaderClass = SafeFireCrawlLoader
|
||||
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
|
||||
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
|
||||
|
||||
if RAG_WEB_LOADER_ENGINE.value == "tavily":
|
||||
if WEB_LOADER_ENGINE.value == "tavily":
|
||||
WebLoaderClass = SafeTavilyLoader
|
||||
web_loader_args["api_key"] = TAVILY_API_KEY.value
|
||||
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
|
||||
|
||||
# Create the appropriate WebLoader based on the configuration
|
||||
WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value]
|
||||
web_loader = WebLoaderClass(**web_loader_args)
|
||||
if WEB_LOADER_ENGINE.value == "external":
|
||||
WebLoaderClass = ExternalWebLoader
|
||||
web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
|
||||
web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
|
||||
|
||||
log.debug(
|
||||
"Using RAG_WEB_LOADER_ENGINE %s for %s URLs",
|
||||
web_loader.__class__.__name__,
|
||||
len(safe_urls),
|
||||
)
|
||||
if WebLoaderClass:
|
||||
web_loader = WebLoaderClass(**web_loader_args)
|
||||
|
||||
return web_loader
|
||||
log.debug(
|
||||
"Using WEB_LOADER_ENGINE %s for %s URLs",
|
||||
web_loader.__class__.__name__,
|
||||
len(safe_urls),
|
||||
)
|
||||
|
||||
return web_loader
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. "
|
||||
"Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from requests.auth import HTTPDigestAuth
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_yacy(
|
||||
query_url: str,
|
||||
username: Optional[str],
|
||||
password: Optional[str],
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""
|
||||
Search a Yacy instance for a given query and return the results as a list of SearchResult objects.
|
||||
|
||||
The function accepts username and password for authenticating to Yacy.
|
||||
|
||||
Args:
|
||||
query_url (str): The base URL of the Yacy server.
|
||||
username (str): Optional YaCy username.
|
||||
password (str): Optional YaCy password.
|
||||
query (str): The search term or question to find in the Yacy database.
|
||||
count (int): The maximum number of results to retrieve from the search.
|
||||
|
||||
Returns:
|
||||
list[SearchResult]: A list of SearchResults sorted by relevance score in descending order.
|
||||
|
||||
Raise:
|
||||
requests.exceptions.RequestException: If a request error occurs during the search process.
|
||||
"""
|
||||
|
||||
# Use authentication if either username or password is set
|
||||
yacy_auth = None
|
||||
if username or password:
|
||||
yacy_auth = HTTPDigestAuth(username, password)
|
||||
|
||||
params = {
|
||||
"query": query,
|
||||
"contentdom": "text",
|
||||
"resource": "global",
|
||||
"maximumRecords": count,
|
||||
"nav": "none",
|
||||
}
|
||||
|
||||
# Check if provided a json API URL
|
||||
if not query_url.endswith("yacysearch.json"):
|
||||
# Strip all query parameters from the URL
|
||||
query_url = query_url.rstrip("/") + "/yacysearch.json"
|
||||
|
||||
log.debug(f"searching {query_url}")
|
||||
|
||||
response = requests.get(
|
||||
query_url,
|
||||
auth=yacy_auth,
|
||||
headers={
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||
"Accept": "text/html",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
params=params,
|
||||
)
|
||||
|
||||
response.raise_for_status() # Raise an exception for HTTP errors.
|
||||
|
||||
json_response = response.json()
|
||||
results = json_response.get("channels", [{}])[0].get("items", [])
|
||||
sorted_results = sorted(results, key=lambda x: x.get("ranking", 0), reverse=True)
|
||||
if filter_list:
|
||||
sorted_results = get_filtered_results(sorted_results, filter_list)
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["link"],
|
||||
title=result.get("title"),
|
||||
snippet=result.get("description"),
|
||||
)
|
||||
for result in sorted_results[:count]
|
||||
]
|
||||
@@ -7,16 +7,21 @@ 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
|
||||
import mimetypes
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
@@ -33,10 +38,12 @@ from open_webui.config import (
|
||||
WHISPER_MODEL_AUTO_UPDATE,
|
||||
WHISPER_MODEL_DIR,
|
||||
CACHE_DIR,
|
||||
WHISPER_LANGUAGE,
|
||||
)
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
ENV,
|
||||
SRC_LOG_LEVELS,
|
||||
@@ -48,8 +55,10 @@ 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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["AUDIO"])
|
||||
@@ -68,27 +77,47 @@ from pydub import AudioSegment
|
||||
from pydub.utils import mediainfo
|
||||
|
||||
|
||||
def is_mp4_audio(file_path):
|
||||
"""Check if the given file is an MP4 audio file."""
|
||||
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"
|
||||
):
|
||||
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
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f"Error getting audio format: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def convert_mp4_to_wav(file_path, output_path):
|
||||
"""Convert MP4 audio file to WAV format."""
|
||||
audio = AudioSegment.from_file(file_path, format="mp4")
|
||||
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):
|
||||
@@ -131,6 +160,7 @@ class TTSConfigForm(BaseModel):
|
||||
VOICE: str
|
||||
SPLIT_ON: str
|
||||
AZURE_SPEECH_REGION: str
|
||||
AZURE_SPEECH_BASE_URL: str
|
||||
AZURE_SPEECH_OUTPUT_FORMAT: str
|
||||
|
||||
|
||||
@@ -139,8 +169,14 @@ 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
|
||||
AZURE_REGION: str
|
||||
AZURE_LOCALES: str
|
||||
AZURE_BASE_URL: str
|
||||
AZURE_MAX_SPEAKERS: str
|
||||
|
||||
|
||||
class AudioConfigUpdateForm(BaseModel):
|
||||
@@ -160,6 +196,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": {
|
||||
@@ -167,8 +204,14 @@ 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,
|
||||
"AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION,
|
||||
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
||||
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
||||
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -185,6 +228,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
|
||||
)
|
||||
@@ -193,13 +239,26 @@ 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
|
||||
request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION
|
||||
request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES
|
||||
request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL
|
||||
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = (
|
||||
form_data.stt.AZURE_MAX_SPEAKERS
|
||||
)
|
||||
|
||||
if request.app.state.config.STT_ENGINE == "":
|
||||
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": {
|
||||
@@ -211,6 +270,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": {
|
||||
@@ -218,8 +278,14 @@ 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,
|
||||
"AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION,
|
||||
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
||||
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
||||
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -262,6 +328,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||
|
||||
r = None
|
||||
if request.app.state.config.TTS_ENGINE == "openai":
|
||||
payload["model"] = request.app.state.config.TTS_MODEL
|
||||
|
||||
@@ -270,7 +337,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
async with session.post(
|
||||
r = await session.post(
|
||||
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
||||
json=payload,
|
||||
headers={
|
||||
@@ -278,7 +345,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -287,14 +354,16 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
else {}
|
||||
),
|
||||
},
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(await r.read())
|
||||
r.raise_for_status()
|
||||
|
||||
async with aiofiles.open(file_body_path, "w") as f:
|
||||
await f.write(json.dumps(payload))
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(await r.read())
|
||||
|
||||
async with aiofiles.open(file_body_path, "w") as f:
|
||||
await f.write(json.dumps(payload))
|
||||
|
||||
return FileResponse(file_path)
|
||||
|
||||
@@ -302,18 +371,22 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
log.exception(e)
|
||||
detail = None
|
||||
|
||||
try:
|
||||
if r.status != 200:
|
||||
res = await r.json()
|
||||
status_code = 500
|
||||
detail = f"Open WebUI: Server Connection Error"
|
||||
|
||||
if r is not None:
|
||||
status_code = r.status
|
||||
|
||||
try:
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
detail = f"External: {res['error'].get('message', '')}"
|
||||
except Exception:
|
||||
detail = f"External: {e}"
|
||||
detail = f"External: {res['error']}"
|
||||
except Exception:
|
||||
detail = f"External: {e}"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=getattr(r, "status", 500),
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
status_code=status_code,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
||||
@@ -342,6 +415,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()
|
||||
|
||||
@@ -366,7 +440,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
detail = f"External: {e}"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=getattr(r, "status", 500),
|
||||
status_code=getattr(r, "status", 500) if r else 500,
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
|
||||
@@ -377,7 +451,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
|
||||
@@ -391,13 +466,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()
|
||||
|
||||
@@ -422,7 +499,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
detail = f"External: {e}"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=getattr(r, "status", 500),
|
||||
status_code=getattr(r, "status", 500) if r else 500,
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
|
||||
@@ -466,12 +543,18 @@ 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 {}
|
||||
|
||||
languages = [
|
||||
metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE,
|
||||
None, # Always fallback to None in case transcription fails
|
||||
]
|
||||
|
||||
if request.app.state.config.STT_ENGINE == "":
|
||||
if request.app.state.faster_whisper_model is None:
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||
@@ -479,7 +562,12 @@ def transcribe(request: Request, file_path):
|
||||
)
|
||||
|
||||
model = request.app.state.faster_whisper_model
|
||||
segments, info = model.transcribe(file_path, beam_size=5)
|
||||
segments, info = model.transcribe(
|
||||
file_path,
|
||||
beam_size=5,
|
||||
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
|
||||
language=languages[0],
|
||||
)
|
||||
log.info(
|
||||
"Detected language '%s' with probability %f"
|
||||
% (info.language, info.language_probability)
|
||||
@@ -496,21 +584,28 @@ def transcribe(request: Request, file_path):
|
||||
log.debug(data)
|
||||
return data
|
||||
elif request.app.state.config.STT_ENGINE == "openai":
|
||||
if is_mp4_audio(file_path):
|
||||
os.rename(file_path, file_path.replace(".wav", ".mp4"))
|
||||
# Convert MP4 audio file to WAV format
|
||||
convert_mp4_to_wav(file_path.replace(".wav", ".mp4"), file_path)
|
||||
|
||||
r = None
|
||||
try:
|
||||
r = requests.post(
|
||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
||||
},
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data={"model": request.app.state.config.STT_MODEL},
|
||||
)
|
||||
for language in languages:
|
||||
payload = {
|
||||
"model": request.app.state.config.STT_MODEL,
|
||||
}
|
||||
|
||||
if language:
|
||||
payload["language"] = language
|
||||
|
||||
r = requests.post(
|
||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
||||
},
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data=payload,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
# Successful transcription
|
||||
break
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
@@ -552,18 +647,26 @@ def transcribe(request: Request, file_path):
|
||||
"Content-Type": mime,
|
||||
}
|
||||
|
||||
# Add model if specified
|
||||
params = {}
|
||||
if request.app.state.config.STT_MODEL:
|
||||
params["model"] = request.app.state.config.STT_MODEL
|
||||
for language in languages:
|
||||
params = {}
|
||||
if request.app.state.config.STT_MODEL:
|
||||
params["model"] = request.app.state.config.STT_MODEL
|
||||
|
||||
if language:
|
||||
params["language"] = language
|
||||
|
||||
# Make request to Deepgram API
|
||||
r = requests.post(
|
||||
"https://api.deepgram.com/v1/listen?smart_format=true",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
# Successful transcription
|
||||
break
|
||||
|
||||
# Make request to Deepgram API
|
||||
r = requests.post(
|
||||
"https://api.deepgram.com/v1/listen",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
response_data = r.json()
|
||||
|
||||
@@ -598,36 +701,262 @@ def transcribe(request: Request, file_path):
|
||||
detail = f"External: {e}"
|
||||
raise Exception(detail if detail else "Open WebUI: Server Connection Error")
|
||||
|
||||
elif request.app.state.config.STT_ENGINE == "azure":
|
||||
# Check file exists and size
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=400, detail="Audio file not found")
|
||||
|
||||
# Check file size (Azure has a larger limit of 200MB)
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > AZURE_MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File size exceeds Azure's limit of {AZURE_MAX_FILE_SIZE_MB}MB",
|
||||
)
|
||||
|
||||
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
|
||||
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 or 3
|
||||
|
||||
# IF NO LOCALES, USE DEFAULTS
|
||||
if len(locales) < 2:
|
||||
locales = [
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"es-MX",
|
||||
"fr-FR",
|
||||
"hi-IN",
|
||||
"it-IT",
|
||||
"de-DE",
|
||||
"en-GB",
|
||||
"en-IN",
|
||||
"ja-JP",
|
||||
"ko-KR",
|
||||
"pt-BR",
|
||||
"zh-CN",
|
||||
]
|
||||
locales = ",".join(locales)
|
||||
|
||||
if not api_key or not region:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Azure API key is required for Azure STT",
|
||||
)
|
||||
|
||||
r = None
|
||||
try:
|
||||
# Prepare the request
|
||||
data = {
|
||||
"definition": json.dumps(
|
||||
{
|
||||
"locales": locales.split(","),
|
||||
"diarization": {"maxSpeakers": max_speakers, "enabled": True},
|
||||
}
|
||||
if locales
|
||||
else {}
|
||||
)
|
||||
}
|
||||
|
||||
url = (
|
||||
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:
|
||||
r = requests.post(
|
||||
url=url,
|
||||
files={"audio": audio_file},
|
||||
data=data,
|
||||
headers={
|
||||
"Ocp-Apim-Subscription-Key": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
response = r.json()
|
||||
|
||||
# Extract transcript from response
|
||||
if not response.get("combinedPhrases"):
|
||||
raise ValueError("No transcription found in response")
|
||||
|
||||
# Get the full transcript from combinedPhrases
|
||||
transcript = response["combinedPhrases"][0].get("text", "").strip()
|
||||
if not transcript:
|
||||
raise ValueError("Empty transcript in response")
|
||||
|
||||
data = {"text": transcript}
|
||||
|
||||
# Save transcript to json file (consistent with other providers)
|
||||
transcript_file = f"{file_dir}/{id}.json"
|
||||
with open(transcript_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
log.debug(data)
|
||||
return data
|
||||
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
log.exception("Error parsing Azure response")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to parse Azure response: {str(e)}",
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.exception(e)
|
||||
detail = None
|
||||
|
||||
try:
|
||||
if r is not None and r.status_code != 200:
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
detail = f"External: {res['error'].get('message', '')}"
|
||||
except Exception:
|
||||
detail = f"External: {e}"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=getattr(r, "status_code", 500) if r else 500,
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
stt_supported_content_types = getattr(
|
||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||
)
|
||||
|
||||
if not file.content_type.startswith(supported_filetypes):
|
||||
if not any(
|
||||
fnmatch(file.content_type, content_type)
|
||||
for content_type in (
|
||||
stt_supported_content_types
|
||||
if stt_supported_content_types
|
||||
and any(t.strip() for t in stt_supported_content_types)
|
||||
else ["audio/*", "video/webm"]
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
|
||||
@@ -648,19 +977,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)
|
||||
|
||||
@@ -769,7 +1097,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
|
||||
}
|
||||
|
||||
@@ -15,43 +15,47 @@ from open_webui.models.auths import (
|
||||
SigninResponse,
|
||||
SignupForm,
|
||||
UpdatePasswordForm,
|
||||
UpdateProfileForm,
|
||||
UserResponse,
|
||||
)
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.users import Users, UpdateProfileForm
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
from open_webui.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
|
||||
|
||||
from open_webui.utils.misc import parse_duration, validate_email_format
|
||||
from open_webui.utils.auth import (
|
||||
decode_token,
|
||||
create_api_key,
|
||||
create_token,
|
||||
get_admin_user,
|
||||
get_verified_user,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
get_http_authorization_cred,
|
||||
)
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
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()
|
||||
|
||||
@@ -68,35 +72,46 @@ class SessionUserResponse(Token, UserResponse):
|
||||
permissions: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/", response_model=SessionUserResponse)
|
||||
class SessionUserInfoResponse(SessionUserResponse):
|
||||
bio: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
|
||||
@router.get("/", response_model=SessionUserInfoResponse)
|
||||
async def get_session_user(
|
||||
request: Request, response: Response, user=Depends(get_current_user)
|
||||
):
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
auth_token = get_http_authorization_cred(auth_header)
|
||||
token = auth_token.credentials
|
||||
data = decode_token(token)
|
||||
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
if data:
|
||||
expires_at = data.get("exp")
|
||||
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else 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,
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
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
|
||||
@@ -111,6 +126,9 @@ async def get_session_user(
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"bio": user.bio,
|
||||
"gender": user.gender,
|
||||
"date_of_birth": user.date_of_birth,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
|
||||
@@ -127,7 +145,7 @@ async def update_profile(
|
||||
if session_user:
|
||||
user = Users.update_user_by_id(
|
||||
session_user.id,
|
||||
{"profile_image_url": form_data.profile_image_url, "name": form_data.name},
|
||||
form_data.model_dump(),
|
||||
)
|
||||
if user:
|
||||
return user
|
||||
@@ -177,6 +195,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
|
||||
@@ -188,14 +209,14 @@ 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,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"An error occurred on TLS: {str(e)}")
|
||||
raise HTTPException(400, detail=str(e))
|
||||
log.error(f"TLS configuration error: {str(e)}")
|
||||
raise HTTPException(400, detail="Failed to configure TLS for LDAP connection.")
|
||||
|
||||
try:
|
||||
server = Server(
|
||||
@@ -215,30 +236,115 @@ 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]
|
||||
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
|
||||
email = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
|
||||
if not email or email == "" or email == "[]":
|
||||
raise HTTPException(400, f"User {form_data.user} does not have email.")
|
||||
else:
|
||||
email = entry[
|
||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}"
|
||||
].value # retrieve the Attribute value
|
||||
if not email:
|
||||
raise HTTPException(400, "User does not have a valid email address.")
|
||||
elif isinstance(email, str):
|
||||
email = email.lower()
|
||||
elif isinstance(email, list):
|
||||
email = email[0].lower()
|
||||
else:
|
||||
email = str(email).lower()
|
||||
|
||||
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,
|
||||
@@ -248,16 +354,14 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
authentication="SIMPLE",
|
||||
)
|
||||
if not connection_user.bind():
|
||||
raise HTTPException(400, f"Authentication failed for {form_data.user}")
|
||||
raise HTTPException(400, "Authentication failed.")
|
||||
|
||||
user = Users.get_user_by_email(email)
|
||||
if not user:
|
||||
try:
|
||||
user_count = Users.get_num_users()
|
||||
|
||||
role = (
|
||||
"admin"
|
||||
if user_count == 0
|
||||
if not Users.has_users()
|
||||
else request.app.state.config.DEFAULT_USER_ROLE
|
||||
)
|
||||
|
||||
@@ -276,32 +380,64 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
|
||||
log.error(f"LDAP user creation error: {str(err)}")
|
||||
raise HTTPException(
|
||||
500, detail="Internal error occurred during LDAP user creation."
|
||||
)
|
||||
|
||||
user = Auths.authenticate_user_by_trusted_header(email)
|
||||
user = Auths.authenticate_user_by_email(email)
|
||||
|
||||
if user:
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=parse_duration(
|
||||
request.app.state.config.JWT_EXPIRES_IN
|
||||
),
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
@@ -312,12 +448,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
else:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}",
|
||||
)
|
||||
raise HTTPException(400, "User record mismatch.")
|
||||
except Exception as e:
|
||||
raise HTTPException(400, detail=str(e))
|
||||
log.error(f"LDAP authentication error: {str(e)}")
|
||||
raise HTTPException(400, detail="LDAP authentication failed.")
|
||||
|
||||
|
||||
############################
|
||||
@@ -331,21 +465,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"
|
||||
@@ -353,7 +495,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
|
||||
if Users.get_user_by_email(admin_email.lower()):
|
||||
user = Auths.authenticate_user(admin_email.lower(), admin_password)
|
||||
else:
|
||||
if Users.get_num_users() != 0:
|
||||
if Users.has_users():
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
|
||||
|
||||
await signup(
|
||||
@@ -420,6 +562,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
|
||||
|
||||
@router.post("/signup", response_model=SessionUserResponse)
|
||||
async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
has_users = Users.has_users()
|
||||
|
||||
if WEBUI_AUTH:
|
||||
if (
|
||||
@@ -430,12 +573,11 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||
)
|
||||
else:
|
||||
if Users.get_num_users() != 0:
|
||||
if has_users:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||
)
|
||||
|
||||
user_count = Users.get_num_users()
|
||||
if not validate_email_format(form_data.email.lower()):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
|
||||
@@ -445,13 +587,14 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||
|
||||
try:
|
||||
role = (
|
||||
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
|
||||
)
|
||||
role = "admin" if not has_users 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(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PASSWORD_TOO_LONG,
|
||||
)
|
||||
|
||||
hashed = get_password_hash(form_data.password)
|
||||
user = Auths.insert_new_auth(
|
||||
@@ -490,7 +633,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
post_webhook(
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
@@ -505,6 +648,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
user.id, request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
@@ -519,27 +666,39 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
except Exception as err:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
|
||||
log.error(f"Signup error: {str(err)}")
|
||||
raise HTTPException(500, detail="An internal error occurred during signup.")
|
||||
|
||||
|
||||
@router.get("/signout")
|
||||
async def signout(request: Request, response: Response):
|
||||
response.delete_cookie("token")
|
||||
response.delete_cookie("oui-session")
|
||||
|
||||
if ENABLE_OAUTH_SIGNUP.value:
|
||||
oauth_id_token = request.cookies.get("oauth_id_token")
|
||||
if oauth_id_token:
|
||||
if oauth_id_token and OPENID_PROVIDER_URL.value:
|
||||
try:
|
||||
async with ClientSession() as session:
|
||||
async with ClientSession(trust_env=True) as session:
|
||||
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
||||
if resp.status == 200:
|
||||
openid_data = await resp.json()
|
||||
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}"
|
||||
+ (
|
||||
f"&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}"
|
||||
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL
|
||||
else ""
|
||||
),
|
||||
},
|
||||
headers=response.headers,
|
||||
url=f"{logout_url}?id_token_hint={oauth_id_token}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -547,9 +706,25 @@ async def signout(request: Request, response: Response):
|
||||
detail="Failed to fetch OpenID configuration",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
log.error(f"OpenID signout error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to sign out from the OpenID provider.",
|
||||
)
|
||||
|
||||
return {"status": True}
|
||||
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": True,
|
||||
"redirect_url": WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
},
|
||||
headers=response.headers,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200, content={"status": True}, headers=response.headers
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -591,7 +766,10 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
except Exception as err:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
|
||||
log.error(f"Add user error: {str(err)}")
|
||||
raise HTTPException(
|
||||
500, detail="An internal error occurred while adding the user."
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -644,7 +822,11 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
||||
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
|
||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||
"ENABLE_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,
|
||||
}
|
||||
|
||||
|
||||
@@ -660,7 +842,11 @@ class AdminConfig(BaseModel):
|
||||
ENABLE_COMMUNITY_SHARING: bool
|
||||
ENABLE_MESSAGE_RATING: bool
|
||||
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")
|
||||
@@ -680,6 +866,7 @@ async def update_admin_config(
|
||||
)
|
||||
|
||||
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
|
||||
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
|
||||
|
||||
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
|
||||
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
|
||||
@@ -697,6 +884,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,
|
||||
@@ -704,12 +900,16 @@ async def update_admin_config(
|
||||
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
|
||||
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
|
||||
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
|
||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
|
||||
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
|
||||
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
|
||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||
"ENABLE_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,
|
||||
}
|
||||
|
||||
|
||||
@@ -725,6 +925,7 @@ class LdapServerConfig(BaseModel):
|
||||
search_filters: str = ""
|
||||
use_tls: bool = True
|
||||
certificate_path: Optional[str] = None
|
||||
validate_cert: bool = True
|
||||
ciphers: Optional[str] = "ALL"
|
||||
|
||||
|
||||
@@ -742,6 +943,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,
|
||||
}
|
||||
|
||||
@@ -764,11 +966,6 @@ async def update_ldap_server(
|
||||
if not value:
|
||||
raise HTTPException(400, detail=f"Required field {key} is empty")
|
||||
|
||||
if form_data.use_tls and not form_data.certificate_path:
|
||||
raise HTTPException(
|
||||
400, detail="TLS is enabled but certificate file path is missing"
|
||||
)
|
||||
|
||||
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
|
||||
request.app.state.config.LDAP_SERVER_HOST = form_data.host
|
||||
request.app.state.config.LDAP_SERVER_PORT = form_data.port
|
||||
@@ -782,6 +979,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 {
|
||||
@@ -796,6 +994,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,14 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ChannelModel])
|
||||
async def get_channels(user=Depends(get_verified_user)):
|
||||
return Channels.get_channels_by_user_id(user.id)
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[ChannelModel])
|
||||
async def get_all_channels(user=Depends(get_verified_user)):
|
||||
if user.role == "admin":
|
||||
return Channels.get_channels()
|
||||
else:
|
||||
return Channels.get_channels_by_user_id(user.id)
|
||||
return Channels.get_channels_by_user_id(user.id)
|
||||
|
||||
|
||||
############################
|
||||
@@ -205,7 +209,7 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
|
||||
)
|
||||
|
||||
if webhook_url:
|
||||
post_webhook(
|
||||
await post_webhook(
|
||||
name,
|
||||
webhook_url,
|
||||
f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
|
||||
@@ -430,13 +434,6 @@ async def update_message_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="read", access_control=channel.access_control
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
message = Messages.get_message_by_id(message_id)
|
||||
if not message:
|
||||
raise HTTPException(
|
||||
@@ -448,6 +445,15 @@ async def update_message_by_id(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(user.id, type="read", access_control=channel.access_control)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
try:
|
||||
message = Messages.update_message_by_id(message_id, form_data)
|
||||
message = Messages.get_message_by_id(message_id)
|
||||
@@ -637,13 +643,6 @@ async def delete_message_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="read", access_control=channel.access_control
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
message = Messages.get_message_by_id(message_id)
|
||||
if not message:
|
||||
raise HTTPException(
|
||||
@@ -655,6 +654,15 @@ async def delete_message_by_id(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(user.id, type="read", access_control=channel.access_control)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
try:
|
||||
Messages.delete_message_by_id(message_id)
|
||||
await sio.emit(
|
||||
|
||||
@@ -36,16 +36,24 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ChatTitleIdResponse])
|
||||
@router.get("/list", response_model=list[ChatTitleIdResponse])
|
||||
async def get_session_user_chat_list(
|
||||
def get_session_user_chat_list(
|
||||
user=Depends(get_verified_user), page: Optional[int] = None
|
||||
):
|
||||
if page is not None:
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
try:
|
||||
if page is not None:
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return Chats.get_chat_title_id_list_by_user_id(user.id, skip=skip, limit=limit)
|
||||
else:
|
||||
return Chats.get_chat_title_id_list_by_user_id(user.id)
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, skip=skip, limit=limit
|
||||
)
|
||||
else:
|
||||
return Chats.get_chat_title_id_list_by_user_id(user.id)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -76,17 +84,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 +219,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 +292,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
|
||||
|
||||
|
||||
############################
|
||||
@@ -564,7 +617,18 @@ async def clone_chat_by_id(
|
||||
"title": form_data.title if form_data.title else f"Clone of {chat.title}",
|
||||
}
|
||||
|
||||
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
|
||||
chat = Chats.import_chat(
|
||||
user.id,
|
||||
ChatImportForm(
|
||||
**{
|
||||
"chat": updated_chat,
|
||||
"meta": chat.meta,
|
||||
"pinned": chat.pinned,
|
||||
"folder_id": chat.folder_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -579,7 +643,12 @@ async def clone_chat_by_id(
|
||||
|
||||
@router.post("/{id}/clone/shared", response_model=Optional[ChatResponse])
|
||||
async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
|
||||
chat = Chats.get_chat_by_share_id(id)
|
||||
|
||||
if user.role == "admin":
|
||||
chat = Chats.get_chat_by_id(id)
|
||||
else:
|
||||
chat = Chats.get_chat_by_share_id(id)
|
||||
|
||||
if chat:
|
||||
updated_chat = {
|
||||
**chat.chat,
|
||||
@@ -588,7 +657,17 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
|
||||
"title": f"Clone of {chat.title}",
|
||||
}
|
||||
|
||||
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
|
||||
chat = Chats.import_chat(
|
||||
user.id,
|
||||
ChatImportForm(
|
||||
**{
|
||||
"chat": updated_chat,
|
||||
"meta": chat.meta,
|
||||
"pinned": chat.pinned,
|
||||
"folder_id": chat.folder_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -633,8 +712,19 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
|
||||
|
||||
|
||||
@router.post("/{id}/share", response_model=Optional[ChatResponse])
|
||||
async def share_chat_by_id(id: str, user=Depends(get_verified_user)):
|
||||
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||
if (user.role != "admin") and (
|
||||
not has_permission(
|
||||
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chat = Chats.get_chat_by_id_and_user_id(id, user.id)
|
||||
|
||||
if chat:
|
||||
if chat.share_id:
|
||||
shared_chat = Chats.update_shared_chat_by_chat_id(chat.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from typing import Optional
|
||||
|
||||
@@ -7,6 +7,12 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.config import get_config, save_config
|
||||
from open_webui.config import BannerModel
|
||||
|
||||
from open_webui.utils.tools import (
|
||||
get_tool_server_data,
|
||||
get_tool_server_url,
|
||||
set_tool_servers,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,35 +43,108 @@ async def export_config(user=Depends(get_admin_user)):
|
||||
|
||||
|
||||
############################
|
||||
# Direct Connections Config
|
||||
# Connections Config
|
||||
############################
|
||||
|
||||
|
||||
class DirectConnectionsConfigForm(BaseModel):
|
||||
class ConnectionsConfigForm(BaseModel):
|
||||
ENABLE_DIRECT_CONNECTIONS: bool
|
||||
ENABLE_BASE_MODELS_CACHE: bool
|
||||
|
||||
|
||||
@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
|
||||
async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
|
||||
@router.get("/connections", response_model=ConnectionsConfigForm)
|
||||
async def get_connections_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/direct_connections", response_model=DirectConnectionsConfigForm)
|
||||
async def set_direct_connections_config(
|
||||
@router.post("/connections", response_model=ConnectionsConfigForm)
|
||||
async def set_connections_config(
|
||||
request: Request,
|
||||
form_data: DirectConnectionsConfigForm,
|
||||
form_data: ConnectionsConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
|
||||
form_data.ENABLE_DIRECT_CONNECTIONS
|
||||
)
|
||||
request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
|
||||
form_data.ENABLE_BASE_MODELS_CACHE
|
||||
)
|
||||
|
||||
return {
|
||||
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# ToolServers Config
|
||||
############################
|
||||
|
||||
|
||||
class ToolServerConnection(BaseModel):
|
||||
url: str
|
||||
path: str
|
||||
auth_type: Optional[str]
|
||||
key: Optional[str]
|
||||
config: Optional[dict]
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolServersConfigForm(BaseModel):
|
||||
TOOL_SERVER_CONNECTIONS: list[ToolServerConnection]
|
||||
|
||||
|
||||
@router.get("/tool_servers", response_model=ToolServersConfigForm)
|
||||
async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tool_servers", response_model=ToolServersConfigForm)
|
||||
async def set_tool_servers_config(
|
||||
request: Request,
|
||||
form_data: ToolServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
|
||||
]
|
||||
await set_tool_servers(request)
|
||||
|
||||
return {
|
||||
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tool_servers/verify")
|
||||
async def verify_tool_servers_config(
|
||||
request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Verify the connection to the tool server.
|
||||
"""
|
||||
try:
|
||||
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to connect to the tool server: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# CodeInterpreterConfig
|
||||
############################
|
||||
|
||||
@@ -56,7 +56,7 @@ async def update_config(
|
||||
}
|
||||
|
||||
|
||||
class FeedbackUserReponse(BaseModel):
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
@@ -68,21 +68,23 @@ class FeedbackUserReponse(BaseModel):
|
||||
|
||||
|
||||
class FeedbackUserResponse(FeedbackResponse):
|
||||
user: Optional[FeedbackUserReponse] = None
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
@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=FeedbackUserReponse(
|
||||
**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")
|
||||
@@ -94,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])
|
||||
@@ -132,7 +129,10 @@ async def create_feedback(
|
||||
|
||||
@router.get("/feedback/{id}", response_model=FeedbackModel)
|
||||
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
|
||||
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
|
||||
if user.role == "admin":
|
||||
feedback = Feedbacks.get_feedback_by_id(id=id)
|
||||
else:
|
||||
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
|
||||
|
||||
if not feedback:
|
||||
raise HTTPException(
|
||||
@@ -146,9 +146,12 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
|
||||
async def update_feedback_by_id(
|
||||
id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
|
||||
):
|
||||
feedback = Feedbacks.update_feedback_by_id_and_user_id(
|
||||
id=id, user_id=user.id, form_data=form_data
|
||||
)
|
||||
if user.role == "admin":
|
||||
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
|
||||
else:
|
||||
feedback = Feedbacks.update_feedback_by_id_and_user_id(
|
||||
id=id, user_id=user.id, form_data=form_data
|
||||
)
|
||||
|
||||
if not feedback:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
import asyncio
|
||||
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
Query,
|
||||
)
|
||||
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.files import (
|
||||
FileForm,
|
||||
FileModel,
|
||||
@@ -36,7 +45,6 @@ from pydantic import BaseModel
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -77,24 +85,113 @@ def has_access_to_file(
|
||||
############################
|
||||
|
||||
|
||||
def process_uploaded_file(request, file, file_path, file_item, file_metadata, user):
|
||||
try:
|
||||
if file.content_type:
|
||||
stt_supported_content_types = getattr(
|
||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||
)
|
||||
|
||||
if any(
|
||||
fnmatch(file.content_type, content_type)
|
||||
for content_type in (
|
||||
stt_supported_content_types
|
||||
if stt_supported_content_types
|
||||
and any(t.strip() for t in stt_supported_content_types)
|
||||
else ["audio/*", "video/webm"]
|
||||
)
|
||||
):
|
||||
file_path = Storage.get_file(file_path)
|
||||
result = transcribe(request, file_path, file_metadata)
|
||||
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(
|
||||
file_id=file_item.id, content=result.get("text", "")
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
elif (not file.content_type.startswith(("image/", "video/"))) or (
|
||||
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
|
||||
):
|
||||
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
||||
else:
|
||||
log.info(
|
||||
f"File type {file.content_type} is not provided, but trying to process anyway"
|
||||
)
|
||||
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file_item.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error processing file: {file_item.id}")
|
||||
Files.update_file_data_by_id(
|
||||
file_item.id,
|
||||
{
|
||||
"status": "failed",
|
||||
"error": str(e.detail) if hasattr(e, "detail") else str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=FileModelResponse)
|
||||
def upload_file(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(get_verified_user),
|
||||
file_metadata: dict = {},
|
||||
metadata: Optional[dict | str] = Form(None),
|
||||
process: bool = Query(True),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
log.info(f"file.content_type: {file.content_type}")
|
||||
|
||||
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 process and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
|
||||
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
|
||||
]
|
||||
|
||||
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
|
||||
filename = f"{id}_{filename}"
|
||||
contents, file_path = Storage.upload_file(file.file, filename)
|
||||
contents, file_path = Storage.upload_file(
|
||||
file.file,
|
||||
filename,
|
||||
{
|
||||
"OpenWebUI-User-Email": user.email,
|
||||
"OpenWebUI-User-Id": user.id,
|
||||
"OpenWebUI-User-Name": user.name,
|
||||
"OpenWebUI-File-Id": id,
|
||||
},
|
||||
)
|
||||
|
||||
file_item = Files.insert_new_file(
|
||||
user.id,
|
||||
@@ -103,6 +200,9 @@ def upload_file(
|
||||
"id": id,
|
||||
"filename": name,
|
||||
"path": file_path,
|
||||
"data": {
|
||||
**({"status": "pending"} if process else {}),
|
||||
},
|
||||
"meta": {
|
||||
"name": name,
|
||||
"content_type": file.content_type,
|
||||
@@ -112,47 +212,32 @@ def upload_file(
|
||||
}
|
||||
),
|
||||
)
|
||||
if process:
|
||||
try:
|
||||
if file.content_type in [
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/x-m4a",
|
||||
]:
|
||||
file_path = Storage.get_file(file_path)
|
||||
result = transcribe(request, file_path)
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=id, content=result.get("text", "")),
|
||||
user=user,
|
||||
)
|
||||
elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]:
|
||||
process_file(request, ProcessFileForm(file_id=id), user=user)
|
||||
file_item = Files.get_file_by_id(id=id)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error(f"Error processing file: {file_item.id}")
|
||||
file_item = FileModelResponse(
|
||||
**{
|
||||
**file_item.model_dump(),
|
||||
"error": str(e.detail) if hasattr(e, "detail") else str(e),
|
||||
}
|
||||
)
|
||||
|
||||
if file_item:
|
||||
return file_item
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
if process:
|
||||
background_tasks.add_task(
|
||||
process_uploaded_file,
|
||||
request,
|
||||
file,
|
||||
file_path,
|
||||
file_item,
|
||||
file_metadata,
|
||||
user,
|
||||
)
|
||||
return {"status": True, **file_item.model_dump()}
|
||||
else:
|
||||
if file_item:
|
||||
return file_item
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
|
||||
|
||||
@@ -162,14 +247,62 @@ def upload_file(
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FileModelResponse])
|
||||
async def list_files(user=Depends(get_verified_user)):
|
||||
async def list_files(user=Depends(get_verified_user), content: bool = Query(True)):
|
||||
if user.role == "admin":
|
||||
files = Files.get_files()
|
||||
else:
|
||||
files = Files.get_files_by_user_id(user.id)
|
||||
|
||||
if not content:
|
||||
for file in files:
|
||||
if "content" in file.data:
|
||||
del file.data["content"]
|
||||
|
||||
return files
|
||||
|
||||
|
||||
############################
|
||||
# Search Files
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[FileModelResponse])
|
||||
async def search_files(
|
||||
filename: str = Query(
|
||||
...,
|
||||
description="Filename pattern to search for. Supports wildcards such as '*.txt'",
|
||||
),
|
||||
content: bool = Query(True),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""
|
||||
Search for files by filename with support for wildcard patterns.
|
||||
"""
|
||||
# Get files according to user role
|
||||
if user.role == "admin":
|
||||
files = Files.get_files()
|
||||
else:
|
||||
files = Files.get_files_by_user_id(user.id)
|
||||
|
||||
# Get matching files
|
||||
matching_files = [
|
||||
file for file in files if fnmatch(file.filename.lower(), filename.lower())
|
||||
]
|
||||
|
||||
if not matching_files:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No files found matching the pattern.",
|
||||
)
|
||||
|
||||
if not content:
|
||||
for file in matching_files:
|
||||
if "content" in file.data:
|
||||
del file.data["content"]
|
||||
|
||||
return matching_files
|
||||
|
||||
|
||||
############################
|
||||
# Delete All Files
|
||||
############################
|
||||
@@ -181,6 +314,7 @@ async def delete_all_files(user=Depends(get_admin_user)):
|
||||
if result:
|
||||
try:
|
||||
Storage.delete_all_files()
|
||||
VECTOR_DB_CLIENT.reset()
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error("Error deleting files")
|
||||
@@ -224,6 +358,60 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{id}/process/status")
|
||||
async def get_file_process_status(
|
||||
id: str, stream: bool = Query(False), user=Depends(get_verified_user)
|
||||
):
|
||||
file = Files.get_file_by_id(id)
|
||||
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
file.user_id == user.id
|
||||
or user.role == "admin"
|
||||
or has_access_to_file(id, "read", user)
|
||||
):
|
||||
if stream:
|
||||
MAX_FILE_PROCESSING_DURATION = 3600 * 2
|
||||
|
||||
async def event_stream(file_item):
|
||||
for _ in range(MAX_FILE_PROCESSING_DURATION):
|
||||
file_item = Files.get_file_by_id(file_item.id)
|
||||
if file_item:
|
||||
data = file_item.model_dump().get("data", {})
|
||||
status = data.get("status")
|
||||
|
||||
if status:
|
||||
event = {"status": status}
|
||||
if status == "failed":
|
||||
event["error"] = data.get("error")
|
||||
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if status in ("completed", "failed"):
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(file),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
else:
|
||||
return {"status": file.data.get("status", "pending")}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# Get File Data Content By Id
|
||||
############################
|
||||
@@ -382,6 +570,13 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
file_user = Users.get_user_by_id(file.user_id)
|
||||
if not file_user.role == "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
file.user_id == user.id
|
||||
or user.role == "admin"
|
||||
@@ -491,12 +686,12 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
|
||||
or user.role == "admin"
|
||||
or has_access_to_file(id, "write", user)
|
||||
):
|
||||
# We should add Chroma cleanup here
|
||||
|
||||
result = Files.delete_file_by_id(id)
|
||||
if result:
|
||||
try:
|
||||
Storage.delete_file(file.path)
|
||||
VECTOR_DB_CLIENT.delete(collection_name=f"file-{id}")
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error("Error deleting files")
|
||||
|
||||
@@ -49,7 +49,7 @@ async def get_folders(user=Depends(get_verified_user)):
|
||||
**folder.model_dump(),
|
||||
"items": {
|
||||
"chats": [
|
||||
{"title": chat.title, "id": chat.id}
|
||||
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
|
||||
for chat in Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder.id, user.id
|
||||
)
|
||||
@@ -78,7 +78,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
try:
|
||||
folder = Folders.insert_new_folder(user.id, form_data.name)
|
||||
folder = Folders.insert_new_folder(user.id, form_data)
|
||||
return folder
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -120,16 +120,14 @@ async def update_folder_name_by_id(
|
||||
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
folder.parent_id, user.id, form_data.name
|
||||
)
|
||||
if existing_folder:
|
||||
if existing_folder and existing_folder.id != id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||
)
|
||||
|
||||
try:
|
||||
folder = Folders.update_folder_name_by_id_and_user_id(
|
||||
id, user.id, form_data.name
|
||||
)
|
||||
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
|
||||
|
||||
return folder
|
||||
except Exception as e:
|
||||
@@ -236,7 +234,8 @@ async def delete_folder_by_id(
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
if not chat_delete_permission:
|
||||
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -245,11 +244,11 @@ async def delete_folder_by_id(
|
||||
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
|
||||
if folder:
|
||||
try:
|
||||
result = Folders.delete_folder_by_id_and_user_id(id, user.id)
|
||||
if result:
|
||||
return result
|
||||
else:
|
||||
raise Exception("Error deleting folder")
|
||||
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
|
||||
for folder_id in folder_ids:
|
||||
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error(f"Error deleting folder: {id}")
|
||||
|
||||
@@ -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,111 @@ 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(trust_env=True) 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(BaseModel):
|
||||
functions: list[FunctionModel] = []
|
||||
|
||||
|
||||
@router.post("/sync", response_model=list[FunctionModel])
|
||||
async def sync_functions(
|
||||
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
|
||||
):
|
||||
try:
|
||||
for function in form_data.functions:
|
||||
function.content = replace_imports(function.content)
|
||||
function_module, function_type, frontmatter = load_function_module_by_id(
|
||||
function.id,
|
||||
content=function.content,
|
||||
)
|
||||
|
||||
return Functions.sync_functions(user.id, form_data.functions)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to load a function: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewFunction
|
||||
############################
|
||||
@@ -262,11 +376,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 +402,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 +463,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 +485,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
|
||||
|
||||
@@ -9,6 +9,7 @@ from open_webui.models.groups import (
|
||||
GroupForm,
|
||||
GroupUpdateForm,
|
||||
GroupResponse,
|
||||
UserIdsForm,
|
||||
)
|
||||
|
||||
from open_webui.config import CACHE_DIR
|
||||
@@ -107,6 +108,56 @@ async def update_group_by_id(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# AddUserToGroupByUserIdAndGroupId
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{id}/users/add", response_model=Optional[GroupResponse])
|
||||
async def add_user_to_group(
|
||||
id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
|
||||
):
|
||||
try:
|
||||
if form_data.user_ids:
|
||||
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
|
||||
|
||||
group = Groups.add_users_to_group(id, form_data.user_ids)
|
||||
if group:
|
||||
return group
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error adding users to group"),
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Error adding users to group {id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse])
|
||||
async def remove_users_from_group(
|
||||
id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
|
||||
):
|
||||
try:
|
||||
group = Groups.remove_users_from_group(id, form_data.user_ids)
|
||||
if group:
|
||||
return group
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error removing users from group"),
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Error removing users from group {id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteGroupById
|
||||
############################
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from open_webui.config import CACHE_DIR
|
||||
@@ -302,8 +303,16 @@ async def update_image_config(
|
||||
):
|
||||
set_image_model(request, form_data.MODEL)
|
||||
|
||||
if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
|
||||
" (auto is only allowed with gpt-image-1)."
|
||||
),
|
||||
)
|
||||
|
||||
pattern = r"^\d+x\d+$"
|
||||
if re.match(pattern, form_data.IMAGE_SIZE):
|
||||
if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE):
|
||||
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -333,10 +342,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 +429,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 +437,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 +460,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 +469,9 @@ 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=file, metadata=metadata, process=False, user=user
|
||||
)
|
||||
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
|
||||
return url
|
||||
|
||||
@@ -470,7 +482,21 @@ async def image_generations(
|
||||
form_data: GenerateImageForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
|
||||
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
|
||||
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
|
||||
# image model other than gpt-image-1, which is warned about on settings save
|
||||
|
||||
size = "512x512"
|
||||
if (
|
||||
request.app.state.config.IMAGE_SIZE
|
||||
and "x" in request.app.state.config.IMAGE_SIZE
|
||||
):
|
||||
size = request.app.state.config.IMAGE_SIZE
|
||||
|
||||
if form_data.size and "x" in form_data.size:
|
||||
size = form_data.size
|
||||
|
||||
width, height = tuple(map(int, size.split("x")))
|
||||
|
||||
r = None
|
||||
try:
|
||||
@@ -482,7 +508,7 @@ async def image_generations(
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
headers["X-OpenWebUI-User-Name"] = user.name
|
||||
headers["X-OpenWebUI-User-Name"] = quote(user.name, safe=" ")
|
||||
headers["X-OpenWebUI-User-Id"] = user.id
|
||||
headers["X-OpenWebUI-User-Email"] = user.email
|
||||
headers["X-OpenWebUI-User-Role"] = user.role
|
||||
@@ -500,7 +526,11 @@ async def image_generations(
|
||||
if form_data.size
|
||||
else request.app.state.config.IMAGE_SIZE
|
||||
),
|
||||
"response_format": "b64_json",
|
||||
**(
|
||||
{}
|
||||
if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
else {"response_format": "b64_json"}
|
||||
),
|
||||
}
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
@@ -522,7 +552,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
|
||||
|
||||
@@ -556,7 +586,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
|
||||
@@ -607,9 +637,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})
|
||||
@@ -619,7 +649,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,
|
||||
@@ -660,9 +690,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})
|
||||
|
||||
@@ -9,8 +9,8 @@ from open_webui.models.knowledge import (
|
||||
KnowledgeResponse,
|
||||
KnowledgeUserResponse,
|
||||
)
|
||||
from open_webui.models.files import Files, FileModel
|
||||
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
|
||||
from open_webui.models.files import Files, FileModel, FileMetadataResponse
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.routers.retrieval import (
|
||||
process_file,
|
||||
ProcessFileForm,
|
||||
@@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.models.models import Models, ModelForm
|
||||
|
||||
|
||||
@@ -42,7 +43,7 @@ router = APIRouter()
|
||||
async def get_knowledge(user=Depends(get_verified_user)):
|
||||
knowledge_bases = []
|
||||
|
||||
if user.role == "admin":
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
else:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
|
||||
@@ -90,7 +91,7 @@ async def get_knowledge(user=Depends(get_verified_user)):
|
||||
async def get_knowledge_list(user=Depends(get_verified_user)):
|
||||
knowledge_bases = []
|
||||
|
||||
if user.role == "admin":
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
else:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
|
||||
@@ -161,13 +162,94 @@ async def create_new_knowledge(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ReindexKnowledgeFiles
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/reindex", response_model=bool)
|
||||
async def reindex_knowledge_files(request: Request, user=Depends(get_verified_user)):
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
|
||||
log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases")
|
||||
|
||||
deleted_knowledge_bases = []
|
||||
|
||||
for knowledge_base in knowledge_bases:
|
||||
# -- Robust error handling for missing or invalid data
|
||||
if not knowledge_base.data or not isinstance(knowledge_base.data, dict):
|
||||
log.warning(
|
||||
f"Knowledge base {knowledge_base.id} has no data or invalid data ({knowledge_base.data!r}). Deleting."
|
||||
)
|
||||
try:
|
||||
Knowledges.delete_knowledge_by_id(id=knowledge_base.id)
|
||||
deleted_knowledge_bases.append(knowledge_base.id)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Failed to delete invalid knowledge base {knowledge_base.id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
file_ids = knowledge_base.data.get("file_ids", [])
|
||||
files = Files.get_files_by_ids(file_ids)
|
||||
try:
|
||||
if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
|
||||
VECTOR_DB_CLIENT.delete_collection(
|
||||
collection_name=knowledge_base.id
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting collection {knowledge_base.id}: {str(e)}")
|
||||
continue # Skip, don't raise
|
||||
|
||||
failed_files = []
|
||||
for file in files:
|
||||
try:
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(
|
||||
file_id=file.id, collection_name=knowledge_base.id
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error processing file {file.filename} (ID: {file.id}): {str(e)}"
|
||||
)
|
||||
failed_files.append({"file_id": file.id, "error": str(e)})
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing knowledge base {knowledge_base.id}: {str(e)}")
|
||||
# Don't raise, just continue
|
||||
continue
|
||||
|
||||
if failed_files:
|
||||
log.warning(
|
||||
f"Failed to process {len(failed_files)} files in knowledge base {knowledge_base.id}"
|
||||
)
|
||||
for failed in failed_files:
|
||||
log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}")
|
||||
|
||||
log.info(
|
||||
f"Reindexing completed. Deleted {len(deleted_knowledge_bases)} invalid knowledge bases: {deleted_knowledge_bases}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
############################
|
||||
# GetKnowledgeById
|
||||
############################
|
||||
|
||||
|
||||
class KnowledgeFilesResponse(KnowledgeResponse):
|
||||
files: list[FileModel]
|
||||
files: list[FileMetadataResponse]
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
|
||||
@@ -183,7 +265,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
|
||||
):
|
||||
|
||||
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
|
||||
files = Files.get_files_by_ids(file_ids)
|
||||
files = Files.get_file_metadatas_by_ids(file_ids)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
@@ -311,7 +393,7 @@ def add_file_to_knowledge_by_id(
|
||||
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
|
||||
|
||||
if knowledge:
|
||||
files = Files.get_files_by_ids(file_ids)
|
||||
files = Files.get_file_metadatas_by_ids(file_ids)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
@@ -388,7 +470,7 @@ def update_file_from_knowledge_by_id(
|
||||
data = knowledge.data or {}
|
||||
file_ids = data.get("file_ids", [])
|
||||
|
||||
files = Files.get_files_by_ids(file_ids)
|
||||
files = Files.get_file_metadatas_by_ids(file_ids)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
@@ -470,7 +552,7 @@ def remove_file_from_knowledge_by_id(
|
||||
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
|
||||
|
||||
if knowledge:
|
||||
files = Files.get_files_by_ids(file_ids)
|
||||
files = Files.get_file_metadatas_by_ids(file_ids)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
@@ -666,7 +748,7 @@ def add_files_to_knowledge_batch(
|
||||
error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
files=Files.get_files_by_ids(existing_file_ids),
|
||||
files=Files.get_file_metadatas_by_ids(existing_file_ids),
|
||||
warnings={
|
||||
"message": "Some files failed to process",
|
||||
"errors": error_details,
|
||||
@@ -674,5 +756,6 @@ def add_files_to_knowledge_batch(
|
||||
)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids)
|
||||
**knowledge.model_dump(),
|
||||
files=Files.get_file_metadatas_by_ids(existing_file_ids),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +82,10 @@ class QueryMemoryForm(BaseModel):
|
||||
async def query_memory(
|
||||
request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user)
|
||||
):
|
||||
memories = Memories.get_memories_by_user_id(user.id)
|
||||
if not memories:
|
||||
raise HTTPException(status_code=404, detail="No memories found for user")
|
||||
|
||||
results = VECTOR_DB_CLIENT.search(
|
||||
collection_name=f"user-memory-{user.id}",
|
||||
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
|
||||
@@ -153,7 +157,9 @@ async def update_memory_by_id(
|
||||
form_data: MemoryUpdateModel,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
memory = Memories.update_memory_by_id(memory_id, form_data.content)
|
||||
memory = Memories.update_memory_by_id_and_user_id(
|
||||
memory_id, user.id, form_data.content
|
||||
)
|
||||
if memory is None:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ from open_webui.models.models import (
|
||||
ModelUserResponse,
|
||||
Models,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
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.utils.access_control import has_access, has_permission
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -25,7 +27,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ModelUserResponse])
|
||||
async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)):
|
||||
if user.role == "admin":
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return Models.get_models()
|
||||
else:
|
||||
return Models.get_models_by_user_id(user.id)
|
||||
@@ -78,6 +80,32 @@ async def create_new_model(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ExportModels
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/export", response_model=list[ModelModel])
|
||||
async def export_models(user=Depends(get_admin_user)):
|
||||
return Models.get_models()
|
||||
|
||||
|
||||
############################
|
||||
# SyncModels
|
||||
############################
|
||||
|
||||
|
||||
class SyncModelsForm(BaseModel):
|
||||
models: list[ModelModel] = []
|
||||
|
||||
|
||||
@router.post("/sync", response_model=list[ModelModel])
|
||||
async def sync_models(
|
||||
request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user)
|
||||
):
|
||||
return Models.sync_models(user.id, form_data.models)
|
||||
|
||||
|
||||
###########################
|
||||
# GetModelById
|
||||
###########################
|
||||
@@ -89,7 +117,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
|
||||
model = Models.get_model_by_id(id)
|
||||
if model:
|
||||
if (
|
||||
user.role == "admin"
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or model.user_id == user.id
|
||||
or has_access(user.id, "read", model.access_control)
|
||||
):
|
||||
@@ -102,7 +130,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
|
||||
|
||||
|
||||
############################
|
||||
# ToggelModelById
|
||||
# ToggleModelById
|
||||
############################
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.socket.main import sio
|
||||
|
||||
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
|
||||
|
||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
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_access, has_permission
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
############################
|
||||
# GetNotes
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=list[NoteUserResponse])
|
||||
async def get_notes(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
notes = [
|
||||
NoteUserResponse(
|
||||
**{
|
||||
**note.model_dump(),
|
||||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
||||
}
|
||||
)
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
]
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
class NoteTitleIdResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
updated_at: int
|
||||
created_at: int
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[NoteTitleIdResponse])
|
||||
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
notes = [
|
||||
NoteTitleIdResponse(**note.model_dump())
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
]
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewNote
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/create", response_model=Optional[NoteModel])
|
||||
async def create_new_note(
|
||||
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
|
||||
):
|
||||
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
note = Notes.insert_new_note(form_data, user.id)
|
||||
return note
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetNoteById
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[NoteModel])
|
||||
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = Notes.get_note_by_id(id)
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
return note
|
||||
|
||||
|
||||
############################
|
||||
# UpdateNoteById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/{id}/update", response_model=Optional[NoteModel])
|
||||
async def update_note_by_id(
|
||||
request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user)
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = Notes.get_note_by_id(id)
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
try:
|
||||
note = Notes.update_note_by_id(id, form_data)
|
||||
await sio.emit(
|
||||
"note-events",
|
||||
note.model_dump(),
|
||||
to=f"note:{note.id}",
|
||||
)
|
||||
|
||||
return note
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteNoteById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/{id}/delete", response_model=bool)
|
||||
async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = Notes.get_note_by_id(id)
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
try:
|
||||
note = Notes.delete_note_by_id(id)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
@@ -9,11 +9,16 @@ 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 urllib.parse import quote
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
from open_webui.env import (
|
||||
@@ -42,7 +47,7 @@ from open_webui.utils.misc import (
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_ollama,
|
||||
apply_model_params_to_body_openai,
|
||||
apply_model_system_prompt_to_body,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
@@ -54,6 +59,8 @@ from open_webui.config import (
|
||||
from open_webui.env import (
|
||||
ENV,
|
||||
SRC_LOG_LEVELS,
|
||||
MODELS_CACHE_TTL,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
@@ -82,7 +89,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -91,6 +98,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
else {}
|
||||
),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await response.json()
|
||||
except Exception as e:
|
||||
@@ -116,6 +124,7 @@ async def send_post_request(
|
||||
key: Optional[str] = None,
|
||||
content_type: Optional[str] = None,
|
||||
user: UserModel = None,
|
||||
metadata: Optional[dict] = None,
|
||||
):
|
||||
|
||||
r = None
|
||||
@@ -132,18 +141,39 @@ async def send_post_request(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
**(
|
||||
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
|
||||
if metadata and metadata.get("chat_id")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
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)
|
||||
|
||||
@@ -160,24 +190,20 @@ async def send_post_request(
|
||||
)
|
||||
else:
|
||||
res = await r.json()
|
||||
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",
|
||||
)
|
||||
finally:
|
||||
if not stream:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
def get_api_key(idx, url, configs):
|
||||
@@ -216,7 +242,8 @@ async def verify_connection(
|
||||
key = form_data.key
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
@@ -225,7 +252,7 @@ async def verify_connection(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -234,6 +261,7 @@ async def verify_connection(
|
||||
else {}
|
||||
),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
detail = f"HTTP Error: {r.status}"
|
||||
@@ -295,7 +323,23 @@ async def update_config(
|
||||
}
|
||||
|
||||
|
||||
@cached(ttl=1)
|
||||
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=MODELS_CACHE_TTL)
|
||||
async def get_all_models(request: Request, user: UserModel = None):
|
||||
log.info("get_all_models()")
|
||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||
@@ -335,6 +379,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", [])
|
||||
@@ -347,31 +393,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,
|
||||
@@ -379,6 +412,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["model"]: m["expires_at"]
|
||||
for m in loaded_models["models"]
|
||||
if "expires_at" in m
|
||||
}
|
||||
|
||||
for m in models["models"]:
|
||||
if m["model"] in expires_map:
|
||||
# Parse ISO8601 datetime with offset, get unix timestamp as int
|
||||
dt = datetime.fromisoformat(expires_map[m["model"]])
|
||||
m["expires_at"] = int(dt.timestamp())
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to get loaded models: {e}")
|
||||
|
||||
else:
|
||||
models = {"models": []}
|
||||
|
||||
@@ -423,7 +472,7 @@ async def get_ollama_tags(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -459,6 +508,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):
|
||||
@@ -532,34 +643,77 @@ 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),
|
||||
class ModelNameForm(BaseModel):
|
||||
model: Optional[str] = None
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/unload")
|
||||
async def unload_model(
|
||||
request: Request,
|
||||
form_data: ModelNameForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
model_name = form_data.get("model", form_data.get("name"))
|
||||
|
||||
if not model_name:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Missing name of the 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,
|
||||
)
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
|
||||
]
|
||||
responses = await asyncio.gather(*request_tasks)
|
||||
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)})
|
||||
|
||||
return dict(zip(request.app.state.config.OLLAMA_BASE_URLS, responses))
|
||||
else:
|
||||
return {}
|
||||
if len(errors) > 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to unload model on {len(errors)} nodes: {errors}",
|
||||
)
|
||||
|
||||
|
||||
class ModelNameForm(BaseModel):
|
||||
name: str
|
||||
return {"status": True}
|
||||
|
||||
|
||||
@router.post("/api/pull")
|
||||
@@ -570,11 +724,14 @@ async def pull_model(
|
||||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
log.info(f"url: {url}")
|
||||
|
||||
# Admin should be able to pull models from any source
|
||||
payload = {**form_data.model_dump(exclude_none=True), "insecure": True}
|
||||
payload = {**form_data, "insecure": True}
|
||||
|
||||
return await send_post_request(
|
||||
url=f"{url}/api/pull",
|
||||
@@ -585,7 +742,7 @@ async def pull_model(
|
||||
|
||||
|
||||
class PushModelForm(BaseModel):
|
||||
name: str
|
||||
model: str
|
||||
insecure: Optional[bool] = None
|
||||
stream: Optional[bool] = None
|
||||
|
||||
@@ -602,12 +759,12 @@ async def push_model(
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if form_data.name in models:
|
||||
url_idx = models[form_data.name]["urls"][0]
|
||||
if form_data.model in models:
|
||||
url_idx = models[form_data.model]["urls"][0]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
@@ -685,7 +842,7 @@ async def copy_model(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -726,16 +883,21 @@ async def delete_model(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
model = form_data.get("model")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if form_data.name in models:
|
||||
url_idx = models[form_data.name]["urls"][0]
|
||||
if model in models:
|
||||
url_idx = models[model]["urls"][0]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
@@ -745,13 +907,13 @@ async def delete_model(
|
||||
r = requests.request(
|
||||
method="DELETE",
|
||||
url=f"{url}/api/delete",
|
||||
data=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
data=json.dumps(form_data).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -787,16 +949,21 @@ async def delete_model(
|
||||
async def show_model_info(
|
||||
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
||||
):
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if form_data.name not in models:
|
||||
model = form_data.get("model")
|
||||
|
||||
if model not in models:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||
)
|
||||
|
||||
url_idx = random.choice(models[form_data.name]["urls"])
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
@@ -810,7 +977,7 @@ async def show_model_info(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -819,7 +986,7 @@ async def show_model_info(
|
||||
else {}
|
||||
),
|
||||
},
|
||||
data=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
data=json.dumps(form_data).encode(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -878,8 +1045,16 @@ 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)
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f"{prefix_id}.", "")
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
method="POST",
|
||||
@@ -889,7 +1064,7 @@ async def embed(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -957,8 +1132,16 @@ 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)
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f"{prefix_id}.", "")
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
method="POST",
|
||||
@@ -968,7 +1151,7 @@ async def embeddings(
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -1006,7 +1189,7 @@ class GenerateCompletionForm(BaseModel):
|
||||
prompt: str
|
||||
suffix: Optional[str] = None
|
||||
images: Optional[list[str]] = None
|
||||
format: Optional[str] = None
|
||||
format: Optional[Union[dict, str]] = None
|
||||
options: Optional[dict] = None
|
||||
system: Optional[str] = None
|
||||
template: Optional[str] = None
|
||||
@@ -1088,6 +1271,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):
|
||||
@@ -1125,7 +1311,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"]
|
||||
|
||||
@@ -1139,13 +1327,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_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
@@ -1178,7 +1363,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),
|
||||
@@ -1186,6 +1371,7 @@ async def generate_chat_completion(
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
content_type="application/x-ndjson",
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -1197,7 +1383,7 @@ class OpenAIChatMessageContent(BaseModel):
|
||||
|
||||
class OpenAIChatMessage(BaseModel):
|
||||
role: str
|
||||
content: Union[str, list[OpenAIChatMessageContent]]
|
||||
content: Union[Optional[str], list[OpenAIChatMessageContent]]
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@@ -1224,6 +1410,8 @@ async def generate_openai_completion(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
metadata = form_data.pop("metadata", None)
|
||||
|
||||
try:
|
||||
form_data = OpenAICompletionForm(**form_data)
|
||||
except Exception as e:
|
||||
@@ -1289,6 +1477,7 @@ async def generate_openai_completion(
|
||||
stream=payload.get("stream", False),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -1327,8 +1516,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_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if user.role == "user":
|
||||
@@ -1368,6 +1559,7 @@ async def generate_openai_chat_completion(
|
||||
stream=payload.get("stream", False),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -1482,7 +1674,9 @@ async def download_file_stream(
|
||||
timeout = aiohttp.ClientTimeout(total=600) # Set the timeout
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(file_url, headers=headers) as response:
|
||||
async with session.get(
|
||||
file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as response:
|
||||
total_size = int(response.headers.get("content-length", 0)) + current_size
|
||||
|
||||
with open(file_path, "ab+") as file:
|
||||
@@ -1497,7 +1691,8 @@ async def download_file_stream(
|
||||
|
||||
if done:
|
||||
file.seek(0)
|
||||
hashed = calculate_sha256(file)
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
file.seek(0)
|
||||
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
@@ -1561,7 +1756,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 ---
|
||||
@@ -1606,13 +1803,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
|
||||
|
||||
@@ -1629,7 +1826,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"
|
||||
|
||||
@@ -2,17 +2,20 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional, overload
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi import Depends, HTTPException, Request, APIRouter
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
StreamingResponse,
|
||||
JSONResponse,
|
||||
PlainTextResponse,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
@@ -21,6 +24,8 @@ from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
)
|
||||
from open_webui.env import (
|
||||
MODELS_CACHE_TTL,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
@@ -29,12 +34,12 @@ from open_webui.env import (
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import ENV, SRC_LOG_LEVELS
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_model_system_prompt_to_body,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.misc import (
|
||||
convert_logit_bias_input_to_json,
|
||||
@@ -65,7 +70,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -74,6 +79,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
else {}
|
||||
),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await response.json()
|
||||
except Exception as e:
|
||||
@@ -92,20 +98,19 @@ async def cleanup_response(
|
||||
await session.close()
|
||||
|
||||
|
||||
def openai_o1_o3_handler(payload):
|
||||
def openai_reasoning_model_handler(payload):
|
||||
"""
|
||||
Handle o1, o3 specific parameters
|
||||
Handle reasoning model specific parameters
|
||||
"""
|
||||
if "max_tokens" in payload:
|
||||
# Remove "max_tokens" from the payload
|
||||
# Convert "max_tokens" to "max_completion_tokens" for all reasoning models
|
||||
payload["max_completion_tokens"] = payload["max_tokens"]
|
||||
del payload["max_tokens"]
|
||||
|
||||
# Fix: o1 and o3 do not support the "system" role directly.
|
||||
# For older models like "o1-mini" or "o1-preview", use role "user".
|
||||
# For newer o1/o3 models, replace "system" with "developer".
|
||||
# Handle system role conversion based on model type
|
||||
if payload["messages"][0]["role"] == "system":
|
||||
model_lower = payload["model"].lower()
|
||||
# Legacy models use "user" role instead of "system"
|
||||
if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"):
|
||||
payload["messages"][0]["role"] = "user"
|
||||
else:
|
||||
@@ -224,7 +229,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": user.name,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
@@ -352,21 +357,33 @@ 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", [])
|
||||
):
|
||||
model["id"] = f"{prefix_id}.{model['id']}"
|
||||
model_list = (
|
||||
response if isinstance(response, list) else response.get("data", [])
|
||||
)
|
||||
if not isinstance(model_list, list):
|
||||
# Catch non-list responses
|
||||
model_list = []
|
||||
|
||||
if tags:
|
||||
for model in (
|
||||
response if isinstance(response, list) else response.get("data", [])
|
||||
):
|
||||
for model in model_list:
|
||||
# Remove name key if its value is None #16689
|
||||
if "name" in model and model["name"] is None:
|
||||
del model["name"]
|
||||
|
||||
if prefix_id:
|
||||
model["id"] = (
|
||||
f"{prefix_id}.{model.get('id', model.get('name', ''))}"
|
||||
)
|
||||
|
||||
if tags:
|
||||
model["tags"] = tags
|
||||
|
||||
if connection_type:
|
||||
model["connection_type"] = connection_type
|
||||
|
||||
log.debug(f"get_all_models:responses() {responses}")
|
||||
return responses
|
||||
|
||||
@@ -384,7 +401,7 @@ async def get_filtered_models(models, user):
|
||||
return filtered_models
|
||||
|
||||
|
||||
@cached(ttl=1)
|
||||
@cached(ttl=MODELS_CACHE_TTL)
|
||||
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
log.info("get_all_models()")
|
||||
|
||||
@@ -414,6 +431,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
|
||||
@@ -460,58 +478,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(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
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 {}
|
||||
),
|
||||
},
|
||||
) 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": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
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)}")
|
||||
@@ -533,6 +567,8 @@ class ConnectionVerificationForm(BaseModel):
|
||||
url: str
|
||||
key: str
|
||||
|
||||
config: Optional[dict] = None
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_connection(
|
||||
@@ -541,37 +577,76 @@ async def verify_connection(
|
||||
url = form_data.url
|
||||
key = form_data.key
|
||||
|
||||
api_config = form_data.config or {}
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
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 {}
|
||||
),
|
||||
},
|
||||
) 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": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
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:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
if r.status != 200:
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
else:
|
||||
return PlainTextResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
|
||||
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:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
if r.status != 200:
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
else:
|
||||
return PlainTextResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
# ClientError covers all aiohttp requests issues
|
||||
@@ -581,8 +656,81 @@ async def verify_connection(
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Unexpected error: {e}")
|
||||
error_detail = f"Unexpected error: {str(e)}"
|
||||
raise HTTPException(status_code=500, detail=error_detail)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Open WebUI: Server Connection Error"
|
||||
)
|
||||
|
||||
|
||||
def get_azure_allowed_params(api_version: str) -> set[str]:
|
||||
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",
|
||||
}
|
||||
|
||||
try:
|
||||
if api_version >= "2024-09-01-preview":
|
||||
allowed_params.add("stream_options")
|
||||
except ValueError:
|
||||
log.debug(
|
||||
f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters."
|
||||
)
|
||||
|
||||
return allowed_params
|
||||
|
||||
|
||||
def is_openai_reasoning_model(model: str) -> bool:
|
||||
return model.lower().startswith(("o1", "o3", "o4", "gpt-5"))
|
||||
|
||||
|
||||
def convert_to_azure_payload(url, payload: dict, api_version: str):
|
||||
model = payload.get("model", "")
|
||||
|
||||
# Filter allowed parameters based on Azure OpenAI API
|
||||
allowed_params = get_azure_allowed_params(api_version)
|
||||
|
||||
# Special handling for o-series models
|
||||
if is_openai_reasoning_model(model):
|
||||
# Convert max_tokens to max_completion_tokens for o-series models
|
||||
if "max_tokens" in payload:
|
||||
payload["max_completion_tokens"] = payload["max_tokens"]
|
||||
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")
|
||||
@@ -610,8 +758,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_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
@@ -666,10 +818,9 @@ async def generate_chat_completion(
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
|
||||
# Fix: o1,o3 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens"
|
||||
is_o1_o3 = payload["model"].lower().startswith(("o1", "o3-"))
|
||||
if is_o1_o3:
|
||||
payload = openai_o1_o3_handler(payload)
|
||||
# Check if model is a reasoning model that needs special handling
|
||||
if is_openai_reasoning_model(payload["model"]):
|
||||
payload = openai_reasoning_model_handler(payload)
|
||||
elif "api.openai.com" not in url:
|
||||
# Remove "max_completion_tokens" from the payload for backward compatibility
|
||||
if "max_completion_tokens" in payload:
|
||||
@@ -685,6 +836,43 @@ async def generate_chat_completion(
|
||||
convert_logit_bias_input_to_json(payload["logit_bias"])
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"HTTP-Referer": "https://openwebui.com/",
|
||||
"X-Title": "Open WebUI",
|
||||
}
|
||||
if "openrouter.ai" in url
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
**(
|
||||
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
|
||||
if metadata and metadata.get("chat_id")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
request_url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||
headers["api-key"] = key
|
||||
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
|
||||
@@ -699,30 +887,10 @@ 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,
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
@@ -743,27 +911,107 @@ async def generate_chat_completion(
|
||||
log.error(e)
|
||||
response = await r.text()
|
||||
|
||||
r.raise_for_status()
|
||||
if r.status >= 400:
|
||||
if isinstance(response, (dict, list)):
|
||||
return JSONResponse(status_code=r.status, content=response)
|
||||
else:
|
||||
return PlainTextResponse(status_code=r.status, content=response)
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
detail = None
|
||||
if isinstance(response, dict):
|
||||
if "error" in response:
|
||||
detail = f"{response['error']['message'] if 'message' in response['error'] else response['error']}"
|
||||
elif isinstance(response, str):
|
||||
detail = response
|
||||
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||
detail="Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not streaming and session:
|
||||
if r:
|
||||
r.close()
|
||||
await session.close()
|
||||
if not streaming:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
async def embeddings(request: Request, form_data: dict, user):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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="POST",
|
||||
url=f"{url}/embeddings",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
**(
|
||||
{
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
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:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
if r.status >= 400:
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(status_code=r.status, content=response_data)
|
||||
else:
|
||||
return PlainTextResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail="Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not streaming:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
@@ -777,33 +1025,54 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
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": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
headers["api-key"] = key
|
||||
headers["api-version"] = api_version
|
||||
|
||||
payload = json.loads(body)
|
||||
url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||
body = json.dumps(payload).encode()
|
||||
|
||||
request_url = f"{url}/{path}?api-version={api_version}"
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
request_url = f"{url}/{path}"
|
||||
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
r = await session.request(
|
||||
method=request.method,
|
||||
url=f"{url}/{path}",
|
||||
url=request_url,
|
||||
data=body,
|
||||
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 {}
|
||||
),
|
||||
},
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# Check if response is SSE
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
@@ -817,27 +1086,27 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
),
|
||||
)
|
||||
else:
|
||||
response_data = await r.json()
|
||||
try:
|
||||
response_data = await r.json()
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
if r.status >= 400:
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(status_code=r.status, content=response_data)
|
||||
else:
|
||||
return PlainTextResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
try:
|
||||
res = await r.json()
|
||||
log.error(res)
|
||||
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",
|
||||
detail="Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not streaming and session:
|
||||
if r:
|
||||
r.close()
|
||||
await session.close()
|
||||
if not streaming:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,10 +66,13 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
|
||||
if "pipeline" in model:
|
||||
sorted_filters.append(model)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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()
|
||||
@@ -115,10 +119,13 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
|
||||
if "pipeline" in model:
|
||||
sorted_filters = [model] + sorted_filters
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user