Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1486ffd0 | ||
|
|
73f7e91dec | ||
|
|
8361f73ca6 | ||
|
|
11efb982c1 | ||
|
|
9d87688ecc | ||
|
|
4f9677ffcf | ||
|
|
a49e1d87ad | ||
|
|
9a65ed2260 | ||
|
|
864d54095f | ||
|
|
b29fdc2a0c | ||
|
|
12f237ff80 | ||
|
|
192c2af7ba | ||
|
|
17bfd38696 | ||
|
|
a7e614ca4c | ||
|
|
e5c6b739c2 | ||
|
|
34169b3581 | ||
|
|
01868e856a | ||
|
|
e301d1962e | ||
|
|
9f6c91987f | ||
|
|
d19023288e | ||
|
|
29236aefe8 | ||
|
|
6ce9afd95d | ||
|
|
39f7575b64 | ||
|
|
954aaa6bdc | ||
|
|
aa589fcbd9 | ||
|
|
9f42b9369f | ||
|
|
143d3fbce2 | ||
|
|
6e531679f4 | ||
|
|
562f22960c | ||
|
|
5388cc1bc6 | ||
|
|
0a14196afb | ||
|
|
7b16637043 | ||
|
|
734c04ebf0 | ||
|
|
4f50571b53 | ||
|
|
52ccab8fc0 | ||
|
|
f5e8d4d5a0 | ||
|
|
51621ba91a | ||
|
|
dba86bc980 | ||
|
|
21f3411692 | ||
|
|
91473c788c | ||
|
|
25f0c26b25 | ||
|
|
9791c9bd8b | ||
|
|
c62609faba | ||
|
|
88decab9be | ||
|
|
d499c3aed8 | ||
|
|
277f3a91f1 | ||
|
|
1818f2b3d9 | ||
|
|
a0826ec9fe | ||
|
|
3c846617cd | ||
|
|
39645102d1 | ||
|
|
3f1d9ccbf8 | ||
|
|
781aeebd2a | ||
|
|
f589b7c189 | ||
|
|
696f356881 | ||
|
|
515f85fe1c | ||
|
|
4d74e6cefa | ||
|
|
3ebb3e2143 | ||
|
|
69b82edd63 | ||
|
|
9d39b9b42c | ||
|
|
e65d92fc6f | ||
|
|
f3c8c7045d | ||
|
|
c9185aaf44 | ||
|
|
05e79bdd0c | ||
|
|
fb6b18faef | ||
|
|
b56adf01e3 | ||
|
|
356e982d30 | ||
|
|
bb4b547574 | ||
|
|
20340c3e4e | ||
|
|
6c53bf7175 | ||
|
|
ff121413da | ||
|
|
c1d760692f | ||
|
|
a7c7993bbf | ||
|
|
0f3156651c | ||
|
|
c8071a3180 | ||
|
|
25994dd3da | ||
|
|
b9e849f17d | ||
|
|
80fbb29ccc | ||
|
|
7b1895ec8a | ||
|
|
aae2fce173 | ||
|
|
451907cc92 | ||
|
|
1b095d12ff | ||
|
|
0518749d51 | ||
|
|
fc06c16dd4 | ||
|
|
33b59adf27 | ||
|
|
70948f8803 | ||
|
|
c2634d45ad | ||
|
|
8ef482a52a | ||
|
|
dcf50c4758 | ||
|
|
742832a850 | ||
|
|
0a4358c3d1 | ||
|
|
369298a83e | ||
|
|
b99c9b277a | ||
|
|
4b6773885c | ||
|
|
d232e433e8 | ||
|
|
848f3fd4d8 | ||
|
|
453ea9b9a1 | ||
|
|
6ee50770cd | ||
|
|
15dc607779 | ||
|
|
32c888c280 | ||
|
|
99a7823e01 | ||
|
|
022f9ff3a5 | ||
|
|
ad86707605 | ||
|
|
289801b608 | ||
|
|
6bb204eb80 | ||
|
|
560702a8f7 | ||
|
|
6752772c1d | ||
|
|
d645cdbaf3 | ||
|
|
3b4d7d568b | ||
|
|
d5d0e72590 | ||
|
|
f1a7de94ba | ||
|
|
acccb9afdd | ||
|
|
f2c56fc839 | ||
|
|
dd6b808e69 | ||
|
|
7a374ca2a5 | ||
|
|
421aba7cd7 | ||
|
|
09b6ea38c5 | ||
|
|
28659f6af5 | ||
|
|
64b4d5d9c2 | ||
|
|
c7a48c50a3 | ||
|
|
b5e5617a41 | ||
|
|
ff4b1b9824 | ||
|
|
86cdcda29a | ||
|
|
5a32ea9b49 | ||
|
|
457af65df6 | ||
|
|
04b337323a | ||
|
|
384753c6ca | ||
|
|
3fe5a47050 | ||
|
|
d1bbf6ba92 | ||
|
|
9f89cc5adc | ||
|
|
fa0efae4d5 | ||
|
|
f2d6a425de | ||
|
|
d071cdf7d4 | ||
|
|
4b21704498 | ||
|
|
9fca4969db | ||
|
|
4370dee79e | ||
|
|
c631659327 | ||
|
|
4df5b7eb2e | ||
|
|
8b2015a97b | ||
|
|
477097c2e4 | ||
|
|
c5b73d7184 | ||
|
|
c7eb713689 | ||
|
|
1bfe2c92ba | ||
|
|
69722ba973 | ||
|
|
140605e660 | ||
|
|
f3547568e4 | ||
|
|
15c6860a49 | ||
|
|
363ef194d8 | ||
|
|
33a52628e6 | ||
|
|
35ab6b7667 | ||
|
|
97ba5b8436 | ||
|
|
9899293f05 | ||
|
|
3fa484f290 | ||
|
|
03dc4d7182 | ||
|
|
82a5f11b72 | ||
|
|
4847bdcc9b | ||
|
|
0fa97bde00 | ||
|
|
d5c3e9ea42 | ||
|
|
6235243b62 | ||
|
|
63ca0a3519 | ||
|
|
6a095099d5 | ||
|
|
f22d92e102 | ||
|
|
84ca2258be | ||
|
|
e6d8f89850 | ||
|
|
c0e1203538 | ||
|
|
baa1e07aec | ||
|
|
f2ee70cbfc | ||
|
|
3b5710d0cd | ||
|
|
a7ee36266a | ||
|
|
f0c7bd3f79 | ||
|
|
743199f2d0 | ||
|
|
488631db98 | ||
|
|
2328dc284e | ||
|
|
b1c1e68e56 | ||
|
|
38c6b0bff6 | ||
|
|
9c19d0abd4 | ||
|
|
b875a438f0 | ||
|
|
f0d75e3a48 | ||
|
|
0a687980ee | ||
|
|
a7b611c0e5 | ||
|
|
e567f42020 | ||
|
|
3b23b96a27 | ||
|
|
e3faec62c5 | ||
|
|
0f8729dea2 | ||
|
|
7ad549b4fb | ||
|
|
5183eaab4d | ||
|
|
b004a4a2c1 | ||
|
|
b2a6597617 | ||
|
|
286a5ad0db | ||
|
|
06f0bfd9f5 | ||
|
|
662a1fac47 | ||
|
|
cea99175ca | ||
|
|
a470f6149f | ||
|
|
fc05e0a6c5 | ||
|
|
819668b42d | ||
|
|
fe6783c166 | ||
|
|
a91663c504 | ||
|
|
1e01836c08 | ||
|
|
97f5c21485 | ||
|
|
9d14bc2a8d | ||
|
|
4857c69bd3 | ||
|
|
de889f5ec7 | ||
|
|
0259312626 | ||
|
|
5af5e1d3e4 | ||
|
|
2e1ddf823b | ||
|
|
5b1fa9dd0d | ||
|
|
fbbfa5aa79 | ||
|
|
42fa92ee64 | ||
|
|
7d3f45eff9 | ||
|
|
0eb1246c87 | ||
|
|
48d1e67e79 | ||
|
|
cd008eeb50 | ||
|
|
e29c262394 | ||
|
|
5148970ef5 | ||
|
|
a5c359aede | ||
|
|
4ad7a9bb9c | ||
|
|
66ffd77f2c | ||
|
|
46bbf760e8 | ||
|
|
103f92c8dd | ||
|
|
9b7c3ff999 | ||
|
|
ec45d77ce9 | ||
|
|
b0491886bc | ||
|
|
a4c3fa70c1 | ||
|
|
50813fcce4 | ||
|
|
99f0fe7f32 | ||
|
|
14baf6955d | ||
|
|
288947a648 | ||
|
|
682013cee3 | ||
|
|
f5809165d7 | ||
|
|
e2ef3d9647 | ||
|
|
60dbde7e19 | ||
|
|
7cf07b7e97 | ||
|
|
e6951e804a | ||
|
|
b2034861ae | ||
|
|
f9c96d03ad | ||
|
|
9bfc414d26 | ||
|
|
902c6cfbea | ||
|
|
bb3e222e09 | ||
|
|
4af7cc818e | ||
|
|
19ffa9fc19 | ||
|
|
d48f34cc5b | ||
|
|
b32f7815b8 | ||
|
|
8f2812d394 | ||
|
|
833e3c2690 | ||
|
|
598650f70b | ||
|
|
de971d7aa2 | ||
|
|
eec697e00d | ||
|
|
b4c4d9baf5 | ||
|
|
4d4c572bba | ||
|
|
b88f829dbb | ||
|
|
2b58191b82 | ||
|
|
0c18cd67d5 | ||
|
|
0dd9ad7ffc | ||
|
|
a51579a84b | ||
|
|
3ef7367f01 | ||
|
|
89b1ad649b | ||
|
|
23cadaa41a | ||
|
|
bed201e46e | ||
|
|
27b8775032 | ||
|
|
41701697ec | ||
|
|
2d8e321add | ||
|
|
6442871947 | ||
|
|
cd30152c83 | ||
|
|
557170c0b6 | ||
|
|
0c430629e5 | ||
|
|
b8728064d8 | ||
|
|
7be750bcbb | ||
|
|
d1e7957e69 | ||
|
|
f69e37a850 | ||
|
|
849278ca4f | ||
|
|
b65c728208 | ||
|
|
680cde8f9b | ||
|
|
e6c7495c1a | ||
|
|
6083960655 | ||
|
|
485896753d | ||
|
|
22e85df448 | ||
|
|
ff7a54653a | ||
|
|
4c28f19bdd | ||
|
|
17ac79920f | ||
|
|
66c5b7380d | ||
|
|
c2f889cf9a | ||
|
|
c49bcc65c4 | ||
|
|
aad23e2e53 | ||
|
|
17389e1b66 | ||
|
|
c4ecad0605 | ||
|
|
e486490451 | ||
|
|
4bb15aa425 | ||
|
|
93d0b8241c | ||
|
|
ee26c0537e | ||
|
|
c2afd0d5aa | ||
|
|
76dbbf57d2 | ||
|
|
88416161cc | ||
|
|
a1d09eae95 | ||
|
|
3d50d5ff77 | ||
|
|
5bec4a8005 | ||
|
|
af1db82c7d | ||
|
|
0c47cbd16a | ||
|
|
76acdabdc3 | ||
|
|
90f76d24ec | ||
|
|
31dc97b68b | ||
|
|
7031bb9067 | ||
|
|
f89c170566 | ||
|
|
7e03637446 | ||
|
|
bbd48b3638 | ||
|
|
7762fa5ddf | ||
|
|
720af637e6 | ||
|
|
4386e5abb8 | ||
|
|
07ef295a77 | ||
|
|
42071cb8e8 | ||
|
|
d98b945d73 | ||
|
|
3f97a6993f | ||
|
|
ccd80b9dba | ||
|
|
baffa89f35 | ||
|
|
2af4c4b3c7 | ||
|
|
02238d3113 | ||
|
|
6cdb13d5cb | ||
|
|
63ebc295ce | ||
|
|
34684e7e58 | ||
|
|
ed6aabfbfd | ||
|
|
73734b186b | ||
|
|
b4bc71d1bd | ||
|
|
0ed174f6a1 | ||
|
|
58cff5e482 | ||
|
|
0a72d047ef | ||
|
|
bc576782d7 | ||
|
|
f05e945a45 | ||
|
|
e76f72576e | ||
|
|
f138be9d8a | ||
|
|
3c381fad13 | ||
|
|
03cc6ce8eb | ||
|
|
7c2bed2c73 | ||
|
|
b1565e6913 | ||
|
|
ee10f372a0 | ||
|
|
21c0dd93e2 | ||
|
|
a79a39bb64 | ||
|
|
31fb34918f | ||
|
|
80388855f4 | ||
|
|
8f48e96f5e | ||
|
|
9370b263f5 | ||
|
|
1b0bce529f | ||
|
|
bc2192e8bf | ||
|
|
4d41013804 | ||
|
|
8f3bd2ecbe | ||
|
|
757caeab55 | ||
|
|
9df8d5b204 | ||
|
|
b160eef7eb | ||
|
|
e2ff2ae252 | ||
|
|
a4b2dc22c4 | ||
|
|
5d4134ba77 | ||
|
|
64747f7f79 | ||
|
|
117a33b030 | ||
|
|
7e05cf4e21 | ||
|
|
2bec5c5a5c | ||
|
|
c43f95f4b8 | ||
|
|
6d9a562edd | ||
|
|
6b638db114 | ||
|
|
2de854fa02 | ||
|
|
38f45a38cb | ||
|
|
84912904fd | ||
|
|
da42850eff | ||
|
|
0bf686396d | ||
|
|
f1c317349e | ||
|
|
3207998114 | ||
|
|
ad17d35ac4 | ||
|
|
8dde493e8e | ||
|
|
6eea0d40ab | ||
|
|
ce19b7120b | ||
|
|
4eecbf8ee4 | ||
|
|
cd99df870d | ||
|
|
ed3744b672 | ||
|
|
4673e120c4 | ||
|
|
8e41fea2d9 | ||
|
|
8bff76f745 | ||
|
|
5aa2d01c17 | ||
|
|
dc6e1fe6bd | ||
|
|
ce23843506 | ||
|
|
b2667470cd | ||
|
|
62073d3b7f | ||
|
|
bc739de024 | ||
|
|
e76f77bcb7 | ||
|
|
6cb41a59da | ||
|
|
413fa27b18 | ||
|
|
27df461abd | ||
|
|
a65cc196a5 | ||
|
|
284764e178 | ||
|
|
908f504885 | ||
|
|
e69c2cf3f6 | ||
|
|
25c7f101f2 | ||
|
|
9578bac099 | ||
|
|
79fd61cd5f | ||
|
|
c13a444326 | ||
|
|
3621e2d56c | ||
|
|
dec59e87a3 | ||
|
|
bebba7424e | ||
|
|
2e5668e25c | ||
|
|
e72a7e4eca | ||
|
|
c260efa2f4 | ||
|
|
7afa83e880 | ||
|
|
8114ddc5f9 | ||
|
|
684324ae9e | ||
|
|
3d43797361 | ||
|
|
ce1079d358 | ||
|
|
89c0e150c8 | ||
|
|
1097838b35 | ||
|
|
9a8646157e | ||
|
|
f052b2801a | ||
|
|
e0d5de1697 | ||
|
|
49d57ae82b | ||
|
|
918e720f97 | ||
|
|
261a55b275 | ||
|
|
0d0a37c884 | ||
|
|
a32a3dfee4 | ||
|
|
9b3ecb703a | ||
|
|
e239e17050 | ||
|
|
c2c02846a8 | ||
|
|
4754108253 | ||
|
|
96b98cd13c | ||
|
|
639d26252e | ||
|
|
7faf19dad9 | ||
|
|
c38f878e1e | ||
|
|
67c4ea1e57 | ||
|
|
e85c7f7931 | ||
|
|
d5fe0f6067 | ||
|
|
9c0bd0c0ed | ||
|
|
651f855289 | ||
|
|
dc3d704800 | ||
|
|
8da4e5bb19 | ||
|
|
cabbdd719b | ||
|
|
4e18c8a689 | ||
|
|
224e4c3a61 | ||
|
|
d3f40c5a56 | ||
|
|
7f4b45e7e8 | ||
|
|
63e8ab7a05 | ||
|
|
74db2b9f36 | ||
|
|
e4e2f8352c | ||
|
|
5f3f5170b7 | ||
|
|
aed2d4a8ee | ||
|
|
1cc3493dc8 | ||
|
|
3b944072e3 | ||
|
|
e2b9942648 | ||
|
|
f5c7152a6b | ||
|
|
1aa285edb9 | ||
|
|
4daf81fba2 | ||
|
|
bdd198e946 | ||
|
|
415b93c7c3 | ||
|
|
00520a9602 | ||
|
|
6c583ef9d3 | ||
|
|
cdf90222c7 | ||
|
|
3dc20a25b1 | ||
|
|
5c1ba23026 | ||
|
|
72f8539fd2 | ||
|
|
8d34fcb586 | ||
|
|
72900cd686 | ||
|
|
314cac0113 | ||
|
|
cde4b93fa6 | ||
|
|
89e59d0103 | ||
|
|
2a98ba0ff8 | ||
|
|
75efa4f931 | ||
|
|
3561c7eedd | ||
|
|
a4fd26b478 | ||
|
|
03f207b803 | ||
|
|
414ab51d5d | ||
|
|
8e5690aab4 | ||
|
|
939521b75d | ||
|
|
a8fe979cf8 | ||
|
|
bafeb76c41 | ||
|
|
ec21577f1a | ||
|
|
244809bab7 | ||
|
|
71b86c08ee | ||
|
|
989f192c92 | ||
|
|
08bc00ea77 | ||
|
|
1447102331 | ||
|
|
67aa1b028d | ||
|
|
024f78d3e0 | ||
|
|
d14329b285 | ||
|
|
6681ff5cbd | ||
|
|
478163eb3b | ||
|
|
9e8e004929 | ||
|
|
a9b4774bde | ||
|
|
d98c539d89 | ||
|
|
20af60be42 | ||
|
|
00eacfcacc | ||
|
|
cf6a476998 | ||
|
|
fdf7ca15ea | ||
|
|
ee61970fb0 | ||
|
|
43f817a75e | ||
|
|
fdc0f04a36 | ||
|
|
7fc4c56ea3 | ||
|
|
da282ce5c7 | ||
|
|
c787070dc9 | ||
|
|
24aeec9120 | ||
|
|
15cc9b6cee | ||
|
|
9f837267b6 | ||
|
|
f18f1db704 | ||
|
|
1d98a45b33 | ||
|
|
16af088f4e | ||
|
|
8feed02d40 | ||
|
|
3bcf9a442a | ||
|
|
4a0359789f | ||
|
|
292be82754 | ||
|
|
a0068c4a17 | ||
|
|
61a2909a88 | ||
|
|
48b538f312 | ||
|
|
d1c9555a0b | ||
|
|
f524a6a8e7 | ||
|
|
e986488ab5 | ||
|
|
81c530a9c4 | ||
|
|
76bde402fe | ||
|
|
a776dbd01d | ||
|
|
b9bbf22581 | ||
|
|
cbcab062eb | ||
|
|
c8b2313362 | ||
|
|
92aafd6c06 | ||
|
|
bfadbc9934 | ||
|
|
f0834e397c | ||
|
|
182e4138bf | ||
|
|
b72443004d | ||
|
|
5e17882488 | ||
|
|
b2ed5be457 | ||
|
|
f47214314b | ||
|
|
46a3f7de5e | ||
|
|
a996497bf1 | ||
|
|
5338edd644 | ||
|
|
c573321305 | ||
|
|
20cd9e9461 | ||
|
|
69e1ba8234 | ||
|
|
a70bc52c34 | ||
|
|
54c05ac6e0 | ||
|
|
ed6449d35f | ||
|
|
82c08a3b5d | ||
|
|
8197844ff7 | ||
|
|
0c4a1ac54d | ||
|
|
c9465da8f2 | ||
|
|
2f6a050325 | ||
|
|
235ed8956c | ||
|
|
7a3f4d85f6 | ||
|
|
d11d49a08a | ||
|
|
a4d0bd1073 | ||
|
|
f83dc60666 | ||
|
|
82c45b721e | ||
|
|
f06e2c1a4a | ||
|
|
46a8e4acad | ||
|
|
06e280d831 | ||
|
|
1f287be27f | ||
|
|
5444210163 | ||
|
|
d28559c49c | ||
|
|
c7df4c5082 | ||
|
|
c0fc37d112 | ||
|
|
79b0c3af47 | ||
|
|
6471945076 | ||
|
|
17e80ecd81 | ||
|
|
7222a9aef6 | ||
|
|
006a2d6bb3 | ||
|
|
c165a6b6c2 | ||
|
|
35504e8486 | ||
|
|
267794638c | ||
|
|
e0e50f7380 | ||
|
|
e171c7915a | ||
|
|
8f060ee2fa | ||
|
|
02a2683eb0 | ||
|
|
6eecade06e | ||
|
|
546a334328 | ||
|
|
8f5eb03a40 | ||
|
|
710f6eec12 | ||
|
|
92f359fb9b | ||
|
|
fe192eb738 | ||
|
|
23ea754061 | ||
|
|
3c7e739b3c | ||
|
|
6593b7ccc8 | ||
|
|
eb9c4c0e35 | ||
|
|
9942de8011 | ||
|
|
0a78ceef6d | ||
|
|
b68a5f330d | ||
|
|
b9f0d239b0 | ||
|
|
a708cdf55e | ||
|
|
bc9067d5aa | ||
|
|
9c9085adfa | ||
|
|
8cbc472f7f | ||
|
|
1be9187236 | ||
|
|
098e64b35d | ||
|
|
c469369c35 | ||
|
|
da89e36abe | ||
|
|
b754aad987 | ||
|
|
e225435c8e | ||
|
|
e361606c61 | ||
|
|
e24fec0de4 | ||
|
|
1f89eacb69 | ||
|
|
892ddf9eac | ||
|
|
2b0b87c0f9 | ||
|
|
f5899e875c | ||
|
|
43eac35b5b | ||
|
|
9aea08ccb2 | ||
|
|
3e003a5f17 | ||
|
|
acac6d5973 | ||
|
|
2a8b0b2581 | ||
|
|
a2b3abfa14 | ||
|
|
32996a16cc | ||
|
|
3af6192495 | ||
|
|
7a83e7dfa3 | ||
|
|
3984184a82 | ||
|
|
ca5bafcd2f | ||
|
|
ca332db7eb | ||
|
|
680361a88c | ||
|
|
375d29bab4 | ||
|
|
9784eb4c0b | ||
|
|
0031fb8274 | ||
|
|
8af6a4cf21 | ||
|
|
4b74034967 | ||
|
|
9762ef3ef6 | ||
|
|
691012782a | ||
|
|
f503ba499c | ||
|
|
d6915c0f40 | ||
|
|
f39f29c38f | ||
|
|
6a0300fdb7 | ||
|
|
7d29991fa5 | ||
|
|
ecbf74dbea | ||
|
|
d49fb9c010 | ||
|
|
c107a3799f | ||
|
|
40c450e6e5 | ||
|
|
6cb58af3db | ||
|
|
25087e09e6 | ||
|
|
d347497609 | ||
|
|
ab07bab140 | ||
|
|
f97e73962a | ||
|
|
c0a91b566b | ||
|
|
850ca01ca2 | ||
|
|
a483d41de2 | ||
|
|
185e01eecc | ||
|
|
7c393bc166 | ||
|
|
f170f37ba4 | ||
|
|
27e85e448b | ||
|
|
eff40229fe | ||
|
|
9ae06a3cac | ||
|
|
2783e0eb89 | ||
|
|
efed0e3f63 | ||
|
|
fd0e9652a8 | ||
|
|
d0da1d722c | ||
|
|
b48c790a76 | ||
|
|
417c9d923a | ||
|
|
e8c1dbb2da | ||
|
|
2bd972305a | ||
|
|
9b79486316 | ||
|
|
fbeff475cf | ||
|
|
0dc04fd8b9 | ||
|
|
288b323df8 | ||
|
|
29d4148fe9 | ||
|
|
62e57fe32c | ||
|
|
9dc7889493 | ||
|
|
aa5de4c05b | ||
|
|
c46ea40f70 | ||
|
|
ffe127e0d6 | ||
|
|
bcb7b65553 | ||
|
|
dd51808a6f | ||
|
|
94806555bf | ||
|
|
7a68f065fa | ||
|
|
ad61460faf | ||
|
|
91a43848a1 | ||
|
|
2a34a0ae10 | ||
|
|
fcc2bb5a05 | ||
|
|
7031708c87 | ||
|
|
8039cc40f7 | ||
|
|
515e136502 | ||
|
|
5fe56a862b | ||
|
|
9971919ca1 | ||
|
|
f0689b260a | ||
|
|
7ba752e2aa | ||
|
|
2b3b2e4aa6 | ||
|
|
4b160d88a2 | ||
|
|
a730a277b9 | ||
|
|
40e9d9c330 | ||
|
|
f373784aaf | ||
|
|
43d64b3bf8 | ||
|
|
f0dfdb30c3 | ||
|
|
cf76922a8f | ||
|
|
e41836f8bd | ||
|
|
0417a456c3 | ||
|
|
2f8685d0b7 | ||
|
|
3fc29b292c | ||
|
|
e7476350e3 | ||
|
|
31f6520ba9 | ||
|
|
1e6426ed4f | ||
|
|
6e78f855a3 | ||
|
|
ffad1f1dd1 | ||
|
|
5064506de4 | ||
|
|
e14430daa8 | ||
|
|
8c84501235 | ||
|
|
74576b88f6 | ||
|
|
7b3bd58735 | ||
|
|
95e3717f96 | ||
|
|
a9c4e4b422 | ||
|
|
d13a35ab96 | ||
|
|
43a2881074 | ||
|
|
a65b78fc61 | ||
|
|
4e763e8aa8 | ||
|
|
f9ff780422 | ||
|
|
b93e9b1698 | ||
|
|
de722390fc | ||
|
|
b37eb3dd5f | ||
|
|
f1113d2758 | ||
|
|
8ac30955a3 | ||
|
|
c4832fdb70 | ||
|
|
cce20f8bfc | ||
|
|
ccfb0f54e1 | ||
|
|
f1b50fb83a | ||
|
|
8ca4596918 | ||
|
|
5043e7fc8c | ||
|
+7 |
fbfbc29789 | ||
|
|
86cb1058c3 | ||
|
|
40f7e17b3e | ||
|
|
bc29e75116 | ||
|
|
17ad6f8aad | ||
|
|
3f030ef537 | ||
|
|
62456a606e | ||
|
|
3512904e00 | ||
|
|
38a39b5892 | ||
|
|
0127ac39eb | ||
|
+6 |
bc0da24e9c | ||
|
|
46ae3f4f5d | ||
|
|
19ba6c06dd | ||
|
+6 |
8d7d79d54b | ||
|
|
c1e86ad0a6 | ||
|
|
dbbdad3ebd | ||
|
|
73f38dce88 | ||
|
|
d11fba2dfd | ||
|
|
911a114ad4 | ||
|
|
216fb5c3db | ||
|
|
18d74f2b10 | ||
|
|
f5e1a42f51 | ||
|
|
299e0e2674 | ||
|
|
3c47e49cf0 | ||
|
|
53e98620bf | ||
|
|
7d205b1711 | ||
|
|
e000494e48 | ||
|
|
2d71193bc0 | ||
|
|
696876d393 | ||
|
|
db1780678e | ||
|
|
861953fd2d | ||
|
|
7448cf6c65 | ||
|
|
4bb5b39410 | ||
|
|
f69426fd77 | ||
|
|
82a16f1305 | ||
|
|
b98d8aa8ec | ||
|
|
b6108527c2 | ||
|
|
c58a4a0166 | ||
|
|
ca3563b3b4 | ||
|
|
dec3748509 | ||
|
|
d62f21549f | ||
|
|
3f71fa641f | ||
|
|
2f398895c6 | ||
|
|
b4536a691a | ||
|
|
270ca2ddbe | ||
|
|
9b4f032660 | ||
|
|
53de48d2b3 | ||
|
|
08f8713ee1 | ||
|
|
a97f1457f6 | ||
|
|
fa2534a529 | ||
|
|
8538d1b2cb | ||
|
|
079b9ec86e | ||
|
|
52998e6aaa | ||
|
|
5448618dd5 | ||
|
|
eaf786c1ef | ||
|
|
c453d919b3 | ||
|
|
062264c7f6 | ||
|
|
713f0ef9ce | ||
|
|
d2cb78179d | ||
|
|
fff0e55f41 | ||
|
|
a743b16728 | ||
|
|
23c8f6d507 | ||
|
|
2250d102b2 | ||
|
|
96ecb47bc7 | ||
|
|
6050c86ab6 | ||
|
|
e6cc7db3c1 | ||
|
|
0ba1cfc612 | ||
|
|
2e08bda19d | ||
|
|
2c59a28860 | ||
|
|
f20723ca54 | ||
|
|
a2a2bafdf6 | ||
|
|
ce83276fa4 | ||
|
|
d40c710354 | ||
|
|
f65231becb | ||
|
|
039358e049 | ||
|
|
59929a8d06 | ||
|
|
9ff6baf7d9 | ||
|
|
0a4922b40f | ||
|
|
c5c6c32f24 | ||
|
|
351ba167f5 | ||
|
|
8334149cb2 | ||
|
|
5b05f13ce5 | ||
|
|
f25b7b73b4 | ||
|
|
7e70f8d2c1 | ||
|
|
227139aa33 | ||
|
|
339e95e9d7 | ||
|
|
2494de8f12 | ||
|
|
d12afc6039 | ||
|
|
abe70d1793 | ||
|
|
893d4fba5f | ||
|
|
d9fdbb627f | ||
|
|
79afb7afdb | ||
|
|
5fe588b480 | ||
|
|
8f41835352 | ||
|
|
b8649fc364 | ||
|
|
981306fa2b | ||
|
|
c8c6a48b94 | ||
|
|
5d5b42d3f5 | ||
|
|
a1fc99c66f | ||
|
|
6c4deed37a | ||
|
|
b6538b2cdd | ||
|
|
a57bb6a7d4 | ||
|
|
6ff392edc0 | ||
|
|
e9bced867d | ||
|
|
39675434f6 | ||
|
|
2e75c6dbdf | ||
|
|
c0d3e70296 | ||
|
|
028f29556f | ||
|
|
3a601e0fc3 | ||
|
|
af34e414e1 | ||
|
|
9677871ce7 | ||
|
|
d87a2315ce | ||
|
|
97faeccebf | ||
|
|
6afbcac9d0 | ||
|
|
e2ca7b8632 | ||
|
|
557367cf48 | ||
|
|
6c8c3257fd | ||
|
|
6f88db1be2 | ||
|
|
dc3911be14 | ||
|
|
03498bd2fd | ||
|
|
6ddb44985e | ||
|
|
2f923e47ec | ||
|
|
ebce0578e6 | ||
|
|
7563a62dfe | ||
|
|
f5a4d27e57 | ||
|
|
6eb479d5af | ||
|
|
8c9c2bec6c | ||
|
|
86727ae271 | ||
|
|
c6e498e837 | ||
|
|
be01b87cd4 | ||
|
|
0330dc3159 | ||
|
|
e493562735 | ||
|
|
b1c196ed83 | ||
|
|
a8a0a2655d | ||
|
|
b0e3afc0be | ||
|
|
242bcc8643 | ||
|
|
17af98126a | ||
|
|
d9a723b0ab | ||
|
|
6cc7a8b052 | ||
|
|
2341c2e729 | ||
|
|
80cbdbb535 | ||
|
|
39b6f4bbf5 | ||
|
|
c850439f92 | ||
|
|
44b71de41e | ||
|
|
5c059e604b | ||
|
|
07cc807bc9 | ||
|
|
e96fb67367 | ||
|
|
7259905114 | ||
|
|
0d0d28641d | ||
|
|
01a5b97415 | ||
|
|
8c662c65a9 | ||
|
|
88a6fe3c7a | ||
|
|
fcc3d9ed2b | ||
|
|
58efa18f96 | ||
|
|
887772db22 | ||
|
|
73f32a88e3 | ||
|
|
ddb109a456 | ||
|
|
ccf53afad4 | ||
|
|
170ae9f3be | ||
|
|
7d513ff1fe | ||
|
|
b387880194 | ||
|
|
b15db795e1 | ||
|
|
2a3f57bc61 | ||
|
|
0a928d6e9d | ||
|
|
4d7fddaf7e | ||
|
|
5438eb0057 | ||
|
|
88f7852928 | ||
|
|
a5a098943c | ||
|
|
f267e479ba | ||
|
|
c9c0dd367f | ||
|
|
a395af31b1 | ||
|
|
3389f0eece | ||
|
|
3b1197a1bb | ||
|
|
37d1c85c99 | ||
|
|
b6a485371d | ||
|
|
da236d5b38 | ||
|
|
e7fa86aa26 | ||
|
|
51a138aec3 | ||
|
|
aee7aaae68 | ||
|
|
2d94b8e905 | ||
|
|
8656bfc5f4 | ||
|
|
d37f108ccd | ||
|
|
88aca2e4d8 | ||
|
|
234aa69753 | ||
|
|
f58fc753e3 | ||
|
|
118549caf3 | ||
|
|
1c418a7f83 | ||
|
|
fe28097817 | ||
|
|
7bae9053ac | ||
|
|
231d182c35 | ||
|
|
fe54fb61aa | ||
|
|
7e8ee46d2c | ||
|
|
da6748a68f | ||
|
|
91b6483aa9 | ||
|
|
cf20f04bdc | ||
|
|
67b3929954 | ||
|
|
ad9cae32dc | ||
|
|
a4e0c10f34 | ||
|
|
b1e63639cd | ||
|
|
0e99c43495 | ||
|
|
4f06f29348 | ||
|
|
4c2c58214c | ||
|
|
9a8c9ecb4d | ||
|
|
95462a394a | ||
|
|
11fd0735d1 | ||
|
|
2cb35e7142 | ||
|
|
e71ed76165 | ||
|
|
742e2ff193 | ||
|
|
3aad157006 | ||
|
|
a572cf4842 | ||
|
|
97dae432ab | ||
|
|
db9d3d386d | ||
|
|
4aa41aa139 | ||
|
|
5568a312cd | ||
|
|
2915bcf28c | ||
|
|
54ed62f7b0 | ||
|
|
baf913c832 | ||
|
|
57c02b2523 | ||
|
|
670dfca6a8 | ||
|
|
fb3eeaa126 | ||
|
|
30550d9190 | ||
|
|
cafe748b1b | ||
|
|
1773a4d4b8 | ||
|
|
27af4506df | ||
|
|
01d4a8ab7a | ||
|
|
8dc43f9e3a | ||
|
|
50a2204046 | ||
|
|
baed3035ac | ||
|
|
88bfe777cd | ||
|
|
f912b06b42 | ||
|
|
2b5dca2929 | ||
|
|
2c80f60f3e | ||
|
|
1283a8ce79 | ||
|
|
68a4fdf0e9 | ||
|
|
27cd87e9ad | ||
|
|
272c6f5ec5 | ||
|
|
86ef57f6c3 | ||
|
|
b4eea78aff | ||
|
|
1a18928c94 | ||
|
|
d7c54d92b5 | ||
|
|
e62f2b3c75 | ||
|
|
45e1c72613 | ||
|
|
23f62a7312 | ||
|
|
d1a6c6b209 | ||
|
|
bad7d69a58 | ||
|
|
a05dab6298 | ||
|
|
c80bb31968 | ||
|
|
54beeeaf72 | ||
|
|
1de5827eb3 | ||
|
|
b77848244b | ||
|
|
16cf973ce5 | ||
|
|
807a8be299 | ||
|
|
5765a87720 | ||
|
|
a1829f6a3e | ||
|
|
b8c3e5ed3e | ||
|
|
d10b518f42 | ||
|
|
4997ef2662 | ||
|
|
cda4c95c86 | ||
|
|
41e4e7395c | ||
|
|
680783266c | ||
|
|
99d7773230 | ||
|
|
ac08529380 | ||
|
|
a80d5240f6 | ||
|
|
9b86932714 | ||
|
|
0431ad9cc4 | ||
|
|
f8a3ed2d18 | ||
|
|
dcb0933149 | ||
|
|
b516431569 | ||
|
|
af6a25eed7 | ||
|
|
acdafcd18d | ||
|
|
a97cdc0da0 | ||
|
|
ac6292b812 | ||
|
|
d126c23ef8 | ||
|
|
36da2b06a5 | ||
|
|
90a5b3befb | ||
|
|
42faa63227 | ||
|
|
9e3d5407ae | ||
|
|
b550d78905 | ||
|
|
123dbf152e | ||
|
|
e7ccaf6e78 | ||
|
|
15bd5040f1 | ||
|
|
598282cf75 | ||
|
|
f7ea60b500 | ||
|
|
2fa222d00a | ||
|
|
6920fed97e | ||
|
|
528e58155b | ||
|
|
503fc354cc | ||
|
|
4eec423950 | ||
|
|
52bc5306b0 | ||
|
|
90e4b49b88 | ||
|
|
750a659a9f | ||
|
|
6fde0f2960 | ||
|
|
a0e323b89b | ||
|
|
a68ac4efe6 | ||
|
|
393bdd7ead | ||
|
|
7f411dd5cc | ||
|
|
c790ec7625 | ||
|
|
c46f3d05a8 | ||
|
|
b38d59dee6 | ||
|
|
da661756fa | ||
|
|
46967ffd04 | ||
|
|
5b1f9e3e21 | ||
|
|
776d4773a9 | ||
|
|
eeed0df743 | ||
|
|
b1006a2b57 | ||
|
|
53cd660de7 | ||
|
|
a7061383e8 | ||
|
|
97529e8ae4 | ||
|
|
c579e1fe44 | ||
|
|
17080a1c00 | ||
|
|
e6b3c91343 | ||
|
|
deb64fa6a6 | ||
|
|
9bc8784e3e | ||
|
|
d6f93753c1 | ||
|
|
0f12d512c4 | ||
|
|
b76d234f97 | ||
|
|
4aa5574ba7 | ||
|
|
ead1c1881c | ||
|
|
3df5826d93 | ||
|
|
cd417ca0ba | ||
|
|
4f8f8e0e0b | ||
|
|
05d7d1e562 | ||
|
|
08f4a2dff8 | ||
|
|
ff44fa242d | ||
|
|
e4f27ab75f | ||
|
|
86f024b9d7 | ||
|
|
3c7d01163d | ||
|
|
4a7e1b93e5 | ||
|
|
98883a68f2 | ||
|
|
b8a51de977 | ||
|
|
cd7bd0aa20 | ||
|
|
4d16cf6bf3 | ||
|
|
422d38fd11 | ||
|
|
879abd7fee | ||
|
|
77e971dd9f | ||
|
|
4f22a1c5e4 | ||
|
|
e201dc995c | ||
|
|
972be4eda5 | ||
|
|
34d16791a8 | ||
|
|
d3b09c6a02 | ||
|
|
27d61307cd | ||
|
|
685cca5bd5 | ||
|
|
bd02a456ab | ||
|
|
e5daec32ba | ||
|
|
b03529b006 | ||
|
|
612a52d7bb | ||
|
|
aa6f63a335 | ||
|
|
9f0010e234 | ||
|
|
e7061b70e9 | ||
|
|
fe65fe0b97 | ||
|
|
3d2f7e3143 | ||
|
|
1df5f1f9f6 | ||
|
|
05c46008da | ||
|
|
1493a5fd32 | ||
|
|
4b6d34438b | ||
|
|
e06489d92b | ||
|
|
c32b407f8a | ||
|
|
ab30b105e2 | ||
|
|
f27ab14bd8 | ||
|
|
23a51f2d01 | ||
|
|
05732de898 | ||
|
|
316f1d6ff0 | ||
|
|
b4d8287946 | ||
|
|
0dee15ba97 | ||
|
|
0e3b6b3b8f | ||
|
|
32086b6ecd | ||
|
|
2a95cbcef7 | ||
|
|
6614c73b6c | ||
|
|
2904a78222 | ||
|
|
ac879513e5 | ||
|
|
6d69ea3ac7 | ||
|
|
f096e99059 | ||
|
|
72d19e44dc | ||
|
|
b25c7da154 | ||
|
|
b8086c5edf | ||
|
|
91a9f32904 | ||
|
|
199f371f58 | ||
|
|
6cdadc73f2 | ||
|
|
c5a967e05f | ||
|
|
651f385ba5 | ||
|
|
f25a144e09 | ||
|
|
5eaee44daa | ||
|
|
9c76fb267a | ||
|
|
cf49b823b0 | ||
|
|
5be58f2601 | ||
|
|
1c64269387 | ||
|
|
f33d6eabdf | ||
|
|
7ae42f5ae3 | ||
|
|
9710060c27 | ||
|
|
06e5c00f24 | ||
|
|
f7b0fdde2a | ||
|
|
7aa3903b6b | ||
|
|
6bf4ba523d | ||
|
|
c42c0436f2 | ||
|
|
61f20acf61 | ||
|
|
c55afc4255 | ||
|
|
f1bbf3a91e | ||
|
|
de7f7b3d85 | ||
|
|
777e81f7a8 | ||
|
|
aeb5288a3c | ||
|
|
2771c26729 | ||
|
|
e4e97e727e | ||
|
|
1afa366dcb | ||
|
|
7c1b6f9157 | ||
|
|
651dea3864 | ||
|
|
96e71cf181 | ||
|
|
ac5cc4a050 | ||
|
|
48dc333a3d | ||
|
|
628b753a35 | ||
|
|
c28fbcb232 | ||
|
|
2edfde1990 | ||
|
|
01fcd057b7 | ||
|
|
fb3b736a35 | ||
|
|
064d9d6141 | ||
|
|
fd7385c392 | ||
|
|
834824ce7b | ||
|
|
466d5bb696 | ||
|
|
6e4a2f18e1 | ||
|
|
5e6e25f8c8 | ||
|
|
428f9a22c4 | ||
|
|
92d79150bd | ||
|
|
6bc5d331a2 | ||
|
|
a7ae280a4f | ||
|
|
2a1c1e34dc | ||
|
|
4b4fda46cf | ||
|
|
4b029048db | ||
|
|
34feaeeb96 | ||
|
|
a6c53303dd | ||
|
|
204484d8e7 | ||
|
|
41e4f33c1d | ||
|
|
94770f6059 | ||
|
|
c4fed37af9 | ||
|
|
b0279c5090 | ||
|
|
73f8237d43 | ||
|
|
4ad7430986 | ||
|
|
27a91cc80a | ||
|
|
820321ad55 | ||
|
|
7042318c34 | ||
|
|
6ae6cc9741 | ||
|
|
dbd7b90123 | ||
|
|
afad34fd09 | ||
|
|
167f4ba8ad | ||
|
|
7f6b260c35 | ||
|
|
62517f01e5 | ||
|
|
293531549c | ||
|
|
60db9ec8ef | ||
|
|
7528f24b61 | ||
|
|
e4c4ba0979 | ||
|
|
a89ffccd7e | ||
|
|
7be5b7f50f | ||
|
|
a5d8882bba | ||
|
|
07c5b25bc8 | ||
|
|
10c9a7d75e | ||
|
|
78e7f14360 | ||
|
|
700894a13d | ||
|
|
1c78594379 | ||
|
|
48cd72fab0 | ||
|
|
04251b7bff | ||
|
|
e150d8763b | ||
|
|
0159a33306 | ||
|
|
c07086401e | ||
|
|
2de11f8daf | ||
|
|
0e75e8529e | ||
|
|
40da51d37f | ||
|
|
bfc8f74477 | ||
|
|
d4f21c7e84 | ||
|
|
b9e200f104 | ||
|
|
da7610f6cb | ||
|
|
f188d570f7 | ||
|
|
52e8c6c998 | ||
|
|
7735a04783 | ||
|
|
8920bf2377 | ||
|
|
b55a38ee97 | ||
|
|
6c39dc764d | ||
|
|
cde5879987 | ||
|
|
dddd1e44f3 | ||
|
|
60f62c2f59 | ||
|
|
a7267b5914 | ||
|
|
c01255570b | ||
|
|
caf0a1fbb6 | ||
|
|
72cd3a54f7 | ||
|
|
c96252f7fe | ||
|
|
1d83bc15de | ||
|
|
b8159fc43e | ||
|
|
09502c6664 | ||
|
|
db1730f47b | ||
|
|
70ab38194f | ||
|
|
867fcc4d95 | ||
|
|
d88c6f81ee | ||
|
|
0cfc8f5256 | ||
|
|
59f9a4cdf5 | ||
|
|
382299d549 | ||
|
|
df433f852f | ||
|
|
980019bb50 | ||
|
|
6549fc839f | ||
|
|
365756c9a2 | ||
|
|
03d5d00d28 | ||
|
|
051765e77a | ||
|
|
001775d6c3 | ||
|
|
2ceb769057 | ||
|
|
9f9f1a1517 | ||
|
|
71c672c90f | ||
|
|
1d0881e283 | ||
|
|
fd2b9cfd61 | ||
|
|
c1f37d9aed | ||
|
|
4fe97d8794 | ||
|
|
9738ddfd99 | ||
|
|
9b3d71f0d2 | ||
|
|
779db74d7e | ||
|
|
1077b2ac8b | ||
|
|
c3ea4feca5 | ||
|
|
9321a6ff60 | ||
|
|
26764a0b92 | ||
|
|
aef1e06f0b | ||
|
|
b27243df81 | ||
|
|
bbd1d2b58c | ||
|
|
99bba12de2 | ||
|
|
f0856bcb46 | ||
|
|
b34b200682 | ||
|
|
a7fd76df41 | ||
|
|
c870b2ea29 | ||
|
|
71f99228ef | ||
|
|
f0bd74b75b | ||
|
|
69646eb1de | ||
|
|
8edfd29102 | ||
|
|
d8ad384b06 | ||
|
|
10e39956ef | ||
|
|
4a971eeaee | ||
|
|
767acd1789 | ||
|
|
a3dd678bbd | ||
|
|
8bf0b40fdd | ||
|
|
3e58413268 | ||
|
|
b1e5bc8e49 | ||
|
|
2b1ee8b0dc | ||
|
|
2a234829f5 | ||
|
|
d46b7b8f1b | ||
|
|
32015c392d | ||
|
|
c8780a7f93 | ||
|
|
0f04227c34 | ||
|
|
b14617a653 | ||
|
|
e66e0526ed | ||
|
|
fed5615c19 | ||
|
|
f68d11ec52 | ||
|
|
034163e9f9 | ||
|
|
77358031f5 | ||
|
|
e1e3009a30 | ||
|
|
76d358bd6c | ||
|
|
c2f98a4cd2 | ||
|
|
83699cf3ef | ||
|
|
5ba0b52cf3 | ||
|
|
40b6e6802f | ||
|
|
a341b9a4ea | ||
|
|
db05e34736 | ||
|
|
072de1fb7e | ||
|
|
d041d58bb6 | ||
|
|
3ec1efb6e0 | ||
|
|
3f27d9ada1 | ||
|
|
19e3214997 | ||
|
|
49c9b3199f | ||
|
|
c17f3bc82b | ||
|
|
8b8908fa93 | ||
|
|
45da209f85 | ||
|
|
ad58e6b297 | ||
|
|
4f72d5453f | ||
|
|
5eb26fe7ab | ||
|
|
e1386fe80b | ||
|
|
ac375fe3d2 | ||
|
|
218920f0c3 | ||
|
|
3d5699ef22 | ||
|
|
621e7679c4 | ||
|
|
c04e1787c5 | ||
|
|
e5bb366719 | ||
|
|
c0f3352b71 | ||
|
|
9701d127bf | ||
|
|
0e5320c39e | ||
|
|
c744ae0d63 | ||
|
|
596be451ec | ||
|
|
5434172e88 | ||
|
|
cf77a4276e | ||
|
|
adaa467f75 | ||
|
|
879778361d | ||
|
|
fb02ec52a5 | ||
|
|
2ca34217e6 | ||
|
|
9e50026107 | ||
|
|
67549dcadd | ||
|
|
308c277fd3 | ||
|
|
24971ffce0 | ||
|
|
b6a2ca877a | ||
|
|
e4c864de7e | ||
|
|
a35469c669 | ||
|
|
a51f0c30ec | ||
|
|
d5824b1b49 | ||
|
|
b0720ec2aa | ||
|
|
5afa42b0d9 | ||
|
|
48ed800d91 | ||
|
|
dd6f83e9c5 | ||
|
|
29f9d1d0c2 | ||
|
|
530460d711 | ||
|
|
3e65109900 | ||
|
|
db0379030e | ||
|
|
098f34f400 | ||
|
|
22e11760a1 | ||
|
|
cae7ad8c70 | ||
|
|
4e21e4ba0e | ||
|
|
e701c65db4 | ||
|
|
e61e7434a0 | ||
|
|
1ef8204359 | ||
|
|
025eef754f | ||
|
|
b775826a27 | ||
|
|
58d7ca35e3 | ||
|
|
aa8ab349ed | ||
|
|
c923461882 | ||
|
|
8753c2ff51 | ||
|
|
cc788eb4f9 | ||
|
|
b53ddfbd19 | ||
|
|
c03ca7270e | ||
|
|
eadec9e86e | ||
|
|
3ed0a6d11f | ||
|
|
caaaa07232 | ||
|
|
8802c6e8db | ||
|
|
a4ca9a981e | ||
|
|
f9fd22d98e | ||
|
|
031cf38655 | ||
|
|
f68d1ba394 | ||
|
|
02f7c3258b | ||
|
|
5c2db102d0 | ||
|
|
58a8cae3d7 | ||
|
|
ef56b14636 | ||
|
|
cd740f436d | ||
|
|
d4c628de09 | ||
|
|
9208a84185 | ||
|
|
49bb1255c5 | ||
|
|
7344bd3e66 | ||
|
|
7f41bb6118 | ||
|
|
74263c872c | ||
|
|
f3cd2ffb18 | ||
|
|
b623a9ea69 | ||
|
|
e097bbdf11 | ||
|
|
b1cba685f4 | ||
|
|
721ea9cbd8 | ||
|
|
6a67a2217c | ||
|
|
d7f43bfc1a | ||
|
|
384a53b339 | ||
|
|
9a55547827 | ||
|
|
3288b19795 | ||
|
|
56a8973fa0 | ||
|
|
680adea4e7 | ||
|
|
d6094c2881 | ||
|
|
f6b1b075e1 | ||
|
|
210197fd43 | ||
|
|
2227f24bd6 | ||
|
|
3f137f9bbc | ||
|
|
9f6872a8e1 | ||
|
|
c55f557828 | ||
|
|
3e1b14f570 | ||
|
|
f51a0b181f | ||
|
|
a4ce1ebf4f | ||
|
|
766e8bc022 | ||
|
|
5df9cad671 | ||
|
|
81cefd8541 | ||
|
|
06c1426e14 | ||
|
|
b2623c9799 | ||
|
|
153afd832c | ||
|
|
19e18bc461 | ||
|
|
06a6855f84 | ||
|
|
6b69c4da0f | ||
|
|
d973db829f | ||
|
|
4977e6d50f | ||
|
|
136972ccf0 | ||
|
|
96b8aaf83f | ||
|
|
097b5e4c0b | ||
|
|
ca853ca465 | ||
|
|
a68342d5a8 | ||
|
|
f704d5b0b3 | ||
|
|
fe7b8d9617 | ||
|
|
042191372a | ||
|
|
fbf9b3f1bb | ||
|
|
cba2e44de4 | ||
|
|
ff8100dc6f | ||
|
|
a156033d3c | ||
|
|
b7f268f163 | ||
|
|
d5c236aa8e | ||
|
|
c9e69fb2f4 | ||
|
|
ee82439e67 | ||
|
|
fcd3c7a10f | ||
|
|
051b6daa82 | ||
|
|
6c0a5fa91c | ||
|
|
edc056351d | ||
|
|
7347f95b24 | ||
|
|
a6f25b99f9 | ||
|
|
2185fc61c0 | ||
|
|
bf3a140254 | ||
|
|
bbe6564bfd | ||
|
|
95674162f9 | ||
|
|
24f78fed21 | ||
|
|
cf72f5503f | ||
|
|
932f759410 | ||
|
|
d6668515f6 | ||
|
|
a05966af96 | ||
|
|
3802ab9fd1 | ||
|
|
4ca43004ed | ||
|
|
171021cfa4 | ||
|
|
d3d7b209b2 | ||
|
|
6ddbb41aac | ||
|
|
2a9925fe18 | ||
|
|
81e401a520 | ||
|
|
ba432090c5 | ||
|
|
de634a7599 | ||
|
|
803daebfe4 | ||
|
|
958a76d448 | ||
|
|
2dd0eaad73 | ||
|
|
8339f59cdf | ||
|
|
c51a65173e | ||
|
|
d5cb65527e | ||
|
|
956cb7beaa | ||
|
|
4eb928693b | ||
|
|
d5b903234d | ||
|
|
86f191d015 | ||
|
|
b35b56394a | ||
|
|
23990276cb | ||
|
|
f2a09c7149 | ||
|
|
774c0056bd | ||
|
|
c299d3fd54 | ||
|
|
7d62ce5b19 | ||
|
|
5ad93b6fc9 | ||
|
|
e6a5618d13 | ||
|
|
d335851a1c | ||
|
|
8bc6a30c7b | ||
|
|
b7243e6efc | ||
|
|
40fab879ff | ||
|
|
5af4714bf0 | ||
|
|
edd65feff5 | ||
|
|
202eada74f | ||
|
|
d863fd3626 | ||
|
|
3ff8deeea8 | ||
|
|
041a89ef5c | ||
|
|
918f507d8c | ||
|
|
f7e85cd0bf | ||
|
|
4b4583df62 | ||
|
|
bcf76dfa74 | ||
|
|
485392fe63 | ||
|
|
cbb4684b16 | ||
|
|
0531ca6530 | ||
|
|
32cb9df3c4 | ||
|
|
71fd483fba | ||
|
|
63f38c584f | ||
|
|
77779b30d4 | ||
|
|
d0f338bb99 | ||
|
|
63ca0b8cba | ||
|
|
d0b20df46c | ||
|
|
0214c1e66c | ||
|
|
015342e669 | ||
|
|
0cd32b015b | ||
|
|
5f0d262c59 | ||
|
|
7fc3ac38ac | ||
|
|
a04e8ee84e | ||
|
|
3cce416cab | ||
|
|
f17d8b5d19 | ||
|
|
c881e033b2 | ||
|
|
3ae547f796 | ||
|
|
79e92378de | ||
|
|
53ecfc2b5d | ||
|
|
d783708745 | ||
|
|
218701e617 | ||
|
|
c6a46195c0 | ||
|
|
4a76bea80c | ||
|
|
fcebc82ec2 | ||
|
|
daa2a036f8 | ||
|
|
41f9a8caff | ||
|
|
6f6412dd16 | ||
|
|
2b2d123531 | ||
|
|
474df5e534 | ||
|
|
30d1dc2c60 | ||
|
|
4cea3a57be | ||
|
|
8a9f862701 | ||
|
|
001dab0439 | ||
|
|
b786d1e3f3 | ||
|
|
b5bb6ae177 | ||
|
|
f71834720e | ||
|
|
f11bdc6ab5 | ||
|
|
35c1c48fd2 | ||
|
|
fc11e4384f | ||
|
|
7693d0e2b0 | ||
|
|
217f4daef0 | ||
|
|
6d38ac41b6 | ||
|
|
91755309ce | ||
|
|
a28ca30519 | ||
|
|
e023a98f11 | ||
|
|
6dc0df2473 | ||
|
|
cd5e2be27b | ||
|
|
3d37e4a42d | ||
|
|
7f523de408 | ||
|
|
f2525ebc44 | ||
|
|
1cdb7aed1e | ||
|
|
16090bc272 | ||
|
|
0a85dd4bca | ||
|
|
33f04a7714 | ||
|
|
c9282135c4 | ||
|
|
3d6d050ad8 | ||
|
|
9368d0ac75 | ||
|
|
40e40d1ddd | ||
|
|
d9f97c832c | ||
|
|
4f2b821088 | ||
|
|
eb10ff2ae6 | ||
|
|
b70e910e4b | ||
|
|
955fc5c736 | ||
|
|
9aac148908 | ||
|
|
183ba83920 | ||
|
|
85cc067599 | ||
|
|
fe0665605a | ||
|
|
e593ea8935 | ||
|
|
e114a40bb8 | ||
|
|
7c0bf59c7c | ||
|
|
eb64f7bdb3 | ||
|
|
1bc9711afd | ||
|
|
de48a32ea2 | ||
|
|
5b3eae3855 | ||
|
|
9764bf3b78 | ||
|
|
a74ec200b3 | ||
|
|
49e045ea3d | ||
|
|
c0a47169fa | ||
|
|
a140cfc521 | ||
|
|
ea2f97004b | ||
|
|
93132abb40 | ||
|
|
df0b1e5552 | ||
|
|
e5ea595425 | ||
|
|
a5b781f3d8 | ||
|
|
5128402282 | ||
|
|
11dea8795b | ||
|
|
b70c0f36c0 | ||
|
|
e42ee34672 | ||
|
|
71b6a942fe | ||
|
|
5bdd334b74 | ||
|
|
6d6794aa85 | ||
|
|
1588f42fe7 | ||
|
|
029db5c635 | ||
|
|
b85700f6ca | ||
|
|
fbbe1117ae | ||
|
|
df66e21472 | ||
|
|
51fc792501 | ||
|
|
7a166152d9 | ||
|
|
926954f93b | ||
|
|
5a596ae40c | ||
|
|
048f30aa97 | ||
|
|
66bf28cd85 | ||
|
|
bbe1167958 | ||
|
|
357a53eb6f | ||
|
|
37bf0087e5 | ||
|
|
e5829572ff | ||
|
|
4ca936f0bf | ||
|
|
0a351580f8 | ||
|
|
472b71f331 | ||
|
|
308f4d6b26 | ||
|
|
2d62796616 | ||
|
|
330bec67b7 | ||
|
|
2890c6d62d | ||
|
|
9119db001d | ||
|
|
c7faccd5fa | ||
|
|
eff06538a6 | ||
|
|
0503fbd2e3 | ||
|
|
03d1d2a88b | ||
|
|
c0b3db38a5 | ||
|
|
f588655f7f | ||
|
|
c45201a8a2 | ||
|
|
3ccbb46938 | ||
|
|
a980af3ca4 | ||
|
|
22c4ef4fb0 | ||
|
|
df0d29c81c | ||
|
|
bc6afc9057 | ||
|
|
182408a52e | ||
|
|
513cab94b1 | ||
|
|
d01439d144 | ||
|
|
0a535a6fed | ||
|
|
3a38173c66 | ||
|
|
adbaaa65f9 | ||
|
|
379592dae4 | ||
|
|
22ff239e3a | ||
|
|
e830b4959e | ||
|
|
1c1b32b390 | ||
|
|
e6daad2ab9 | ||
|
|
4f2e426fc7 | ||
|
|
609a6a3721 | ||
|
|
85153afda8 | ||
|
|
f56889c5c7 | ||
|
|
cef4028c1c | ||
|
|
4b97884fce | ||
|
|
9ac7f2635c | ||
|
|
2d6f68eb2d | ||
|
|
61e7aa2229 | ||
|
|
9f5df72d7c | ||
|
|
4dd9484b48 | ||
|
|
dbe36e841a | ||
|
|
201eaa9655 | ||
|
|
4cd550d1d0 | ||
|
|
94dae76d0f | ||
|
|
3e69f10af7 | ||
|
|
bcc0cbf895 | ||
|
|
0e0c1c4ca4 | ||
|
|
ed5d95f434 | ||
|
|
3d2fcef28f | ||
|
|
77b65ccbfb | ||
|
|
487979859a | ||
|
|
ac0243e8b7 | ||
|
|
5037197541 | ||
|
|
b45219c8b1 | ||
|
|
ebfc887869 | ||
|
|
719d115d49 | ||
|
|
4e7b0ea4b4 | ||
|
|
c2b4976c82 | ||
|
|
e0ab5adb97 | ||
|
|
b0f6f24ca8 | ||
|
|
61f530ff4b | ||
|
|
7df1a059d6 | ||
|
|
3e18187805 | ||
|
|
aa13ed885d | ||
|
|
2a27db2d3b | ||
|
|
89f005ece6 | ||
|
|
48afd424a3 | ||
|
|
b50ae5bf69 | ||
|
|
39ae9167ee | ||
|
|
20b6902b9f | ||
|
|
08b958cfc9 | ||
|
|
c62f30e22c | ||
|
|
562710fe33 | ||
|
|
647e38f701 | ||
|
|
292cb62d4a | ||
|
|
05223f720d | ||
|
|
32a303f392 | ||
|
|
1d1a83b754 | ||
|
|
1ca5ad47b1 | ||
|
|
3111d1bf61 | ||
|
|
d735b036fe | ||
|
|
9d80cc3b2d | ||
|
|
0ebe4f8f84 | ||
|
|
be373e9fd4 | ||
|
|
898826dc22 | ||
|
|
0bca4e230e | ||
|
|
ef5374a34e | ||
|
|
2407d9b905 | ||
|
|
25d9a728f4 | ||
|
|
103688936e | ||
|
|
ac73f97604 | ||
|
|
0d09571ca2 | ||
|
|
5b4409814b | ||
|
|
8cca648efa | ||
|
|
a8222e391b | ||
|
|
dca7ce9420 | ||
|
|
bcd5783752 | ||
|
|
12bd04d24d | ||
|
|
2bb6063dcb | ||
|
|
4d7c8b4a6a | ||
|
|
40617b9e0e | ||
|
|
52030a241c | ||
|
|
3d6605bbfd | ||
|
|
a60b0a108a | ||
|
|
48635ced35 | ||
|
|
f4dde86b36 | ||
|
|
fcc1e2729c | ||
|
|
e7c7c65227 | ||
|
|
b2d1aa3c6e | ||
|
|
24767bb0fa | ||
|
|
c000188728 | ||
|
|
db4adc0e89 | ||
|
|
cf08d34879 | ||
|
|
d2fdf6999b | ||
|
|
d9c8a2508f | ||
|
|
df2428b356 | ||
|
|
e39ce16a86 | ||
|
|
d67cf5d9f2 | ||
|
|
688bfac4ba | ||
|
|
7c2cb5899c | ||
|
|
b16ad094be | ||
|
|
d5715cec6f | ||
|
|
c7caa268ce | ||
|
|
de8e8af989 | ||
|
|
ddc88d615c | ||
|
|
7644d66902 | ||
|
|
177788540b | ||
|
|
65a9a7eae0 | ||
|
|
804340ee26 | ||
|
|
937f97d7d6 | ||
|
|
f6637cc78e | ||
|
|
29cd1bc739 | ||
|
|
31485835a7 | ||
|
|
e4b6855984 | ||
|
|
bbd3e38e25 | ||
|
|
b29d5b80cd | ||
|
|
ceaafbbfd2 | ||
|
|
7b0ce47eb8 | ||
|
|
edf7a3d142 | ||
|
|
a64586f9c2 | ||
|
|
8b425a6295 | ||
|
|
d42e3fce4a | ||
|
|
e214d59d10 | ||
|
|
58cc57e8a4 | ||
|
|
d3a952877a | ||
|
|
07357afcf6 | ||
|
|
ceb4948a28 | ||
|
|
86a8eb1023 | ||
|
|
dced9e4094 | ||
|
|
803b2e35be | ||
|
|
23a9731899 | ||
|
|
4267e22d4a | ||
|
|
e3207f35d7 | ||
|
|
85264e76de | ||
|
|
1dc8056d84 | ||
|
|
853c14b4c2 | ||
|
|
01dba25466 | ||
|
|
9c3f54cf1c | ||
|
|
f4047eea77 | ||
|
|
0583847943 | ||
|
|
4b22aa819c | ||
|
|
630cea105e | ||
|
|
c61698efcf | ||
|
|
a37d411dcf | ||
|
|
eacec793e9 | ||
|
|
0e46f8091f | ||
|
|
7b9dc69362 | ||
|
|
0a16af4e96 | ||
|
|
24e696e341 | ||
|
|
0ea421ea20 | ||
|
|
3da6049a4a | ||
|
|
3839f18bb1 | ||
|
|
2b0e90c032 | ||
|
|
3a360e441e | ||
|
|
8df74dde80 | ||
|
|
c6d80496ab | ||
|
|
a463e58de6 | ||
|
|
7a59cc9186 | ||
|
|
ff6946c230 | ||
|
|
3ff773c199 | ||
|
|
8048d4ac39 | ||
|
|
8d7319441e | ||
|
|
d98a60fbbb | ||
|
|
093af754e7 | ||
|
|
fa1590df57 | ||
|
|
9e7ec0eb1e | ||
|
|
5051bfe7ab | ||
|
|
1db8dec4f5 | ||
|
|
dafedf5675 | ||
|
|
37a3de0703 | ||
|
|
72b25ab78b | ||
|
|
fbff4e19de | ||
|
|
2777bab148 | ||
|
|
4ec70e3d46 | ||
|
|
43b2eca418 | ||
|
|
99f7d1b62b | ||
|
|
62a5db7719 | ||
|
|
278a4edd0b | ||
|
|
279e3e970f | ||
|
|
88a5b5ff4c | ||
|
|
c159a1a120 | ||
|
|
1decefbe59 | ||
|
|
28157b4f8c | ||
|
|
e013df19f9 | ||
|
|
486f953ba2 | ||
|
|
407dc9a401 | ||
|
|
9fb2c9c86d | ||
|
|
60b8cfb9fa | ||
|
|
5a66f69460 | ||
|
|
578f116ebb | ||
|
|
d3b5ba8472 | ||
|
|
3e28c46026 | ||
|
|
71d7ba6c07 | ||
|
|
581c0f137e | ||
|
|
f2e78d7940 | ||
|
|
c821c3ecb0 | ||
|
|
b3de3295d6 | ||
|
|
0952772705 | ||
|
|
c0189e43ad | ||
|
|
8fad4fb264 | ||
|
|
e9cab818f8 | ||
|
|
3523cea8cc | ||
|
|
01002042a8 | ||
|
|
f81f3b3a02 | ||
|
|
99ee1a2702 | ||
|
|
7ede54ee0a | ||
|
|
e96d139180 | ||
|
|
e6da38464b | ||
|
|
1e9abe6d8c | ||
|
|
61af00d0a6 | ||
|
|
bb6864dd12 | ||
|
|
1404fd9fbd | ||
|
|
02479425a5 | ||
|
|
cd762db9bf | ||
|
|
6663fc3a6c | ||
|
|
67db71f140 | ||
|
|
aa94359f74 | ||
|
|
9726c0b7ee | ||
|
|
449f0c155b | ||
|
|
48f0992300 | ||
|
|
492bcf9d26 | ||
|
|
bbfe456f90 | ||
|
|
774140ae0f | ||
|
|
5590ad438f | ||
|
|
9eea1aff10 | ||
|
|
1a15a62b73 | ||
|
|
efcf6db687 | ||
|
|
66f00ce4bf | ||
|
|
d720e027ad | ||
|
|
f46837fd10 | ||
|
|
1477f14827 | ||
|
|
094a16ab49 | ||
|
|
86011e40be | ||
|
|
4451f86eb0 | ||
|
|
8f51c11bc2 | ||
|
|
01f438c3fe | ||
|
|
1f91e5d3c6 | ||
|
|
faf01bdeac | ||
|
|
bb21bf5239 | ||
|
|
919d65f36f | ||
|
|
19e82ace23 | ||
|
|
98159dbcae | ||
|
|
ba972ecd52 | ||
|
|
d2d23f4abd | ||
|
|
45180e829a | ||
|
|
cd02ff2e07 | ||
|
|
39fe385017 | ||
|
|
7452b87877 | ||
|
|
45d9a720b9 | ||
|
|
7fcc545672 | ||
|
|
5e1f4fa0ff | ||
|
|
efebf4d3a0 | ||
|
|
e304ca5bd5 | ||
|
|
8eb5807c5f | ||
|
|
7670543a39 | ||
|
|
ed926c8f26 | ||
|
|
b3a95f40fc | ||
|
|
96643f5b6d | ||
|
|
575db66295 | ||
|
|
47ec443728 | ||
|
|
d6f709574e | ||
|
|
4bc77b544e | ||
|
|
be93081bd7 | ||
|
|
70d0477418 | ||
|
|
f592748011 | ||
|
|
8a157578f4 | ||
|
|
8a1f321fd4 | ||
|
|
f97f21bf3a | ||
|
|
a651598d19 | ||
|
|
68d42ef850 | ||
|
|
979ec71522 | ||
|
|
6d0f757848 | ||
|
|
dc9445c4ec | ||
|
|
901c997054 | ||
|
|
99b88c3024 | ||
|
|
094a82b264 | ||
|
|
99604fd644 | ||
|
|
33ecc405cb | ||
|
|
ad49435a94 | ||
|
|
282a884fad | ||
|
|
d7363fd65f | ||
|
|
f23eb2a31c | ||
|
|
ccd2a0be5b | ||
|
|
47560d4d72 | ||
|
|
7f0c50f445 | ||
|
|
bed6aa63e1 | ||
|
|
6a109e972e | ||
|
|
2387877dae | ||
|
|
40f60c163d | ||
|
|
28c1de237b | ||
|
|
04bd09d027 | ||
|
|
35e8c77025 | ||
|
|
ffd9e9cbe7 | ||
|
|
8f5843c8bc | ||
|
|
397421ce0b | ||
|
|
0dc6220ae6 | ||
|
|
8ae99ad7bb | ||
|
|
fe4b7621b3 | ||
|
|
3da22af859 | ||
|
|
cb1445086e | ||
|
|
9ba424e18a | ||
|
|
dfc9412117 | ||
|
|
43aa23ea77 | ||
|
|
286896b941 | ||
|
|
b810868239 | ||
|
|
0b59aa940e | ||
|
|
f1c28455ad | ||
|
|
daa4b3284f | ||
|
|
72023f4d03 | ||
|
|
313c54a4d1 | ||
|
|
c82183f985 | ||
|
|
84fe62d557 | ||
|
|
43234c3c4d | ||
|
|
9e2d8470d4 | ||
|
|
057533f9cc | ||
|
|
e277dba8fe | ||
|
|
4525ac687b | ||
|
|
53f1caf91f | ||
|
|
7dd5c957cf | ||
|
|
a1e62ab422 | ||
|
|
1a42e96a3b | ||
|
|
13fe78428b | ||
|
|
50705f926e | ||
|
|
709258bbb5 | ||
|
|
d303f0351d | ||
|
|
82ed9b0a97 | ||
|
|
e13f1738d4 | ||
|
|
b43acc2b3d | ||
|
|
3e8d3b08fa | ||
|
|
c134de8799 | ||
|
|
ad98d4300b | ||
|
|
115231c0e5 | ||
|
|
72168b1586 | ||
|
|
93205d4320 | ||
|
|
64b6ac4c1f | ||
|
|
28faaa23c4 | ||
|
|
2ed9896dea | ||
|
|
84fd0e4152 | ||
|
|
7fb0228ac8 | ||
|
|
0845b7ffe5 | ||
|
|
55b6a82cd5 | ||
|
|
ff55ca4d75 | ||
|
|
2011e5711d | ||
|
|
e7d9755d97 | ||
|
|
20101579e0 | ||
|
|
8a745b9bbf | ||
|
|
2cb87a71f7 | ||
|
|
86a3e0a0b8 | ||
|
|
37b8ea3ab2 | ||
|
|
4cca1829c2 | ||
|
|
230e21b10c | ||
|
|
dd3cd4ac4f | ||
|
|
f0c2d47b24 | ||
|
|
5fa502e419 | ||
|
|
5a2fafceae | ||
|
|
b5ab9634e8 | ||
|
|
e289e02193 | ||
|
|
e966f54001 | ||
|
|
c11f02662f | ||
|
|
23575825a1 | ||
|
|
1e67035bd3 | ||
|
|
85077d198b | ||
|
|
7f17b31c12 | ||
|
|
01e54dae94 | ||
|
|
47cde38369 | ||
|
|
4616997e6a | ||
|
|
1aba7accbf | ||
|
|
32753326fa | ||
|
|
966e244e33 | ||
|
|
0c5950fb60 | ||
|
|
1d3c656286 | ||
|
|
74b1c80132 | ||
|
|
7aa41d5d0c | ||
|
|
4c30f22444 | ||
|
|
6af94ae8f3 | ||
|
|
2625cd8719 | ||
|
|
f91da291d9 | ||
|
|
5543f30c49 | ||
|
|
06666cb148 | ||
|
|
df314fda1d | ||
|
|
f758bf74c2 | ||
|
|
357b57e1d6 | ||
|
|
a10e038fff | ||
|
|
d8c4dd6f79 | ||
|
|
7ace997c53 | ||
|
|
62506b1955 | ||
|
|
53c9c48387 | ||
|
|
2df4e7207b | ||
|
|
f890fe6901 | ||
|
|
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 | ||
|
|
13e7a3dee9 | ||
|
|
0afe972bc6 |
@@ -7,6 +7,15 @@ OPENAI_API_KEY=''
|
|||||||
|
|
||||||
# AUTOMATIC1111_BASE_URL="http://localhost:7860"
|
# 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
|
# DO NOT TRACK
|
||||||
SCARF_NO_ANALYTICS=true
|
SCARF_NO_ANALYTICS=true
|
||||||
DO_NOT_TRACK=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
|
||||||
@@ -11,7 +11,9 @@ body:
|
|||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project.
|
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.**
|
||||||
|
|
||||||
|
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!**
|
||||||
|
|
||||||
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
|
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
|
||||||
|
|
||||||
@@ -19,13 +21,19 @@ body:
|
|||||||
|
|
||||||
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
|
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
|
||||||
|
|
||||||
|
- **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security).
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: issue-check
|
id: issue-check
|
||||||
attributes:
|
attributes:
|
||||||
label: Check Existing Issues
|
label: Check Existing Issues
|
||||||
description: Confirm that you’ve checked for existing reports before submitting a new one.
|
description: Confirm that you’ve checked for existing reports before submitting a new one.
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and discussions.
|
- label: I have searched for any existing and/or related issues.
|
||||||
|
required: true
|
||||||
|
- label: I have searched for any existing and/or related discussions.
|
||||||
|
required: true
|
||||||
|
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of Open WebUI.
|
- label: I am using the latest version of Open WebUI.
|
||||||
required: true
|
required: true
|
||||||
@@ -47,7 +55,7 @@ body:
|
|||||||
id: open-webui-version
|
id: open-webui-version
|
||||||
attributes:
|
attributes:
|
||||||
label: Open WebUI Version
|
label: Open WebUI Version
|
||||||
description: Specify the version (e.g., v0.3.11)
|
description: Specify the version (e.g., v0.6.26)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -63,7 +71,7 @@ body:
|
|||||||
id: operating-system
|
id: operating-system
|
||||||
attributes:
|
attributes:
|
||||||
label: Operating System
|
label: Operating System
|
||||||
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
|
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -89,9 +97,20 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have included the Docker container logs.
|
- label: I have included the Docker container logs.
|
||||||
required: true
|
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
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected-behavior
|
id: expected-behavior
|
||||||
attributes:
|
attributes:
|
||||||
@@ -112,15 +131,26 @@ body:
|
|||||||
id: reproduction-steps
|
id: reproduction-steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce
|
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.**
|
||||||
|
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
|
||||||
|
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Go to '...'
|
Example (include every detail):
|
||||||
2. Click on '...'
|
1. Start with a clean Ubuntu 22.04 install.
|
||||||
3. Scroll down to '...'
|
2. Install Docker v24.0.5 and start the service.
|
||||||
4. See the error message '...'
|
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs-screenshots
|
id: logs-screenshots
|
||||||
attributes:
|
attributes:
|
||||||
@@ -142,5 +172,5 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## Note
|
## Note
|
||||||
If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
|
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
|
||||||
Thank you for contributing to Open WebUI!
|
Thank you for contributing to Open WebUI!
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
## Important Notes
|
## Important Notes
|
||||||
### Before submitting
|
### Before submitting
|
||||||
Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
|
|
||||||
|
Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
|
||||||
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
|
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
|
||||||
This will help us efficiently focus on improving the project.
|
|
||||||
|
|
||||||
|
#### Scope
|
||||||
|
|
||||||
|
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
|
||||||
|
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
|
||||||
|
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
|
||||||
|
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
|
||||||
|
|
||||||
|
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
|
||||||
|
|
||||||
|
This will help us efficiently focus on improving the project.
|
||||||
|
|
||||||
### Collaborate respectfully
|
### Collaborate respectfully
|
||||||
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
|
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
|
||||||
|
|
||||||
@@ -22,7 +33,6 @@ body:
|
|||||||
|
|
||||||
We appreciate your time and ask that you **respect ours**.
|
We appreciate your time and ask that you **respect ours**.
|
||||||
|
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||||
|
|
||||||
@@ -35,14 +45,22 @@ body:
|
|||||||
label: Check Existing Issues
|
label: Check Existing Issues
|
||||||
description: Please confirm that you've checked for existing similar requests
|
description: Please confirm that you've checked for existing similar requests
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and discussions.
|
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
|
||||||
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
id: feature-scope
|
||||||
|
attributes:
|
||||||
|
label: Verify Feature Scope
|
||||||
|
description: Please confirm the feature's scope is within the described scope
|
||||||
|
options:
|
||||||
|
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem-description
|
id: problem-description
|
||||||
attributes:
|
attributes:
|
||||||
label: Problem Description
|
label: Problem Description
|
||||||
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
||||||
placeholder: "Ex. I'm always frustrated when..."
|
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ updates:
|
|||||||
interval: monthly
|
interval: monthly
|
||||||
target-branch: 'dev'
|
target-branch: 'dev'
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: '/'
|
|
||||||
schedule:
|
|
||||||
interval: monthly
|
|
||||||
target-branch: 'dev'
|
|
||||||
|
|
||||||
- package-ecosystem: 'github-actions'
|
- package-ecosystem: 'github-actions'
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
# Pull Request Checklist
|
# Pull Request Checklist
|
||||||
|
|
||||||
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) and describe your changes before submitting a pull request.
|
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
|
||||||
|
|
||||||
|
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
|
||||||
|
|
||||||
**Before submitting, make sure you've checked the following:**
|
**Before submitting, make sure you've checked the following:**
|
||||||
|
|
||||||
- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
|
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
|
||||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request.
|
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||||
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
|
- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
|
||||||
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
|
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
|
||||||
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
|
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in the PR description**.
|
||||||
|
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||||
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||||
- **build**: Changes that affect the build system or external dependencies
|
- **build**: Changes that affect the build system or external dependencies
|
||||||
- **ci**: Changes to our continuous integration processes or workflows
|
- **ci**: Changes to our continuous integration processes or workflows
|
||||||
@@ -73,4 +76,7 @@
|
|||||||
|
|
||||||
### Contributor License Agreement
|
### Contributor License Agreement
|
||||||
|
|
||||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Check for changes in package.json
|
- name: Check for changes in package.json
|
||||||
run: |
|
run: |
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
echo "::set-output name=content::$CHANGELOG_ESCAPED"
|
echo "::set-output name=content::$CHANGELOG_ESCAPED"
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Trigger Docker build workflow
|
- name: Trigger Docker build workflow
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
github.rest.actions.createWorkflowDispatch({
|
github.rest.actions.createWorkflowDispatch({
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
lfs: true
|
lfs: true
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,18 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-main-image:
|
build-main-image:
|
||||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
include:
|
||||||
- linux/amd64
|
- platform: linux/amd64
|
||||||
- linux/arm64
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# GitHub Packages requires the entire repository name to be in lowercase
|
# GitHub Packages requires the entire repository name to be in lowercase
|
||||||
@@ -41,7 +43,7 @@ jobs:
|
|||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -111,16 +113,18 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
build-cuda-image:
|
build-cuda-image:
|
||||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
include:
|
||||||
- linux/amd64
|
- platform: linux/amd64
|
||||||
- linux/arm64
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# GitHub Packages requires the entire repository name to be in lowercase
|
# GitHub Packages requires the entire repository name to be in lowercase
|
||||||
@@ -137,8 +141,11 @@ jobs:
|
|||||||
platform=${{ matrix.platform }}
|
platform=${{ matrix.platform }}
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Delete huge unnecessary tools folder
|
||||||
|
run: rm -rf /opt/hostedtoolcache
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -210,17 +217,125 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
build-ollama-image:
|
build-cuda126-image:
|
||||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
include:
|
||||||
- linux/amd64
|
- platform: linux/amd64
|
||||||
- linux/arm64
|
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: Delete huge unnecessary tools folder
|
||||||
|
run: rm -rf /opt/hostedtoolcache
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker images (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:
|
steps:
|
||||||
# GitHub Packages requires the entire repository name to be in lowercase
|
# GitHub Packages requires the entire repository name to be in lowercase
|
||||||
@@ -238,7 +353,7 @@ jobs:
|
|||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -310,6 +425,108 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
build-slim-image:
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# GitHub Packages requires the entire repository name to be in lowercase
|
||||||
|
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||||
|
- name: Set repository and image name to lowercase
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||||
|
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: '${{ github.repository }}'
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker images (slim tag)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.FULL_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=sha,prefix=git-
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||||
|
flavor: |
|
||||||
|
latest=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
suffix=-slim,onlatest=true
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker cache
|
||||||
|
id: cache-meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.FULL_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||||
|
flavor: |
|
||||||
|
prefix=cache-slim-${{ matrix.platform }}-
|
||||||
|
latest=false
|
||||||
|
|
||||||
|
- name: Build Docker image (slim)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
id: build
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||||
|
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||||
|
build-args: |
|
||||||
|
BUILD_HASH=${{ github.sha }}
|
||||||
|
USE_SLIM=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-slim-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
merge-main-images:
|
merge-main-images:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-main-image]
|
needs: [build-main-image]
|
||||||
@@ -324,7 +541,7 @@ jobs:
|
|||||||
IMAGE_NAME: '${{ github.repository }}'
|
IMAGE_NAME: '${{ github.repository }}'
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
pattern: digests-main-*
|
pattern: digests-main-*
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
@@ -378,7 +595,7 @@ jobs:
|
|||||||
IMAGE_NAME: '${{ github.repository }}'
|
IMAGE_NAME: '${{ github.repository }}'
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
pattern: digests-cuda-*
|
pattern: digests-cuda-*
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
@@ -420,6 +637,62 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
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@v5
|
||||||
|
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:
|
merge-ollama-images:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-ollama-image]
|
needs: [build-ollama-image]
|
||||||
@@ -434,7 +707,7 @@ jobs:
|
|||||||
IMAGE_NAME: '${{ github.repository }}'
|
IMAGE_NAME: '${{ github.repository }}'
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
pattern: digests-ollama-*
|
pattern: digests-ollama-*
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
@@ -475,3 +748,59 @@ jobs:
|
|||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
merge-slim-images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-slim-image]
|
||||||
|
steps:
|
||||||
|
# GitHub Packages requires the entire repository name to be in lowercase
|
||||||
|
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||||
|
- name: Set repository and image name to lowercase
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||||
|
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: '${{ github.repository }}'
|
||||||
|
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
pattern: digests-slim-*
|
||||||
|
path: /tmp/digests
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker images (default slim tag)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.FULL_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=sha,prefix=git-
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||||
|
flavor: |
|
||||||
|
latest=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
suffix=-slim,onlatest=true
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
- 3.12.x
|
- 3.12.x
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '${{ matrix.python-version }}'
|
python-version: '${{ matrix.python-version }}'
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install --force
|
||||||
|
|
||||||
- name: Format Frontend
|
- name: Format Frontend
|
||||||
run: npm run format
|
run: npm run format
|
||||||
@@ -51,15 +51,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci --force
|
||||||
|
|
||||||
- name: Run vitest
|
- name: Run vitest
|
||||||
run: npm run test:frontend
|
run: npm run test:frontend
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Install Git
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y git
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
+4
-1
@@ -1,3 +1,5 @@
|
|||||||
|
x.py
|
||||||
|
yarn.lock
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
/build
|
/build
|
||||||
@@ -12,7 +14,8 @@ vite.config.ts.timestamp-*
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
.nvmrc
|
||||||
|
CLAUDE.md
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -5,5 +5,6 @@
|
|||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"pluginSearchDirs": ["."],
|
"pluginSearchDirs": ["."],
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
|
||||||
|
"endOfLine": "lf"
|
||||||
}
|
}
|
||||||
|
|||||||
+1218
File diff suppressed because it is too large
Load Diff
+41
-26
@@ -3,6 +3,8 @@
|
|||||||
# use build args in the docker build command with --build-arg="BUILDARG=true"
|
# use build args in the docker build command with --build-arg="BUILDARG=true"
|
||||||
ARG USE_CUDA=false
|
ARG USE_CUDA=false
|
||||||
ARG USE_OLLAMA=false
|
ARG USE_OLLAMA=false
|
||||||
|
ARG USE_SLIM=false
|
||||||
|
ARG USE_PERMISSION_HARDENING=false
|
||||||
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
|
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
|
||||||
ARG USE_CUDA_VER=cu128
|
ARG USE_CUDA_VER=cu128
|
||||||
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
|
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
|
||||||
@@ -24,10 +26,16 @@ ARG GID=0
|
|||||||
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
|
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
|
||||||
ARG BUILD_HASH
|
ARG BUILD_HASH
|
||||||
|
|
||||||
|
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
|
||||||
|
# ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# to store git revision in build
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci --force
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV APP_BUILD_HASH=${BUILD_HASH}
|
ENV APP_BUILD_HASH=${BUILD_HASH}
|
||||||
@@ -40,6 +48,8 @@ FROM python:3.11-slim-bookworm AS base
|
|||||||
ARG USE_CUDA
|
ARG USE_CUDA
|
||||||
ARG USE_OLLAMA
|
ARG USE_OLLAMA
|
||||||
ARG USE_CUDA_VER
|
ARG USE_CUDA_VER
|
||||||
|
ARG USE_SLIM
|
||||||
|
ARG USE_PERMISSION_HARDENING
|
||||||
ARG USE_EMBEDDING_MODEL
|
ARG USE_EMBEDDING_MODEL
|
||||||
ARG USE_RERANKING_MODEL
|
ARG USE_RERANKING_MODEL
|
||||||
ARG UID
|
ARG UID
|
||||||
@@ -51,6 +61,7 @@ ENV ENV=prod \
|
|||||||
# pass build args to the build
|
# pass build args to the build
|
||||||
USE_OLLAMA_DOCKER=${USE_OLLAMA} \
|
USE_OLLAMA_DOCKER=${USE_OLLAMA} \
|
||||||
USE_CUDA_DOCKER=${USE_CUDA} \
|
USE_CUDA_DOCKER=${USE_CUDA} \
|
||||||
|
USE_SLIM_DOCKER=${USE_SLIM} \
|
||||||
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
|
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
|
||||||
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
|
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
|
||||||
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
|
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
|
||||||
@@ -105,29 +116,13 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
|
|||||||
# Make sure the user has access to the app and root directory
|
# Make sure the user has access to the app and root directory
|
||||||
RUN chown -R $UID:$GID /app $HOME
|
RUN chown -R $UID:$GID /app $HOME
|
||||||
|
|
||||||
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
# Install common system dependencies
|
||||||
apt-get update && \
|
RUN apt-get update && \
|
||||||
# Install pandoc and netcat
|
apt-get install -y --no-install-recommends \
|
||||||
apt-get install -y --no-install-recommends git build-essential pandoc netcat-openbsd curl && \
|
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||||
apt-get install -y --no-install-recommends gcc python3-dev && \
|
python3-dev \
|
||||||
# for RAG OCR
|
ffmpeg libsm6 libxext6 \
|
||||||
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# 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 python dependencies
|
# install python dependencies
|
||||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||||
@@ -143,13 +138,22 @@ RUN pip3 install --no-cache-dir uv && \
|
|||||||
else \
|
else \
|
||||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
||||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||||
|
if [ "$USE_SLIM" != "true" ]; then \
|
||||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
||||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||||
fi; \
|
fi; \
|
||||||
chown -R $UID:$GID /app/backend/data/
|
fi; \
|
||||||
|
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
|
||||||
|
rm -rf /var/lib/apt/lists/*;
|
||||||
|
|
||||||
|
# Install Ollama if requested
|
||||||
|
RUN if [ "$USE_OLLAMA" = "true" ]; then \
|
||||||
|
date +%s > /tmp/ollama_build_hash && \
|
||||||
|
echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \
|
||||||
|
curl -fsSL https://ollama.com/install.sh | sh && \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
fi
|
||||||
|
|
||||||
# copy embedding weight from build
|
# copy embedding weight from build
|
||||||
# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
|
# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
|
||||||
@@ -167,6 +171,17 @@ EXPOSE 8080
|
|||||||
|
|
||||||
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
|
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
|
||||||
|
|
||||||
|
# Minimal, atomic permission hardening for OpenShift (arbitrary UID):
|
||||||
|
# - Group 0 owns /app and /root
|
||||||
|
# - Directories are group-writable and have SGID so new files inherit GID 0
|
||||||
|
RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \
|
||||||
|
set -eux; \
|
||||||
|
chgrp -R 0 /app /root || true; \
|
||||||
|
chmod -R g+rwX /app /root || true; \
|
||||||
|
find /app -type d -exec chmod g+s {} + || true; \
|
||||||
|
find /root -type d -exec chmod g+s {} + || true; \
|
||||||
|
fi
|
||||||
|
|
||||||
USER $UID:$GID
|
USER $UID:$GID
|
||||||
|
|
||||||
ARG BUILD_HASH
|
ARG BUILD_HASH
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Open WebUI Multi-License Notice
|
||||||
|
|
||||||
|
This repository contains code governed by multiple licenses based on the date and origin of contribution:
|
||||||
|
|
||||||
|
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
|
||||||
|
|
||||||
|
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
|
||||||
|
|
||||||
|
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
|
||||||
|
|
||||||
|
For details on which commits are covered by which license, refer to LICENSE_HISTORY.
|
||||||
@@ -12,10 +12,12 @@
|
|||||||
|
|
||||||
**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**.
|
**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]
|
> [!TIP]
|
||||||
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](mailto:sales@openwebui.com)**
|
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
|
||||||
>
|
>
|
||||||
> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!**
|
> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!**
|
||||||
|
|
||||||
@@ -35,24 +37,38 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
|||||||
|
|
||||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||||
|
|
||||||
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features, allowing for a more dynamic and interactive chat environment.
|
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
|
||||||
|
|
||||||
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
|
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
|
||||||
|
|
||||||
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
|
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
|
||||||
|
|
||||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support. This feature seamlessly integrates document interactions into your chat experience. You can load documents directly into the chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
|
||||||
|
|
||||||
- 🔍 **Web Search for RAG**: Perform web searches using providers like `SearXNG`, `Google PSE`, `Brave Search`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `TavilySearch`, `SearchApi` and `Bing` and inject the results directly into your chat experience.
|
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||||
|
|
||||||
|
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
|
||||||
|
|
||||||
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
|
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
|
||||||
|
|
||||||
- 🎨 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API or ComfyUI (local), and OpenAI's DALL-E (external), enriching your chat experience with dynamic visual content.
|
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
|
||||||
|
|
||||||
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
|
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
|
||||||
|
|
||||||
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
|
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
|
||||||
|
|
||||||
|
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
|
||||||
|
|
||||||
|
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
|
||||||
|
|
||||||
|
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. 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.
|
||||||
|
|
||||||
|
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
|
||||||
|
|
||||||
|
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
|
||||||
|
|
||||||
|
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
|
||||||
|
|
||||||
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
|
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
|
||||||
|
|
||||||
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
|
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
|
||||||
@@ -61,23 +77,6 @@ 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!
|
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
|
||||||
|
|
||||||
## Sponsors 🙌
|
|
||||||
|
|
||||||
#### 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>
|
|
||||||
Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
|
|
||||||
</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!
|
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!
|
||||||
@@ -171,6 +170,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.
|
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
|
### 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).
|
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).
|
||||||
@@ -222,7 +223,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
|
|||||||
|
|
||||||
## License 📜
|
## License 📜
|
||||||
|
|
||||||
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
|
This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
|
||||||
|
|
||||||
## Support 💬
|
## Support 💬
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
|
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||||
PORT="${PORT:-8080}"
|
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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ script_location = migrations
|
|||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
# defaults to the current working directory.
|
# defaults to the current working directory.
|
||||||
prepend_sys_path = .
|
prepend_sys_path = ..
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
# timezone to use when rendering the date within the migration file
|
||||||
# as well as the filename.
|
# as well as the filename.
|
||||||
|
|||||||
+1153
-122
File diff suppressed because it is too large
Load Diff
@@ -38,13 +38,14 @@ class ERROR_MESSAGES(str, Enum):
|
|||||||
ID_TAKEN = "Uh-oh! This id is already registered. Please choose another id string."
|
ID_TAKEN = "Uh-oh! This id is already registered. Please choose another id string."
|
||||||
MODEL_ID_TAKEN = "Uh-oh! This model id is already registered. Please choose another model id string."
|
MODEL_ID_TAKEN = "Uh-oh! This model id is already registered. Please choose another model id string."
|
||||||
NAME_TAG_TAKEN = "Uh-oh! This name tag is already registered. Please choose another name tag string."
|
NAME_TAG_TAKEN = "Uh-oh! This name tag is already registered. Please choose another name tag string."
|
||||||
|
MODEL_ID_TOO_LONG = "The model id is too long. Please make sure your model id is less than 256 characters long."
|
||||||
|
|
||||||
INVALID_TOKEN = (
|
INVALID_TOKEN = (
|
||||||
"Your session has expired or the token is invalid. Please sign in again."
|
"Your session has expired or the token is invalid. Please sign in again."
|
||||||
)
|
)
|
||||||
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
|
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
|
||||||
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
|
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
|
||||||
INVALID_PASSWORD = (
|
INCORRECT_PASSWORD = (
|
||||||
"The password provided is incorrect. Please check for typos and try again."
|
"The password provided is incorrect. Please check for typos and try again."
|
||||||
)
|
)
|
||||||
INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance."
|
INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance."
|
||||||
@@ -104,6 +105,10 @@ class ERROR_MESSAGES(str, Enum):
|
|||||||
)
|
)
|
||||||
FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding."
|
FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding."
|
||||||
|
|
||||||
|
INVALID_PASSWORD = lambda err="": (
|
||||||
|
err if err else "The password does not meet the required validation criteria."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TASKS(str, Enum):
|
class TASKS(str, Enum):
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -111,6 +116,7 @@ class TASKS(str, Enum):
|
|||||||
|
|
||||||
DEFAULT = lambda task="": f"{task if task else 'generation'}"
|
DEFAULT = lambda task="": f"{task if task else 'generation'}"
|
||||||
TITLE_GENERATION = "title_generation"
|
TITLE_GENERATION = "title_generation"
|
||||||
|
FOLLOW_UP_GENERATION = "follow_up_generation"
|
||||||
TAGS_GENERATION = "tags_generation"
|
TAGS_GENERATION = "tags_generation"
|
||||||
EMOJI_GENERATION = "emoji_generation"
|
EMOJI_GENERATION = "emoji_generation"
|
||||||
QUERY_GENERATION = "query_generation"
|
QUERY_GENERATION = "query_generation"
|
||||||
|
|||||||
+340
-15
@@ -5,7 +5,11 @@ import os
|
|||||||
import pkgutil
|
import pkgutil
|
||||||
import sys
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
|
from uuid import uuid4
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -15,14 +19,17 @@ from open_webui.constants import ERROR_MESSAGES
|
|||||||
# Load .env file
|
# Load .env file
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
OPEN_WEBUI_DIR = Path(__file__).parent # the path containing this file
|
# Use .resolve() to get the canonical path, removing any '..' or '.' components
|
||||||
print(OPEN_WEBUI_DIR)
|
ENV_FILE_PATH = Path(__file__).resolve()
|
||||||
|
|
||||||
BACKEND_DIR = OPEN_WEBUI_DIR.parent # the path containing this file
|
# OPEN_WEBUI_DIR should be the directory where env.py resides (open_webui/)
|
||||||
BASE_DIR = BACKEND_DIR.parent # the path containing the backend/
|
OPEN_WEBUI_DIR = ENV_FILE_PATH.parent
|
||||||
|
|
||||||
print(BACKEND_DIR)
|
# BACKEND_DIR is the parent of OPEN_WEBUI_DIR (backend/)
|
||||||
print(BASE_DIR)
|
BACKEND_DIR = OPEN_WEBUI_DIR.parent
|
||||||
|
|
||||||
|
# BASE_DIR is the parent of BACKEND_DIR (open-webui-dev/)
|
||||||
|
BASE_DIR = BACKEND_DIR.parent
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dotenv import find_dotenv, load_dotenv
|
from dotenv import find_dotenv, load_dotenv
|
||||||
@@ -132,6 +139,10 @@ else:
|
|||||||
VERSION = PACKAGE_DATA["version"]
|
VERSION = PACKAGE_DATA["version"]
|
||||||
|
|
||||||
|
|
||||||
|
DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
|
||||||
|
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
|
||||||
|
|
||||||
|
|
||||||
# Function to parse each section
|
# Function to parse each section
|
||||||
def parse_section(section):
|
def parse_section(section):
|
||||||
items = []
|
items = []
|
||||||
@@ -197,6 +208,7 @@ CHANGELOG = changelog_json
|
|||||||
|
|
||||||
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# ENABLE_FORWARD_USER_INFO_HEADERS
|
# ENABLE_FORWARD_USER_INFO_HEADERS
|
||||||
####################################
|
####################################
|
||||||
@@ -205,6 +217,11 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
|
|||||||
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
|
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Experimental feature, may be removed in future
|
||||||
|
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
||||||
|
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# WEBUI_BUILD_HASH
|
# WEBUI_BUILD_HASH
|
||||||
####################################
|
####################################
|
||||||
@@ -264,21 +281,43 @@ else:
|
|||||||
|
|
||||||
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
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://
|
# Replace the postgres:// with postgresql://
|
||||||
if "postgres://" in DATABASE_URL:
|
if "postgres://" in DATABASE_URL:
|
||||||
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
||||||
|
|
||||||
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
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 == "":
|
if DATABASE_POOL_SIZE != None:
|
||||||
DATABASE_POOL_SIZE = 0
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
||||||
except Exception:
|
except Exception:
|
||||||
DATABASE_POOL_SIZE = 0
|
DATABASE_POOL_SIZE = None
|
||||||
|
|
||||||
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
||||||
|
|
||||||
@@ -310,6 +349,21 @@ else:
|
|||||||
except Exception:
|
except Exception:
|
||||||
DATABASE_POOL_RECYCLE = 3600
|
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 = (
|
RESET_CONFIG_ON_START = (
|
||||||
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
|
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
|
||||||
)
|
)
|
||||||
@@ -318,14 +372,29 @@ ENABLE_REALTIME_CHAT_SAVE = (
|
|||||||
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
|
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# REDIS
|
# REDIS
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
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_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
|
||||||
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
|
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
|
# UVICORN WORKERS
|
||||||
####################################
|
####################################
|
||||||
@@ -345,10 +414,33 @@ except ValueError:
|
|||||||
####################################
|
####################################
|
||||||
|
|
||||||
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
|
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
|
||||||
|
|
||||||
|
ENABLE_INITIAL_ADMIN_SIGNUP = (
|
||||||
|
os.environ.get("ENABLE_INITIAL_ADMIN_SIGNUP", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
|
||||||
|
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
|
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
|
||||||
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
|
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
|
||||||
)
|
)
|
||||||
WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ENABLE_PASSWORD_VALIDATION = (
|
||||||
|
os.environ.get("ENABLE_PASSWORD_VALIDATION", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
|
||||||
|
"PASSWORD_VALIDATION_REGEX_PATTERN",
|
||||||
|
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
|
||||||
|
|
||||||
|
|
||||||
BYPASS_MODEL_ACCESS_CONTROL = (
|
BYPASS_MODEL_ACCESS_CONTROL = (
|
||||||
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
|
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
|
||||||
@@ -390,18 +482,186 @@ WEBUI_AUTH_COOKIE_SECURE = (
|
|||||||
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
||||||
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
||||||
|
|
||||||
|
ENABLE_COMPRESSION_MIDDLEWARE = (
|
||||||
|
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# OAUTH Configuration
|
||||||
|
####################################
|
||||||
|
ENABLE_OAUTH_EMAIL_FALLBACK = (
|
||||||
|
os.environ.get("ENABLE_OAUTH_EMAIL_FALLBACK", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
ENABLE_OAUTH_ID_TOKEN_COOKIE = (
|
||||||
|
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
|
||||||
|
"OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||||
|
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# SCIM Configuration
|
||||||
|
####################################
|
||||||
|
|
||||||
|
ENABLE_SCIM = (
|
||||||
|
os.environ.get("ENABLE_SCIM", 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
|
||||||
|
####################################
|
||||||
|
|
||||||
|
ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION = (
|
||||||
|
os.environ.get("ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION", "False").lower()
|
||||||
|
== "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
|
||||||
|
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
|
||||||
|
)
|
||||||
|
|
||||||
|
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
|
||||||
|
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
|
||||||
|
except Exception:
|
||||||
|
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get(
|
||||||
|
"CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == "":
|
||||||
|
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = int(
|
||||||
|
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = None
|
||||||
|
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# WEBSOCKET SUPPORT
|
||||||
|
####################################
|
||||||
|
|
||||||
ENABLE_WEBSOCKET_SUPPORT = (
|
ENABLE_WEBSOCKET_SUPPORT = (
|
||||||
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
||||||
|
|
||||||
|
WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
|
||||||
|
if WEBSOCKET_REDIS_OPTIONS == "":
|
||||||
|
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
|
||||||
|
WEBSOCKET_REDIS_OPTIONS = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
|
||||||
|
except Exception:
|
||||||
|
log.warning("Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to None")
|
||||||
|
WEBSOCKET_REDIS_OPTIONS = None
|
||||||
|
|
||||||
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
|
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_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
|
||||||
|
|
||||||
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
|
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
|
||||||
|
WEBSOCKET_SERVER_LOGGING = (
|
||||||
|
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
|
||||||
|
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
|
||||||
|
try:
|
||||||
|
WEBSOCKET_SERVER_PING_TIMEOUT = int(WEBSOCKET_SERVER_PING_TIMEOUT)
|
||||||
|
except ValueError:
|
||||||
|
WEBSOCKET_SERVER_PING_TIMEOUT = 20
|
||||||
|
|
||||||
|
WEBSOCKET_SERVER_PING_INTERVAL = os.environ.get("WEBSOCKET_SERVER_PING_INTERVAL", "25")
|
||||||
|
try:
|
||||||
|
WEBSOCKET_SERVER_PING_INTERVAL = int(WEBSOCKET_SERVER_PING_INTERVAL)
|
||||||
|
except ValueError:
|
||||||
|
WEBSOCKET_SERVER_PING_INTERVAL = 25
|
||||||
|
|
||||||
|
|
||||||
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
|
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
|
||||||
|
|
||||||
@@ -500,19 +760,32 @@ else:
|
|||||||
# OFFLINE_MODE
|
# 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"
|
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
if OFFLINE_MODE:
|
if OFFLINE_MODE:
|
||||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = False
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# AUDIT LOGGING
|
# AUDIT LOGGING
|
||||||
####################################
|
####################################
|
||||||
# Where to store log file
|
# Where to store log file
|
||||||
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
|
# Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to
|
||||||
|
# provide the whole path, like: /app/audit.log
|
||||||
|
AUDIT_LOGS_FILE_PATH = os.getenv("AUDIT_LOGS_FILE_PATH", f"{DATA_DIR}/audit.log")
|
||||||
# Maximum size of a file before rotating into a new log file
|
# 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")
|
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
|
# METADATA | REQUEST | REQUEST_RESPONSE
|
||||||
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
||||||
try:
|
try:
|
||||||
@@ -533,9 +806,34 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
|
|||||||
####################################
|
####################################
|
||||||
|
|
||||||
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
|
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 = os.environ.get(
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
"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_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
|
||||||
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||||
"OTEL_RESOURCE_ATTRIBUTES", ""
|
"OTEL_RESOURCE_ATTRIBUTES", ""
|
||||||
@@ -543,6 +841,33 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
|||||||
OTEL_TRACES_SAMPLER = os.environ.get(
|
OTEL_TRACES_SAMPLER = os.environ.get(
|
||||||
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
||||||
).lower()
|
).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
|
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||||
|
|||||||
@@ -19,16 +19,21 @@ from fastapi import (
|
|||||||
from starlette.responses import Response, StreamingResponse
|
from starlette.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
from open_webui.socket.main import (
|
from open_webui.socket.main import (
|
||||||
get_event_call,
|
get_event_call,
|
||||||
get_event_emitter,
|
get_event_emitter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.models.users import UserModel
|
||||||
from open_webui.models.functions import Functions
|
from open_webui.models.functions import Functions
|
||||||
from open_webui.models.models import Models
|
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.tools import get_tools
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ from open_webui.utils.misc import (
|
|||||||
)
|
)
|
||||||
from open_webui.utils.payload import (
|
from open_webui.utils.payload import (
|
||||||
apply_model_params_to_body_openai,
|
apply_model_params_to_body_openai,
|
||||||
apply_model_system_prompt_to_body,
|
apply_system_prompt_to_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,16 +58,23 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
|||||||
|
|
||||||
|
|
||||||
def get_function_module_by_id(request: Request, pipe_id: str):
|
def get_function_module_by_id(request: Request, pipe_id: str):
|
||||||
# Check if function is already loaded
|
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
|
||||||
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]
|
|
||||||
|
|
||||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||||
|
Valves = function_module.Valves
|
||||||
valves = Functions.get_function_valves_by_id(pipe_id)
|
valves = Functions.get_function_valves_by_id(pipe_id)
|
||||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
|
||||||
|
if valves:
|
||||||
|
try:
|
||||||
|
function_module.valves = Valves(
|
||||||
|
**{k: v for k, v in valves.items() if v is not None}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error loading valves for function {pipe_id}: {e}")
|
||||||
|
raise e
|
||||||
|
else:
|
||||||
|
function_module.valves = Valves()
|
||||||
|
|
||||||
return function_module
|
return function_module
|
||||||
|
|
||||||
|
|
||||||
@@ -71,65 +83,75 @@ async def get_function_models(request):
|
|||||||
pipe_models = []
|
pipe_models = []
|
||||||
|
|
||||||
for pipe in pipes:
|
for pipe in pipes:
|
||||||
function_module = get_function_module_by_id(request, pipe.id)
|
try:
|
||||||
|
function_module = get_function_module_by_id(request, pipe.id)
|
||||||
|
|
||||||
# Check if function is a manifold
|
has_user_valves = False
|
||||||
if hasattr(function_module, "pipes"):
|
if hasattr(function_module, "UserValves"):
|
||||||
sub_pipes = []
|
has_user_valves = True
|
||||||
|
|
||||||
# Handle pipes being a list, sync function, or async function
|
# Check if function is a manifold
|
||||||
try:
|
if hasattr(function_module, "pipes"):
|
||||||
if callable(function_module.pipes):
|
|
||||||
if asyncio.iscoroutinefunction(function_module.pipes):
|
|
||||||
sub_pipes = await function_module.pipes()
|
|
||||||
else:
|
|
||||||
sub_pipes = function_module.pipes()
|
|
||||||
else:
|
|
||||||
sub_pipes = function_module.pipes
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
sub_pipes = []
|
sub_pipes = []
|
||||||
|
|
||||||
log.debug(
|
# Handle pipes being a list, sync function, or async function
|
||||||
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
try:
|
||||||
)
|
if callable(function_module.pipes):
|
||||||
|
if asyncio.iscoroutinefunction(function_module.pipes):
|
||||||
|
sub_pipes = await function_module.pipes()
|
||||||
|
else:
|
||||||
|
sub_pipes = function_module.pipes()
|
||||||
|
else:
|
||||||
|
sub_pipes = function_module.pipes
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
sub_pipes = []
|
||||||
|
|
||||||
for p in sub_pipes:
|
log.debug(
|
||||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
||||||
sub_pipe_name = p["name"]
|
)
|
||||||
|
|
||||||
if hasattr(function_module, "name"):
|
for p in sub_pipes:
|
||||||
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||||
|
sub_pipe_name = p["name"]
|
||||||
|
|
||||||
pipe_flag = {"type": pipe.type}
|
if hasattr(function_module, "name"):
|
||||||
|
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
||||||
|
|
||||||
|
pipe_flag = {"type": pipe.type}
|
||||||
|
|
||||||
|
pipe_models.append(
|
||||||
|
{
|
||||||
|
"id": sub_pipe_id,
|
||||||
|
"name": sub_pipe_name,
|
||||||
|
"object": "model",
|
||||||
|
"created": pipe.created_at,
|
||||||
|
"owned_by": "openai",
|
||||||
|
"pipe": pipe_flag,
|
||||||
|
"has_user_valves": has_user_valves,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pipe_flag = {"type": "pipe"}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||||
|
)
|
||||||
|
|
||||||
pipe_models.append(
|
pipe_models.append(
|
||||||
{
|
{
|
||||||
"id": sub_pipe_id,
|
"id": pipe.id,
|
||||||
"name": sub_pipe_name,
|
"name": pipe.name,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"created": pipe.created_at,
|
"created": pipe.created_at,
|
||||||
"owned_by": "openai",
|
"owned_by": "openai",
|
||||||
"pipe": pipe_flag,
|
"pipe": pipe_flag,
|
||||||
|
"has_user_valves": has_user_valves,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
except Exception as e:
|
||||||
pipe_flag = {"type": "pipe"}
|
log.exception(e)
|
||||||
|
continue
|
||||||
log.debug(
|
|
||||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
|
||||||
)
|
|
||||||
|
|
||||||
pipe_models.append(
|
|
||||||
{
|
|
||||||
"id": pipe.id,
|
|
||||||
"name": pipe.name,
|
|
||||||
"object": "model",
|
|
||||||
"created": pipe.created_at,
|
|
||||||
"owned_by": "openai",
|
|
||||||
"pipe": pipe_flag,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return pipe_models
|
return pipe_models
|
||||||
|
|
||||||
@@ -220,6 +242,16 @@ async def generate_function_chat_completion(
|
|||||||
__task__ = metadata.get("task", None)
|
__task__ = metadata.get("task", None)
|
||||||
__task_body__ = metadata.get("task_body", None)
|
__task_body__ = metadata.get("task_body", None)
|
||||||
|
|
||||||
|
oauth_token = None
|
||||||
|
try:
|
||||||
|
if request.cookies.get("oauth_session_id", None):
|
||||||
|
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||||
|
user.id,
|
||||||
|
request.cookies.get("oauth_session_id", None),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error getting OAuth token: {e}")
|
||||||
|
|
||||||
extra_params = {
|
extra_params = {
|
||||||
"__event_emitter__": __event_emitter__,
|
"__event_emitter__": __event_emitter__,
|
||||||
"__event_call__": __event_call__,
|
"__event_call__": __event_call__,
|
||||||
@@ -229,16 +261,12 @@ async def generate_function_chat_completion(
|
|||||||
"__task__": __task__,
|
"__task__": __task__,
|
||||||
"__task_body__": __task_body__,
|
"__task_body__": __task_body__,
|
||||||
"__files__": files,
|
"__files__": files,
|
||||||
"__user__": {
|
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||||
"id": user.id,
|
|
||||||
"email": user.email,
|
|
||||||
"name": user.name,
|
|
||||||
"role": user.role,
|
|
||||||
},
|
|
||||||
"__metadata__": metadata,
|
"__metadata__": metadata,
|
||||||
|
"__oauth_token__": oauth_token,
|
||||||
"__request__": request,
|
"__request__": request,
|
||||||
}
|
}
|
||||||
extra_params["__tools__"] = get_tools(
|
extra_params["__tools__"] = await get_tools(
|
||||||
request,
|
request,
|
||||||
tool_ids,
|
tool_ids,
|
||||||
user,
|
user,
|
||||||
@@ -255,8 +283,11 @@ async def generate_function_chat_completion(
|
|||||||
form_data["model"] = model_info.base_model_id
|
form_data["model"] = model_info.base_model_id
|
||||||
|
|
||||||
params = model_info.params.model_dump()
|
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)
|
pipe_id = get_pipe_id(form_data)
|
||||||
function_module = get_function_module_by_id(request, pipe_id)
|
function_module = get_function_module_by_id(request, pipe_id)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -13,9 +14,10 @@ from open_webui.env import (
|
|||||||
DATABASE_POOL_RECYCLE,
|
DATABASE_POOL_RECYCLE,
|
||||||
DATABASE_POOL_SIZE,
|
DATABASE_POOL_SIZE,
|
||||||
DATABASE_POOL_TIMEOUT,
|
DATABASE_POOL_TIMEOUT,
|
||||||
|
DATABASE_ENABLE_SQLITE_WAL,
|
||||||
)
|
)
|
||||||
from peewee_migrate import Router
|
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.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||||
from sqlalchemy.pool import QueuePool, NullPool
|
from sqlalchemy.pool import QueuePool, NullPool
|
||||||
@@ -62,6 +64,9 @@ def handle_peewee_migration(DATABASE_URL):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to initialize the database connection: {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
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Properly closing the database connection
|
# Properly closing the database connection
|
||||||
@@ -76,25 +81,68 @@ handle_peewee_migration(DATABASE_URL)
|
|||||||
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = 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(
|
engine = create_engine(
|
||||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
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:
|
else:
|
||||||
if DATABASE_POOL_SIZE > 0:
|
if isinstance(DATABASE_POOL_SIZE, int):
|
||||||
engine = create_engine(
|
if DATABASE_POOL_SIZE > 0:
|
||||||
SQLALCHEMY_DATABASE_URL,
|
engine = create_engine(
|
||||||
pool_size=DATABASE_POOL_SIZE,
|
SQLALCHEMY_DATABASE_URL,
|
||||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
pool_size=DATABASE_POOL_SIZE,
|
||||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||||
pool_pre_ping=True,
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||||
poolclass=QueuePool,
|
pool_pre_ping=True,
|
||||||
)
|
poolclass=QueuePool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
engine = create_engine(
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||||
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
@@ -43,24 +44,47 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
|
|||||||
|
|
||||||
|
|
||||||
def register_connection(db_url):
|
def register_connection(db_url):
|
||||||
db = connect(db_url, unquote_password=True)
|
# Check if using SQLCipher protocol
|
||||||
if isinstance(db, PostgresqlDatabase):
|
if db_url.startswith("sqlite+sqlcipher://"):
|
||||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
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.autoconnect = True
|
||||||
db.reuse_if_open = 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:
|
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
|
return db
|
||||||
|
|||||||
+899
-156
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ from logging.config import fileConfig
|
|||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from open_webui.models.auths import Auth
|
from open_webui.models.auths import Auth
|
||||||
from open_webui.env import DATABASE_URL
|
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool, create_engine
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# 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.
|
and associate a connection with the context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
connectable = engine_from_config(
|
# Handle SQLCipher URLs
|
||||||
config.get_section(config.config_ini_section, {}),
|
if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
|
||||||
prefix="sqlalchemy.",
|
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
|
||||||
poolclass=pool.NullPool,
|
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:
|
with connectable.connect() as connection:
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
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")
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
"""Update messages and channel member table
|
||||||
|
|
||||||
|
Revision ID: 2f1211949ecc
|
||||||
|
Revises: 37f288994c47
|
||||||
|
Create Date: 2025-11-27 03:07:56.200231
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2f1211949ecc"
|
||||||
|
down_revision: Union[str, None] = "37f288994c47"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# New columns to be added to channel_member table
|
||||||
|
op.add_column("channel_member", sa.Column("status", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"channel_member",
|
||||||
|
sa.Column(
|
||||||
|
"is_active",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
default=True,
|
||||||
|
server_default=sa.sql.expression.true(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"channel_member",
|
||||||
|
sa.Column(
|
||||||
|
"is_channel_muted",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default=sa.sql.expression.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"channel_member",
|
||||||
|
sa.Column(
|
||||||
|
"is_channel_pinned",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default=sa.sql.expression.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("channel_member", sa.Column("data", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("channel_member", sa.Column("meta", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"channel_member", sa.Column("joined_at", sa.BigInteger(), nullable=False)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"channel_member", sa.Column("left_at", sa.BigInteger(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"channel_member", sa.Column("last_read_at", sa.BigInteger(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"channel_member", sa.Column("updated_at", sa.BigInteger(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# New columns to be added to message table
|
||||||
|
op.add_column(
|
||||||
|
"message",
|
||||||
|
sa.Column(
|
||||||
|
"is_pinned",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default=sa.sql.expression.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column("message", sa.Column("pinned_at", sa.BigInteger(), nullable=True))
|
||||||
|
op.add_column("message", sa.Column("pinned_by", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("channel_member", "updated_at")
|
||||||
|
op.drop_column("channel_member", "last_read_at")
|
||||||
|
|
||||||
|
op.drop_column("channel_member", "meta")
|
||||||
|
op.drop_column("channel_member", "data")
|
||||||
|
|
||||||
|
op.drop_column("channel_member", "is_channel_pinned")
|
||||||
|
op.drop_column("channel_member", "is_channel_muted")
|
||||||
|
|
||||||
|
op.drop_column("message", "pinned_by")
|
||||||
|
op.drop_column("message", "pinned_at")
|
||||||
|
op.drop_column("message", "is_pinned")
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""add_group_member_table
|
||||||
|
|
||||||
|
Revision ID: 37f288994c47
|
||||||
|
Revises: a5c220713937
|
||||||
|
Create Date: 2025-11-17 03:45:25.123939
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "37f288994c47"
|
||||||
|
down_revision: Union[str, None] = "a5c220713937"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Create new table
|
||||||
|
op.create_table(
|
||||||
|
"group_member",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"group_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("group.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||||
|
sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 2. Read existing group with user_ids JSON column
|
||||||
|
group_table = sa.Table(
|
||||||
|
"group",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG
|
||||||
|
)
|
||||||
|
|
||||||
|
results = connection.execute(
|
||||||
|
sa.select(group_table.c.id, group_table.c.user_ids)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
print(results)
|
||||||
|
|
||||||
|
# 3. Insert members into group_member table
|
||||||
|
gm_table = sa.Table(
|
||||||
|
"group_member",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("group_id", sa.Text()),
|
||||||
|
sa.Column("user_id", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.BigInteger()),
|
||||||
|
sa.Column("updated_at", sa.BigInteger()),
|
||||||
|
)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
for group_id, user_ids in results:
|
||||||
|
if not user_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(user_ids, str):
|
||||||
|
try:
|
||||||
|
user_ids = json.loads(user_ids)
|
||||||
|
except Exception:
|
||||||
|
continue # skip invalid JSON
|
||||||
|
|
||||||
|
if not isinstance(user_ids, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"group_id": group_id,
|
||||||
|
"user_id": uid,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
for uid in user_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
connection.execute(gm_table.insert(), rows)
|
||||||
|
|
||||||
|
# 4. Optionally drop the old column
|
||||||
|
with op.batch_alter_table("group") as batch:
|
||||||
|
batch.drop_column("user_ids")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Reverse: restore user_ids column
|
||||||
|
with op.batch_alter_table("group") as batch:
|
||||||
|
batch.add_column(sa.Column("user_ids", sa.JSON()))
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
gm_table = sa.Table(
|
||||||
|
"group_member",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("group_id", sa.Text()),
|
||||||
|
sa.Column("user_id", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.BigInteger()),
|
||||||
|
sa.Column("updated_at", sa.BigInteger()),
|
||||||
|
)
|
||||||
|
|
||||||
|
group_table = sa.Table(
|
||||||
|
"group",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("user_ids", sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build JSON arrays again
|
||||||
|
results = connection.execute(sa.select(group_table.c.id)).fetchall()
|
||||||
|
|
||||||
|
for (group_id,) in results:
|
||||||
|
members = connection.execute(
|
||||||
|
sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
member_ids = [m[0] for m in members]
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
group_table.update()
|
||||||
|
.where(group_table.c.id == group_id)
|
||||||
|
.values(user_ids=member_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the new table
|
||||||
|
op.drop_table("group_member")
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Add oauth_session table
|
||||||
|
|
||||||
|
Revision ID: 38d63c18f30f
|
||||||
|
Revises: 3af16a1c9fb6
|
||||||
|
Create Date: 2025-09-08 14:19:59.583921
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "38d63c18f30f"
|
||||||
|
down_revision: Union[str, None] = "3af16a1c9fb6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
columns = inspector.get_columns("user")
|
||||||
|
|
||||||
|
pk_columns = inspector.get_pk_constraint("user")["constrained_columns"]
|
||||||
|
id_column = next((col for col in columns if col["name"] == "id"), None)
|
||||||
|
|
||||||
|
if id_column and not id_column.get("unique", False):
|
||||||
|
unique_constraints = inspector.get_unique_constraints("user")
|
||||||
|
unique_columns = {tuple(u["column_names"]) for u in unique_constraints}
|
||||||
|
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
# If primary key is wrong, drop it
|
||||||
|
if pk_columns and pk_columns != ["id"]:
|
||||||
|
batch_op.drop_constraint(
|
||||||
|
inspector.get_pk_constraint("user")["name"], type_="primary"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add unique constraint if missing
|
||||||
|
if ("id",) not in unique_columns:
|
||||||
|
batch_op.create_unique_constraint("uq_user_id", ["id"])
|
||||||
|
|
||||||
|
# Re-create correct primary key
|
||||||
|
batch_op.create_primary_key("pk_user_id", ["id"])
|
||||||
|
|
||||||
|
# Create oauth_session table
|
||||||
|
op.create_table(
|
||||||
|
"oauth_session",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("provider", sa.Text(), nullable=False),
|
||||||
|
sa.Column("token", sa.Text(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
|
||||||
|
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
|
||||||
|
op.create_index(
|
||||||
|
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop indexes first
|
||||||
|
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
|
||||||
|
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
|
||||||
|
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
|
||||||
|
|
||||||
|
# Drop the table
|
||||||
|
op.drop_table("oauth_session")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""update user table
|
||||||
|
|
||||||
|
Revision ID: 3af16a1c9fb6
|
||||||
|
Revises: 018012973d35
|
||||||
|
Create Date: 2025-08-21 02:07:18.078283
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3af16a1c9fb6"
|
||||||
|
down_revision: Union[str, None] = "018012973d35"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("user", sa.Column("username", sa.String(length=50), nullable=True))
|
||||||
|
op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
|
||||||
|
op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
|
||||||
|
op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user", "username")
|
||||||
|
op.drop_column("user", "bio")
|
||||||
|
op.drop_column("user", "gender")
|
||||||
|
op.drop_column("user", "date_of_birth")
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Add knowledge_file table
|
||||||
|
|
||||||
|
Revision ID: 3e0e00844bb0
|
||||||
|
Revises: 90ef40d4714e
|
||||||
|
Create Date: 2025-12-02 06:54:19.401334
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3e0e00844bb0"
|
||||||
|
down_revision: Union[str, None] = "90ef40d4714e"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"knowledge_file",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"knowledge_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("knowledge.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"file_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("file.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
# indexes
|
||||||
|
sa.Index("ix_knowledge_file_knowledge_id", "knowledge_id"),
|
||||||
|
sa.Index("ix_knowledge_file_file_id", "file_id"),
|
||||||
|
sa.Index("ix_knowledge_file_user_id", "user_id"),
|
||||||
|
# unique constraints
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
|
||||||
|
), # prevent duplicate entries
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 2. Read existing group with user_ids JSON column
|
||||||
|
knowledge_table = sa.Table(
|
||||||
|
"knowledge",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("user_id", sa.Text()),
|
||||||
|
sa.Column("data", sa.JSON()), # JSON stored as text in SQLite + PG
|
||||||
|
)
|
||||||
|
|
||||||
|
results = connection.execute(
|
||||||
|
sa.select(
|
||||||
|
knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 3. Insert members into group_member table
|
||||||
|
kf_table = sa.Table(
|
||||||
|
"knowledge_file",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("user_id", sa.Text()),
|
||||||
|
sa.Column("knowledge_id", sa.Text()),
|
||||||
|
sa.Column("file_id", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.BigInteger()),
|
||||||
|
sa.Column("updated_at", sa.BigInteger()),
|
||||||
|
)
|
||||||
|
|
||||||
|
file_table = sa.Table(
|
||||||
|
"file",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
for knowledge_id, user_id, data in results:
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(data, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except Exception:
|
||||||
|
continue # skip invalid JSON
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_ids = data.get("file_ids", [])
|
||||||
|
|
||||||
|
for file_id in file_ids:
|
||||||
|
file_exists = connection.execute(
|
||||||
|
sa.select(file_table.c.id).where(file_table.c.id == file_id)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not file_exists:
|
||||||
|
continue # skip non-existing files
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"user_id": user_id,
|
||||||
|
"knowledge_id": knowledge_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
connection.execute(kf_table.insert().values(**row))
|
||||||
|
|
||||||
|
with op.batch_alter_table("knowledge") as batch:
|
||||||
|
batch.drop_column("data")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 1. Add back the old data column
|
||||||
|
op.add_column("knowledge", sa.Column("data", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 2. Read knowledge_file entries and reconstruct data JSON
|
||||||
|
knowledge_table = sa.Table(
|
||||||
|
"knowledge",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("data", sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
kf_table = sa.Table(
|
||||||
|
"knowledge_file",
|
||||||
|
sa.MetaData(),
|
||||||
|
sa.Column("id", sa.Text()),
|
||||||
|
sa.Column("knowledge_id", sa.Text()),
|
||||||
|
sa.Column("file_id", sa.Text()),
|
||||||
|
)
|
||||||
|
|
||||||
|
results = connection.execute(sa.select(knowledge_table.c.id)).fetchall()
|
||||||
|
|
||||||
|
for (knowledge_id,) in results:
|
||||||
|
file_ids = connection.execute(
|
||||||
|
sa.select(kf_table.c.file_id).where(kf_table.c.knowledge_id == knowledge_id)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
file_ids_list = [fid for (fid,) in file_ids]
|
||||||
|
|
||||||
|
data_json = {"file_ids": file_ids_list}
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
knowledge_table.update()
|
||||||
|
.where(knowledge_table.c.id == knowledge_id)
|
||||||
|
.values(data=data_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Drop the knowledge_file table
|
||||||
|
op.drop_table("knowledge_file")
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
"""Update channel and channel members table
|
||||||
|
|
||||||
|
Revision ID: 90ef40d4714e
|
||||||
|
Revises: b10670c03dd5
|
||||||
|
Create Date: 2025-11-30 06:33:38.790341
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import open_webui.internal.db
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "90ef40d4714e"
|
||||||
|
down_revision: Union[str, None] = "b10670c03dd5"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Update 'channel' table
|
||||||
|
op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True))
|
||||||
|
op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True))
|
||||||
|
op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
# Update 'channel_member' table
|
||||||
|
op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True))
|
||||||
|
op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 'channel_webhook' table
|
||||||
|
op.create_table(
|
||||||
|
"channel_webhook",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"channel_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("channel.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("profile_image_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("token", sa.Text(), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Downgrade 'channel' table
|
||||||
|
op.drop_column("channel", "is_private")
|
||||||
|
op.drop_column("channel", "archived_at")
|
||||||
|
op.drop_column("channel", "archived_by")
|
||||||
|
op.drop_column("channel", "deleted_at")
|
||||||
|
op.drop_column("channel", "deleted_by")
|
||||||
|
op.drop_column("channel", "updated_by")
|
||||||
|
|
||||||
|
# Downgrade 'channel_member' table
|
||||||
|
op.drop_column("channel_member", "role")
|
||||||
|
op.drop_column("channel_member", "invited_by")
|
||||||
|
op.drop_column("channel_member", "invited_at")
|
||||||
|
|
||||||
|
# Drop 'channel_webhook' table
|
||||||
|
op.drop_table("channel_webhook")
|
||||||
|
|
||||||
|
pass
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add reply_to_id column to message
|
||||||
|
|
||||||
|
Revision ID: a5c220713937
|
||||||
|
Revises: 38d63c18f30f
|
||||||
|
Create Date: 2025-09-27 02:24:18.058455
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a5c220713937"
|
||||||
|
down_revision: Union[str, None] = "38d63c18f30f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add 'reply_to_id' column to the 'message' table for replying to messages
|
||||||
|
op.add_column(
|
||||||
|
"message",
|
||||||
|
sa.Column("reply_to_id", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove 'reply_to_id' column from the 'message' table
|
||||||
|
op.drop_column("message", "reply_to_id")
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Update user table
|
||||||
|
|
||||||
|
Revision ID: b10670c03dd5
|
||||||
|
Revises: 2f1211949ecc
|
||||||
|
Create Date: 2025-11-28 04:55:31.737538
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
import open_webui.internal.db
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b10670c03dd5"
|
||||||
|
down_revision: Union[str, None] = "2f1211949ecc"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
|
||||||
|
"""
|
||||||
|
SQLite requires manual removal of any indexes referencing a column
|
||||||
|
before ALTER TABLE ... DROP COLUMN can succeed.
|
||||||
|
"""
|
||||||
|
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
|
||||||
|
|
||||||
|
for idx in indexes:
|
||||||
|
index_name = idx[1] # index name
|
||||||
|
# Get indexed columns
|
||||||
|
idx_info = conn.execute(
|
||||||
|
sa.text(f"PRAGMA index_info('{index_name}')")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
indexed_cols = [row[2] for row in idx_info] # col names
|
||||||
|
if column_name in indexed_cols:
|
||||||
|
conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_column_to_json(table: str, column: str):
|
||||||
|
conn = op.get_bind()
|
||||||
|
dialect = conn.dialect.name
|
||||||
|
|
||||||
|
# SQLite cannot ALTER COLUMN → must recreate column
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# 1. Add temporary column
|
||||||
|
op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# 2. Load old data
|
||||||
|
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
uid, raw = row
|
||||||
|
if raw is None:
|
||||||
|
parsed = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = None # fallback safe behavior
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
|
||||||
|
{"val": json.dumps(parsed) if parsed else None, "id": uid},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Drop old TEXT column
|
||||||
|
op.drop_column(table, column)
|
||||||
|
|
||||||
|
# 4. Rename new JSON column → original name
|
||||||
|
op.alter_column(table, f"{column}_json", new_column_name=column)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# PostgreSQL supports direct CAST
|
||||||
|
op.alter_column(
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
type_=sa.JSON(),
|
||||||
|
postgresql_using=f"{column}::json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_column_to_text(table: str, column: str):
|
||||||
|
conn = op.get_bind()
|
||||||
|
dialect = conn.dialect.name
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||||
|
|
||||||
|
for uid, raw in rows:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
|
||||||
|
{"val": json.dumps(raw) if raw else None, "id": uid},
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column(table, column)
|
||||||
|
op.alter_column(table, f"{column}_text", new_column_name=column)
|
||||||
|
|
||||||
|
else:
|
||||||
|
op.alter_column(
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
type_=sa.Text(),
|
||||||
|
postgresql_using=f"to_json({column})::text",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column("user", sa.Column("timezone", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True))
|
||||||
|
op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True))
|
||||||
|
op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# Convert info (TEXT/JSONField) → JSON
|
||||||
|
_convert_column_to_json("user", "info")
|
||||||
|
# Convert settings (TEXT/JSONField) → JSON
|
||||||
|
_convert_column_to_json("user", "settings")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"api_key",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True, unique=True),
|
||||||
|
sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")),
|
||||||
|
sa.Column("key", sa.Text(), unique=True, nullable=False),
|
||||||
|
sa.Column("data", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
users = conn.execute(
|
||||||
|
sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for uid, oauth_sub in users:
|
||||||
|
if oauth_sub:
|
||||||
|
# Example formats supported:
|
||||||
|
# provider@sub
|
||||||
|
# plain sub (stored as {"oidc": {"sub": sub}})
|
||||||
|
if "@" in oauth_sub:
|
||||||
|
provider, sub = oauth_sub.split("@", 1)
|
||||||
|
else:
|
||||||
|
provider, sub = "oidc", oauth_sub
|
||||||
|
|
||||||
|
oauth_json = json.dumps({provider: {"sub": sub}})
|
||||||
|
conn.execute(
|
||||||
|
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
|
||||||
|
{"oauth": oauth_json, "id": uid},
|
||||||
|
)
|
||||||
|
|
||||||
|
users_with_keys = conn.execute(
|
||||||
|
sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')
|
||||||
|
).fetchall()
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
for uid, api_key in users_with_keys:
|
||||||
|
if api_key:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
|
||||||
|
VALUES (:id, :user_id, :key, :created_at, :updated_at)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": f"key_{uid}",
|
||||||
|
"user_id": uid,
|
||||||
|
"key": api_key,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
_drop_sqlite_indexes_for_column("user", "api_key", conn)
|
||||||
|
_drop_sqlite_indexes_for_column("user", "oauth_sub", conn)
|
||||||
|
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.drop_column("api_key")
|
||||||
|
batch_op.drop_column("oauth_sub")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# --- 1. Restore old oauth_sub column ---
|
||||||
|
op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
users = conn.execute(
|
||||||
|
sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for uid, oauth in users:
|
||||||
|
try:
|
||||||
|
data = json.loads(oauth)
|
||||||
|
provider = list(data.keys())[0]
|
||||||
|
sub = data[provider].get("sub")
|
||||||
|
oauth_sub = f"{provider}@{sub}"
|
||||||
|
except Exception:
|
||||||
|
oauth_sub = None
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
|
||||||
|
{"oauth_sub": oauth_sub, "id": uid},
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("user", "oauth")
|
||||||
|
|
||||||
|
# --- 2. Restore api_key field ---
|
||||||
|
op.add_column("user", sa.Column("api_key", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# Restore values from api_key
|
||||||
|
keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall()
|
||||||
|
for uid, key in keys:
|
||||||
|
conn.execute(
|
||||||
|
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
|
||||||
|
{"key": key, "id": uid},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop new table
|
||||||
|
op.drop_table("api_key")
|
||||||
|
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.drop_column("profile_banner_image_url")
|
||||||
|
batch_op.drop_column("timezone")
|
||||||
|
|
||||||
|
batch_op.drop_column("presence_state")
|
||||||
|
batch_op.drop_column("status_emoji")
|
||||||
|
batch_op.drop_column("status_message")
|
||||||
|
batch_op.drop_column("status_expires_at")
|
||||||
|
|
||||||
|
# Convert info (JSON) → TEXT
|
||||||
|
_convert_column_to_text("user", "info")
|
||||||
|
# Convert settings (JSON) → TEXT
|
||||||
|
_convert_column_to_text("user", "settings")
|
||||||
@@ -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")
|
||||||
@@ -3,11 +3,10 @@ import uuid
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.models.users import UserModel, Users
|
from open_webui.models.users import UserModel, UserProfileImageResponse, Users
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import Boolean, Column, String, Text
|
from sqlalchemy import Boolean, Column, String, Text
|
||||||
from open_webui.utils.auth import verify_password
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||||
@@ -20,7 +19,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
class Auth(Base):
|
class Auth(Base):
|
||||||
__tablename__ = "auth"
|
__tablename__ = "auth"
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True, unique=True)
|
||||||
email = Column(String)
|
email = Column(String)
|
||||||
password = Column(Text)
|
password = Column(Text)
|
||||||
active = Column(Boolean)
|
active = Column(Boolean)
|
||||||
@@ -47,15 +46,7 @@ class ApiKey(BaseModel):
|
|||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class SigninResponse(Token, UserProfileImageResponse):
|
||||||
id: str
|
|
||||||
email: str
|
|
||||||
name: str
|
|
||||||
role: str
|
|
||||||
profile_image_url: str
|
|
||||||
|
|
||||||
|
|
||||||
class SigninResponse(Token, UserResponse):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -73,11 +64,6 @@ class ProfileImageUrlForm(BaseModel):
|
|||||||
profile_image_url: str
|
profile_image_url: str
|
||||||
|
|
||||||
|
|
||||||
class UpdateProfileForm(BaseModel):
|
|
||||||
profile_image_url: str
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePasswordForm(BaseModel):
|
class UpdatePasswordForm(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
@@ -102,7 +88,7 @@ class AuthsTable:
|
|||||||
name: str,
|
name: str,
|
||||||
profile_image_url: str = "/user.png",
|
profile_image_url: str = "/user.png",
|
||||||
role: str = "pending",
|
role: str = "pending",
|
||||||
oauth_sub: Optional[str] = None,
|
oauth: Optional[dict] = None,
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
log.info("insert_new_auth")
|
log.info("insert_new_auth")
|
||||||
@@ -116,7 +102,7 @@ class AuthsTable:
|
|||||||
db.add(result)
|
db.add(result)
|
||||||
|
|
||||||
user = Users.insert_new_user(
|
user = Users.insert_new_user(
|
||||||
id, name, email, profile_image_url, role, oauth_sub
|
id, name, email, profile_image_url, role, oauth=oauth
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -127,14 +113,20 @@ class AuthsTable:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
|
def authenticate_user(
|
||||||
|
self, email: str, verify_password: callable
|
||||||
|
) -> Optional[UserModel]:
|
||||||
log.info(f"authenticate_user: {email}")
|
log.info(f"authenticate_user: {email}")
|
||||||
|
|
||||||
|
user = Users.get_user_by_email(email)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
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 auth:
|
||||||
if verify_password(password, auth.password):
|
if verify_password(auth.password):
|
||||||
user = Users.get_user_by_id(auth.id)
|
|
||||||
return user
|
return user
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@@ -155,8 +147,8 @@ class AuthsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def authenticate_user_by_trusted_header(self, email: str) -> Optional[UserModel]:
|
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
|
||||||
log.info(f"authenticate_user_by_trusted_header: {email}")
|
log.info(f"authenticate_user_by_email: {email}")
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import uuid
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.models.groups import Groups
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case, cast
|
||||||
from sqlalchemy import or_, func, select, and_, text
|
from sqlalchemy import or_, func, select, and_, text
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
|
||||||
@@ -19,19 +22,30 @@ from sqlalchemy.sql import exists
|
|||||||
class Channel(Base):
|
class Channel(Base):
|
||||||
__tablename__ = "channel"
|
__tablename__ = "channel"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
type = Column(Text, nullable=True)
|
type = Column(Text, nullable=True)
|
||||||
|
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Used to indicate if the channel is private (for 'group' type channels)
|
||||||
|
is_private = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
data = Column(JSON, nullable=True)
|
data = Column(JSON, nullable=True)
|
||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
access_control = Column(JSON, nullable=True)
|
access_control = Column(JSON, nullable=True)
|
||||||
|
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
|
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
updated_by = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
archived_at = Column(BigInteger, nullable=True)
|
||||||
|
archived_by = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
deleted_at = Column(BigInteger, nullable=True)
|
||||||
|
deleted_by = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class ChannelModel(BaseModel):
|
class ChannelModel(BaseModel):
|
||||||
@@ -39,17 +53,122 @@ class ChannelModel(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
is_private: Optional[bool] = None
|
||||||
|
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch (time_ns)
|
||||||
updated_at: int # timestamp in epoch
|
|
||||||
|
updated_at: int # timestamp in epoch (time_ns)
|
||||||
|
updated_by: Optional[str] = None
|
||||||
|
|
||||||
|
archived_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
archived_by: Optional[str] = None
|
||||||
|
|
||||||
|
deleted_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
deleted_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelMember(Base):
|
||||||
|
__tablename__ = "channel_member"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
|
channel_id = Column(Text, nullable=False)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
role = Column(Text, nullable=True)
|
||||||
|
status = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
is_channel_muted = Column(Boolean, nullable=False, default=False)
|
||||||
|
is_channel_pinned = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
data = Column(JSON, nullable=True)
|
||||||
|
meta = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
invited_at = Column(BigInteger, nullable=True)
|
||||||
|
invited_by = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
joined_at = Column(BigInteger)
|
||||||
|
left_at = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
last_read_at = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(BigInteger)
|
||||||
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelMemberModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
channel_id: str
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
role: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
is_channel_muted: bool = False
|
||||||
|
is_channel_pinned: bool = False
|
||||||
|
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
|
invited_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
invited_by: Optional[str] = None
|
||||||
|
|
||||||
|
joined_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
left_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
last_read_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
created_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
updated_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelWebhook(Base):
|
||||||
|
__tablename__ = "channel_webhook"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
|
channel_id = Column(Text, nullable=False)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
profile_image_url = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
token = Column(Text, nullable=False)
|
||||||
|
last_used_at = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
updated_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelWebhookModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
channel_id: str
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
name: str
|
||||||
|
profile_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
token: str
|
||||||
|
last_used_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
created_at: int # timestamp in epoch (time_ns)
|
||||||
|
updated_at: int # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@@ -57,23 +176,95 @@ class ChannelModel(BaseModel):
|
|||||||
####################
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelResponse(ChannelModel):
|
||||||
|
is_manager: bool = False
|
||||||
|
write_access: bool = False
|
||||||
|
|
||||||
|
user_count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ChannelForm(BaseModel):
|
class ChannelForm(BaseModel):
|
||||||
name: str
|
name: str = ""
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
is_private: Optional[bool] = None
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
group_ids: Optional[list[str]] = None
|
||||||
|
user_ids: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateChannelForm(ChannelForm):
|
||||||
|
type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChannelTable:
|
class ChannelTable:
|
||||||
|
|
||||||
|
def _collect_unique_user_ids(
|
||||||
|
self,
|
||||||
|
invited_by: str,
|
||||||
|
user_ids: Optional[list[str]] = None,
|
||||||
|
group_ids: Optional[list[str]] = None,
|
||||||
|
) -> set[str]:
|
||||||
|
"""
|
||||||
|
Collect unique user ids from:
|
||||||
|
- invited_by
|
||||||
|
- user_ids
|
||||||
|
- each group in group_ids
|
||||||
|
Returns a set for efficient SQL diffing.
|
||||||
|
"""
|
||||||
|
users = set(user_ids or [])
|
||||||
|
users.add(invited_by)
|
||||||
|
|
||||||
|
for group_id in group_ids or []:
|
||||||
|
users.update(Groups.get_group_user_ids_by_id(group_id))
|
||||||
|
|
||||||
|
return users
|
||||||
|
|
||||||
|
def _create_membership_models(
|
||||||
|
self,
|
||||||
|
channel_id: str,
|
||||||
|
invited_by: str,
|
||||||
|
user_ids: set[str],
|
||||||
|
) -> list[ChannelMember]:
|
||||||
|
"""
|
||||||
|
Takes a set of NEW user IDs (already filtered to exclude existing members).
|
||||||
|
Returns ORM ChannelMember objects to be added.
|
||||||
|
"""
|
||||||
|
now = int(time.time_ns())
|
||||||
|
memberships = []
|
||||||
|
|
||||||
|
for uid in user_ids:
|
||||||
|
model = ChannelMemberModel(
|
||||||
|
**{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"user_id": uid,
|
||||||
|
"status": "joined",
|
||||||
|
"is_active": True,
|
||||||
|
"is_channel_muted": False,
|
||||||
|
"is_channel_pinned": False,
|
||||||
|
"invited_at": now,
|
||||||
|
"invited_by": invited_by,
|
||||||
|
"joined_at": now,
|
||||||
|
"left_at": None,
|
||||||
|
"last_read_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
memberships.append(ChannelMember(**model.model_dump()))
|
||||||
|
|
||||||
|
return memberships
|
||||||
|
|
||||||
def insert_new_channel(
|
def insert_new_channel(
|
||||||
self, type: Optional[str], form_data: ChannelForm, user_id: str
|
self, form_data: CreateChannelForm, user_id: str
|
||||||
) -> Optional[ChannelModel]:
|
) -> Optional[ChannelModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
channel = ChannelModel(
|
channel = ChannelModel(
|
||||||
**{
|
**{
|
||||||
**form_data.model_dump(),
|
**form_data.model_dump(),
|
||||||
"type": type,
|
"type": form_data.type if form_data.type else None,
|
||||||
"name": form_data.name.lower(),
|
"name": form_data.name.lower(),
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -81,9 +272,21 @@ class ChannelTable:
|
|||||||
"updated_at": int(time.time_ns()),
|
"updated_at": int(time.time_ns()),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
new_channel = Channel(**channel.model_dump())
|
new_channel = Channel(**channel.model_dump())
|
||||||
|
|
||||||
|
if form_data.type in ["group", "dm"]:
|
||||||
|
users = self._collect_unique_user_ids(
|
||||||
|
invited_by=user_id,
|
||||||
|
user_ids=form_data.user_ids,
|
||||||
|
group_ids=form_data.group_ids,
|
||||||
|
)
|
||||||
|
memberships = self._create_membership_models(
|
||||||
|
channel_id=new_channel.id,
|
||||||
|
invited_by=user_id,
|
||||||
|
user_ids=users,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add_all(memberships)
|
||||||
db.add(new_channel)
|
db.add(new_channel)
|
||||||
db.commit()
|
db.commit()
|
||||||
return channel
|
return channel
|
||||||
@@ -93,16 +296,346 @@ class ChannelTable:
|
|||||||
channels = db.query(Channel).all()
|
channels = db.query(Channel).all()
|
||||||
return [ChannelModel.model_validate(channel) for channel in channels]
|
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||||
|
|
||||||
def get_channels_by_user_id(
|
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||||
self, user_id: str, permission: str = "read"
|
group_ids = filter.get("group_ids", [])
|
||||||
) -> list[ChannelModel]:
|
user_id = filter.get("user_id")
|
||||||
channels = self.get_channels()
|
|
||||||
return [
|
dialect_name = db.bind.dialect.name
|
||||||
channel
|
|
||||||
for channel in channels
|
# Public access
|
||||||
if channel.user_id == user_id
|
conditions = []
|
||||||
or has_access(user_id, permission, channel.access_control)
|
if group_ids or user_id:
|
||||||
]
|
conditions.extend(
|
||||||
|
[
|
||||||
|
Channel.access_control.is_(None),
|
||||||
|
cast(Channel.access_control, String) == "null",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# User-level permission
|
||||||
|
if user_id:
|
||||||
|
conditions.append(Channel.user_id == user_id)
|
||||||
|
|
||||||
|
# Group-level permission
|
||||||
|
if group_ids:
|
||||||
|
group_conditions = []
|
||||||
|
for gid in group_ids:
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
group_conditions.append(
|
||||||
|
Channel.access_control[permission]["group_ids"].contains([gid])
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
group_conditions.append(
|
||||||
|
cast(
|
||||||
|
Channel.access_control[permission]["group_ids"],
|
||||||
|
JSONB,
|
||||||
|
).contains([gid])
|
||||||
|
)
|
||||||
|
conditions.append(or_(*group_conditions))
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query = query.filter(or_(*conditions))
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
user_group_ids = [
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
membership_channels = (
|
||||||
|
db.query(Channel)
|
||||||
|
.join(ChannelMember, Channel.id == ChannelMember.channel_id)
|
||||||
|
.filter(
|
||||||
|
Channel.deleted_at.is_(None),
|
||||||
|
Channel.archived_at.is_(None),
|
||||||
|
Channel.type.in_(["group", "dm"]),
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
ChannelMember.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = db.query(Channel).filter(
|
||||||
|
Channel.deleted_at.is_(None),
|
||||||
|
Channel.archived_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Channel.type.is_(None), # True NULL/None
|
||||||
|
Channel.type == "", # Empty string
|
||||||
|
and_(Channel.type != "group", Channel.type != "dm"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
query = self._has_permission(
|
||||||
|
db, query, {"user_id": user_id, "group_ids": user_group_ids}
|
||||||
|
)
|
||||||
|
|
||||||
|
standard_channels = query.all()
|
||||||
|
|
||||||
|
all_channels = membership_channels + standard_channels
|
||||||
|
return [ChannelModel.model_validate(c) for c in all_channels]
|
||||||
|
|
||||||
|
def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
# Ensure uniqueness in case a list with duplicates is passed
|
||||||
|
unique_user_ids = list(set(user_ids))
|
||||||
|
|
||||||
|
match_count = func.sum(
|
||||||
|
case(
|
||||||
|
(ChannelMember.user_id.in_(unique_user_ids), 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subquery = (
|
||||||
|
db.query(ChannelMember.channel_id)
|
||||||
|
.group_by(ChannelMember.channel_id)
|
||||||
|
# 1. Channel must have exactly len(user_ids) members
|
||||||
|
.having(func.count(ChannelMember.user_id) == len(unique_user_ids))
|
||||||
|
# 2. All those members must be in unique_user_ids
|
||||||
|
.having(match_count == len(unique_user_ids))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = (
|
||||||
|
db.query(Channel)
|
||||||
|
.filter(
|
||||||
|
Channel.id.in_(subquery),
|
||||||
|
Channel.type == "dm",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
return ChannelModel.model_validate(channel) if channel else None
|
||||||
|
|
||||||
|
def add_members_to_channel(
|
||||||
|
self,
|
||||||
|
channel_id: str,
|
||||||
|
invited_by: str,
|
||||||
|
user_ids: Optional[list[str]] = None,
|
||||||
|
group_ids: Optional[list[str]] = None,
|
||||||
|
) -> list[ChannelMemberModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
# 1. Collect all user_ids including groups + inviter
|
||||||
|
requested_users = self._collect_unique_user_ids(
|
||||||
|
invited_by, user_ids, group_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_users = {
|
||||||
|
row.user_id
|
||||||
|
for row in db.query(ChannelMember.user_id)
|
||||||
|
.filter(ChannelMember.channel_id == channel_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
new_user_ids = requested_users - existing_users
|
||||||
|
if not new_user_ids:
|
||||||
|
return [] # Nothing to add
|
||||||
|
|
||||||
|
new_memberships = self._create_membership_models(
|
||||||
|
channel_id, invited_by, new_user_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add_all(new_memberships)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return [
|
||||||
|
ChannelMemberModel.model_validate(membership)
|
||||||
|
for membership in new_memberships
|
||||||
|
]
|
||||||
|
|
||||||
|
def remove_members_from_channel(
|
||||||
|
self,
|
||||||
|
channel_id: str,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> int:
|
||||||
|
with get_db() as db:
|
||||||
|
result = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id.in_(user_ids),
|
||||||
|
)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return result # number of rows deleted
|
||||||
|
|
||||||
|
def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
# Check if the user is the creator of the channel
|
||||||
|
# or has a 'manager' role in ChannelMember
|
||||||
|
channel = db.query(Channel).filter(Channel.id == channel_id).first()
|
||||||
|
if channel and channel.user_id == user_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
ChannelMember.role == "manager",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return membership is not None
|
||||||
|
|
||||||
|
def join_channel(
|
||||||
|
self, channel_id: str, user_id: str
|
||||||
|
) -> Optional[ChannelMemberModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
# Check if the membership already exists
|
||||||
|
existing_membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_membership:
|
||||||
|
return ChannelMemberModel.model_validate(existing_membership)
|
||||||
|
|
||||||
|
# Create new membership
|
||||||
|
channel_member = ChannelMemberModel(
|
||||||
|
**{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"status": "joined",
|
||||||
|
"is_active": True,
|
||||||
|
"is_channel_muted": False,
|
||||||
|
"is_channel_pinned": False,
|
||||||
|
"joined_at": int(time.time_ns()),
|
||||||
|
"left_at": None,
|
||||||
|
"last_read_at": int(time.time_ns()),
|
||||||
|
"created_at": int(time.time_ns()),
|
||||||
|
"updated_at": int(time.time_ns()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
new_membership = ChannelMember(**channel_member.model_dump())
|
||||||
|
|
||||||
|
db.add(new_membership)
|
||||||
|
db.commit()
|
||||||
|
return channel_member
|
||||||
|
|
||||||
|
def leave_channel(self, channel_id: str, user_id: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not membership:
|
||||||
|
return False
|
||||||
|
|
||||||
|
membership.status = "left"
|
||||||
|
membership.is_active = False
|
||||||
|
membership.left_at = int(time.time_ns())
|
||||||
|
membership.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_member_by_channel_and_user_id(
|
||||||
|
self, channel_id: str, user_id: str
|
||||||
|
) -> Optional[ChannelMemberModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return ChannelMemberModel.model_validate(membership) if membership else None
|
||||||
|
|
||||||
|
def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
memberships = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(ChannelMember.channel_id == channel_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ChannelMemberModel.model_validate(membership)
|
||||||
|
for membership in memberships
|
||||||
|
]
|
||||||
|
|
||||||
|
def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not membership:
|
||||||
|
return False
|
||||||
|
|
||||||
|
membership.is_channel_pinned = is_pinned
|
||||||
|
membership.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not membership:
|
||||||
|
return False
|
||||||
|
|
||||||
|
membership.last_read_at = int(time.time_ns())
|
||||||
|
membership.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_member_active_status(
|
||||||
|
self, channel_id: str, user_id: str, is_active: bool
|
||||||
|
) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not membership:
|
||||||
|
return False
|
||||||
|
|
||||||
|
membership.is_active = is_active
|
||||||
|
membership.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_user_channel_member(self, channel_id: str, user_id: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
membership = (
|
||||||
|
db.query(ChannelMember)
|
||||||
|
.filter(
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
ChannelMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return membership is not None
|
||||||
|
|
||||||
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
|
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -118,8 +651,12 @@ class ChannelTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
channel.name = form_data.name
|
channel.name = form_data.name
|
||||||
|
channel.description = form_data.description
|
||||||
|
channel.is_private = form_data.is_private
|
||||||
|
|
||||||
channel.data = form_data.data
|
channel.data = form_data.data
|
||||||
channel.meta = form_data.meta
|
channel.meta = form_data.meta
|
||||||
|
|
||||||
channel.access_control = form_data.access_control
|
channel.access_control = form_data.access_control
|
||||||
channel.updated_at = int(time.time_ns())
|
channel.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ from typing import Optional
|
|||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.models.tags import TagModel, Tag, Tags
|
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 open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
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 import or_, func, select, and_, text
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
from sqlalchemy.sql.expression import bindparam
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Chat DB Schema
|
# Chat DB Schema
|
||||||
@@ -24,7 +26,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
class Chat(Base):
|
class Chat(Base):
|
||||||
__tablename__ = "chat"
|
__tablename__ = "chat"
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True, unique=True)
|
||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
title = Column(Text)
|
title = Column(Text)
|
||||||
chat = Column(JSON)
|
chat = Column(JSON)
|
||||||
@@ -39,6 +41,20 @@ class Chat(Base):
|
|||||||
meta = Column(JSON, server_default="{}")
|
meta = Column(JSON, server_default="{}")
|
||||||
folder_id = Column(Text, nullable=True)
|
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):
|
class ChatModel(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -66,12 +82,18 @@ class ChatModel(BaseModel):
|
|||||||
|
|
||||||
class ChatForm(BaseModel):
|
class ChatForm(BaseModel):
|
||||||
chat: dict
|
chat: dict
|
||||||
|
folder_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatImportForm(ChatForm):
|
class ChatImportForm(ChatForm):
|
||||||
meta: Optional[dict] = {}
|
meta: Optional[dict] = {}
|
||||||
pinned: Optional[bool] = False
|
pinned: Optional[bool] = False
|
||||||
folder_id: Optional[str] = None
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatsImportForm(BaseModel):
|
||||||
|
chats: list[ChatImportForm]
|
||||||
|
|
||||||
|
|
||||||
class ChatTitleMessagesForm(BaseModel):
|
class ChatTitleMessagesForm(BaseModel):
|
||||||
@@ -105,6 +127,43 @@ class ChatTitleIdResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ChatTable:
|
class ChatTable:
|
||||||
|
def _clean_null_bytes(self, obj):
|
||||||
|
"""
|
||||||
|
Recursively remove actual null bytes (\x00) and unicode escape \\u0000
|
||||||
|
from strings inside dict/list structures.
|
||||||
|
Safe for JSON objects.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return obj.replace("\x00", "").replace("\u0000", "")
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {k: self._clean_null_bytes(v) for k, v in obj.items()}
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [self._clean_null_bytes(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _sanitize_chat_row(self, chat_item):
|
||||||
|
"""
|
||||||
|
Clean a Chat SQLAlchemy model's title + chat JSON,
|
||||||
|
and return True if anything changed.
|
||||||
|
"""
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# Clean title
|
||||||
|
if chat_item.title:
|
||||||
|
cleaned = self._clean_null_bytes(chat_item.title)
|
||||||
|
if cleaned != chat_item.title:
|
||||||
|
chat_item.title = cleaned
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Clean JSON
|
||||||
|
if chat_item.chat:
|
||||||
|
cleaned = self._clean_null_bytes(chat_item.chat)
|
||||||
|
if cleaned != chat_item.chat:
|
||||||
|
chat_item.chat = cleaned
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]:
|
def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
id = str(uuid.uuid4())
|
id = str(uuid.uuid4())
|
||||||
@@ -112,59 +171,76 @@ class ChatTable:
|
|||||||
**{
|
**{
|
||||||
"id": id,
|
"id": id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"title": (
|
"title": self._clean_null_bytes(
|
||||||
form_data.chat["title"]
|
form_data.chat["title"]
|
||||||
if "title" in form_data.chat
|
if "title" in form_data.chat
|
||||||
else "New Chat"
|
else "New Chat"
|
||||||
),
|
),
|
||||||
"chat": form_data.chat,
|
"chat": self._clean_null_bytes(form_data.chat),
|
||||||
"created_at": int(time.time()),
|
|
||||||
"updated_at": int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = Chat(**chat.model_dump())
|
|
||||||
db.add(result)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(result)
|
|
||||||
return ChatModel.model_validate(result) if result else None
|
|
||||||
|
|
||||||
def import_chat(
|
|
||||||
self, user_id: str, form_data: ChatImportForm
|
|
||||||
) -> Optional[ChatModel]:
|
|
||||||
with get_db() as db:
|
|
||||||
id = str(uuid.uuid4())
|
|
||||||
chat = ChatModel(
|
|
||||||
**{
|
|
||||||
"id": id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"title": (
|
|
||||||
form_data.chat["title"]
|
|
||||||
if "title" in form_data.chat
|
|
||||||
else "New Chat"
|
|
||||||
),
|
|
||||||
"chat": form_data.chat,
|
|
||||||
"meta": form_data.meta,
|
|
||||||
"pinned": form_data.pinned,
|
|
||||||
"folder_id": form_data.folder_id,
|
"folder_id": form_data.folder_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
result = Chat(**chat.model_dump())
|
chat_item = Chat(**chat.model_dump())
|
||||||
db.add(result)
|
db.add(chat_item)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
db.refresh(chat_item)
|
||||||
return ChatModel.model_validate(result) if result else None
|
return ChatModel.model_validate(chat_item) if chat_item else None
|
||||||
|
|
||||||
|
def _chat_import_form_to_chat_model(
|
||||||
|
self, user_id: str, form_data: ChatImportForm
|
||||||
|
) -> ChatModel:
|
||||||
|
id = str(uuid.uuid4())
|
||||||
|
chat = ChatModel(
|
||||||
|
**{
|
||||||
|
"id": id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"title": self._clean_null_bytes(
|
||||||
|
form_data.chat["title"] if "title" in form_data.chat else "New Chat"
|
||||||
|
),
|
||||||
|
"chat": self._clean_null_bytes(form_data.chat),
|
||||||
|
"meta": form_data.meta,
|
||||||
|
"pinned": form_data.pinned,
|
||||||
|
"folder_id": form_data.folder_id,
|
||||||
|
"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())
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return chat
|
||||||
|
|
||||||
|
def import_chats(
|
||||||
|
self, user_id: str, chat_import_forms: list[ChatImportForm]
|
||||||
|
) -> list[ChatModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
chats = []
|
||||||
|
|
||||||
|
for form_data in chat_import_forms:
|
||||||
|
chat = self._chat_import_form_to_chat_model(user_id, form_data)
|
||||||
|
chats.append(Chat(**chat.model_dump()))
|
||||||
|
|
||||||
|
db.add_all(chats)
|
||||||
|
db.commit()
|
||||||
|
return [ChatModel.model_validate(chat) for chat in chats]
|
||||||
|
|
||||||
def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
|
def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
chat_item = db.get(Chat, id)
|
chat_item = db.get(Chat, id)
|
||||||
chat_item.chat = chat
|
chat_item.chat = self._clean_null_bytes(chat)
|
||||||
chat_item.title = chat["title"] if "title" in chat else "New Chat"
|
chat_item.title = (
|
||||||
|
self._clean_null_bytes(chat["title"])
|
||||||
|
if "title" in chat
|
||||||
|
else "New Chat"
|
||||||
|
)
|
||||||
|
|
||||||
chat_item.updated_at = int(time.time())
|
chat_item.updated_at = int(time.time())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(chat_item)
|
db.refresh(chat_item)
|
||||||
|
|
||||||
@@ -209,7 +285,7 @@ class ChatTable:
|
|||||||
|
|
||||||
return chat.chat.get("title", "New Chat")
|
return chat.chat.get("title", "New Chat")
|
||||||
|
|
||||||
def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
|
def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
|
||||||
chat = self.get_chat_by_id(id)
|
chat = self.get_chat_by_id(id)
|
||||||
if chat is None:
|
if chat is None:
|
||||||
return None
|
return None
|
||||||
@@ -232,6 +308,10 @@ class ChatTable:
|
|||||||
if chat is None:
|
if chat is None:
|
||||||
return 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
|
chat = chat.chat
|
||||||
history = chat.get("history", {})
|
history = chat.get("history", {})
|
||||||
|
|
||||||
@@ -266,6 +346,27 @@ class ChatTable:
|
|||||||
chat["history"] = history
|
chat["history"] = history
|
||||||
return self.update_chat_by_id(id, chat)
|
return self.update_chat_by_id(id, chat)
|
||||||
|
|
||||||
|
def add_message_files_by_id_and_message_id(
|
||||||
|
self, id: str, message_id: str, files: list[dict]
|
||||||
|
) -> list[dict]:
|
||||||
|
chat = self.get_chat_by_id(id)
|
||||||
|
if chat is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
chat = chat.chat
|
||||||
|
history = chat.get("history", {})
|
||||||
|
|
||||||
|
message_files = []
|
||||||
|
|
||||||
|
if message_id in history.get("messages", {}):
|
||||||
|
message_files = history["messages"][message_id].get("files", [])
|
||||||
|
message_files = message_files + files
|
||||||
|
history["messages"][message_id]["files"] = message_files
|
||||||
|
|
||||||
|
chat["history"] = history
|
||||||
|
self.update_chat_by_id(id, chat)
|
||||||
|
return message_files
|
||||||
|
|
||||||
def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
|
def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
# Get the existing chat to share
|
# Get the existing chat to share
|
||||||
@@ -280,6 +381,9 @@ class ChatTable:
|
|||||||
"user_id": f"shared-{chat_id}",
|
"user_id": f"shared-{chat_id}",
|
||||||
"title": chat.title,
|
"title": chat.title,
|
||||||
"chat": chat.chat,
|
"chat": chat.chat,
|
||||||
|
"meta": chat.meta,
|
||||||
|
"pinned": chat.pinned,
|
||||||
|
"folder_id": chat.folder_id,
|
||||||
"created_at": chat.created_at,
|
"created_at": chat.created_at,
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
}
|
}
|
||||||
@@ -311,7 +415,9 @@ class ChatTable:
|
|||||||
|
|
||||||
shared_chat.title = chat.title
|
shared_chat.title = chat.title
|
||||||
shared_chat.chat = chat.chat
|
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())
|
shared_chat.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(shared_chat)
|
db.refresh(shared_chat)
|
||||||
@@ -330,6 +436,15 @@ class ChatTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def update_chat_share_id_by_id(
|
def update_chat_share_id_by_id(
|
||||||
self, id: str, share_id: Optional[str]
|
self, id: str, share_id: Optional[str]
|
||||||
) -> Optional[ChatModel]:
|
) -> Optional[ChatModel]:
|
||||||
@@ -360,6 +475,7 @@ class ChatTable:
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
chat = db.get(Chat, id)
|
chat = db.get(Chat, id)
|
||||||
chat.archived = not chat.archived
|
chat.archived = not chat.archived
|
||||||
|
chat.folder_id = None
|
||||||
chat.updated_at = int(time.time())
|
chat.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(chat)
|
db.refresh(chat)
|
||||||
@@ -377,22 +493,50 @@ class ChatTable:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_archived_chat_list_by_user_id(
|
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]:
|
) -> list[ChatModel]:
|
||||||
|
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_chats = (
|
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
|
||||||
db.query(Chat)
|
|
||||||
.filter_by(user_id=user_id, archived=True)
|
if filter:
|
||||||
.order_by(Chat.updated_at.desc())
|
query_key = filter.get("query")
|
||||||
# .limit(limit).offset(skip)
|
if query_key:
|
||||||
.all()
|
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
|
||||||
)
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by and direction:
|
||||||
|
if not getattr(Chat, order_by, None):
|
||||||
|
raise ValueError("Invalid order_by field")
|
||||||
|
|
||||||
|
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]
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||||
|
|
||||||
def get_chat_list_by_user_id(
|
def get_chat_list_by_user_id(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
filter: Optional[dict] = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[ChatModel]:
|
) -> list[ChatModel]:
|
||||||
@@ -401,7 +545,23 @@ class ChatTable:
|
|||||||
if not include_archived:
|
if not include_archived:
|
||||||
query = query.filter_by(archived=False)
|
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:
|
if skip:
|
||||||
query = query.offset(skip)
|
query = query.offset(skip)
|
||||||
@@ -415,12 +575,19 @@ class ChatTable:
|
|||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
include_folders: bool = False,
|
||||||
|
include_pinned: bool = False,
|
||||||
skip: Optional[int] = None,
|
skip: Optional[int] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
) -> list[ChatTitleIdResponse]:
|
) -> list[ChatTitleIdResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
|
query = db.query(Chat).filter_by(user_id=user_id)
|
||||||
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
|
||||||
|
if not include_folders:
|
||||||
|
query = query.filter_by(folder_id=None)
|
||||||
|
|
||||||
|
if not include_pinned:
|
||||||
|
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||||
|
|
||||||
if not include_archived:
|
if not include_archived:
|
||||||
query = query.filter_by(archived=False)
|
query = query.filter_by(archived=False)
|
||||||
@@ -465,8 +632,15 @@ class ChatTable:
|
|||||||
def get_chat_by_id(self, id: str) -> Optional[ChatModel]:
|
def get_chat_by_id(self, id: str) -> Optional[ChatModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
chat = db.get(Chat, id)
|
chat_item = db.get(Chat, id)
|
||||||
return ChatModel.model_validate(chat)
|
if chat_item is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._sanitize_chat_row(chat_item):
|
||||||
|
db.commit()
|
||||||
|
db.refresh(chat_item)
|
||||||
|
|
||||||
|
return ChatModel.model_validate(chat_item)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -539,10 +713,12 @@ class ChatTable:
|
|||||||
"""
|
"""
|
||||||
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
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:
|
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(" ")
|
search_text_words = search_text.split(" ")
|
||||||
|
|
||||||
@@ -553,8 +729,45 @@ class ChatTable:
|
|||||||
if word.startswith("tag:")
|
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 = [
|
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)
|
search_text = " ".join(search_text_words)
|
||||||
@@ -562,30 +775,41 @@ class ChatTable:
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
query = db.query(Chat).filter(Chat.user_id == user_id)
|
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)
|
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())
|
query = query.order_by(Chat.updated_at.desc())
|
||||||
|
|
||||||
# Check if the database dialect is either 'sqlite' or 'postgresql'
|
# Check if the database dialect is either 'sqlite' or 'postgresql'
|
||||||
dialect_name = db.bind.dialect.name
|
dialect_name = db.bind.dialect.name
|
||||||
if dialect_name == "sqlite":
|
if dialect_name == "sqlite":
|
||||||
# SQLite case: using JSON1 extension for JSON searching
|
# 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(
|
query = query.filter(
|
||||||
(
|
or_(
|
||||||
Chat.title.ilike(
|
Chat.title.ilike(bindparam("title_key")), sqlite_content_clause
|
||||||
f"%{search_text}%"
|
).params(title_key=f"%{search_text}%", content_key=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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are any tags to filter, it should have all the tags
|
# Check if there are any tags to filter, it should have all the tags
|
||||||
@@ -619,23 +843,32 @@ class ChatTable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif dialect_name == "postgresql":
|
elif dialect_name == "postgresql":
|
||||||
# PostgreSQL relies on proper JSON query for search
|
# PostgreSQL doesn't allow null bytes in text. We filter those out by checking
|
||||||
query = query.filter(
|
# the JSON representation for \u0000 before attempting text extraction
|
||||||
(
|
|
||||||
Chat.title.ilike(
|
# Safety filter: JSON field must not contain \u0000
|
||||||
f"%{search_text}%"
|
query = query.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'"))
|
||||||
) # Case-insensitive search in title
|
|
||||||
| text(
|
# Safety filter: title must not contain actual null bytes
|
||||||
"""
|
query = query.filter(text("Chat.title::text NOT LIKE '%\\x00%'"))
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
postgres_content_sql = """
|
||||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
EXISTS (
|
||||||
WHERE LOWER(message->>'content') LIKE '%' || :search_text || '%'
|
SELECT 1
|
||||||
)
|
FROM json_array_elements(Chat.chat->'messages') AS message
|
||||||
"""
|
WHERE json_typeof(message->'content') = 'string'
|
||||||
)
|
AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
|
||||||
).params(search_text=search_text)
|
|
||||||
)
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
postgres_content_clause = text(postgres_content_sql)
|
||||||
|
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Chat.title.ilike(bindparam("title_key")),
|
||||||
|
postgres_content_clause,
|
||||||
|
)
|
||||||
|
).params(title_key=f"%{search_text}%", content_key=search_text.lower())
|
||||||
|
|
||||||
# Check if there are any tags to filter, it should have all the tags
|
# Check if there are any tags to filter, it should have all the tags
|
||||||
if "none" in tag_ids:
|
if "none" in tag_ids:
|
||||||
@@ -680,7 +913,7 @@ class ChatTable:
|
|||||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||||
|
|
||||||
def get_chats_by_folder_id_and_user_id(
|
def get_chats_by_folder_id_and_user_id(
|
||||||
self, folder_id: str, user_id: str
|
self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
|
||||||
) -> list[ChatModel]:
|
) -> list[ChatModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
|
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
|
||||||
@@ -689,6 +922,11 @@ class ChatTable:
|
|||||||
|
|
||||||
query = query.order_by(Chat.updated_at.desc())
|
query = query.order_by(Chat.updated_at.desc())
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
all_chats = query.all()
|
all_chats = query.all()
|
||||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||||
|
|
||||||
@@ -818,6 +1056,16 @@ class ChatTable:
|
|||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Chat).filter_by(user_id=user_id)
|
||||||
|
|
||||||
|
query = query.filter_by(folder_id=folder_id)
|
||||||
|
count = query.count()
|
||||||
|
|
||||||
|
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||||
|
return count
|
||||||
|
|
||||||
def delete_tag_by_id_and_user_id_and_tag_name(
|
def delete_tag_by_id_and_user_id_and_tag_name(
|
||||||
self, id: str, user_id: str, tag_name: str
|
self, id: str, user_id: str, tag_name: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -895,6 +1143,20 @@ class ChatTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def move_chats_by_user_id_and_folder_id(
|
||||||
|
self, user_id: str, folder_id: str, new_folder_id: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update(
|
||||||
|
{"folder_id": new_folder_id}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def delete_shared_chats_by_user_id(self, user_id: str) -> bool:
|
def delete_shared_chats_by_user_id(self, user_id: str) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.users import User
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
@@ -21,7 +21,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
|
|
||||||
class Feedback(Base):
|
class Feedback(Base):
|
||||||
__tablename__ = "feedback"
|
__tablename__ = "feedback"
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
version = Column(BigInteger, default=0)
|
version = Column(BigInteger, default=0)
|
||||||
type = Column(Text)
|
type = Column(Text)
|
||||||
@@ -92,6 +92,28 @@ class FeedbackForm(BaseModel):
|
|||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
role: str = "pending"
|
||||||
|
|
||||||
|
last_active_at: int # timestamp in epoch
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackUserResponse(FeedbackResponse):
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackListResponse(BaseModel):
|
||||||
|
items: list[FeedbackUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class FeedbackTable:
|
class FeedbackTable:
|
||||||
def insert_new_feedback(
|
def insert_new_feedback(
|
||||||
self, user_id: str, form_data: FeedbackForm
|
self, user_id: str, form_data: FeedbackForm
|
||||||
@@ -143,6 +165,70 @@ class FeedbackTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_feedback_items(
|
||||||
|
self, filter: dict = {}, skip: int = 0, limit: int = 30
|
||||||
|
) -> FeedbackListResponse:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by == "username":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(User.name.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(User.name.desc())
|
||||||
|
elif order_by == "model_id":
|
||||||
|
# it's stored in feedback.data['model_id']
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(
|
||||||
|
Feedback.data["model_id"].as_string().asc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.order_by(
|
||||||
|
Feedback.data["model_id"].as_string().desc()
|
||||||
|
)
|
||||||
|
elif order_by == "rating":
|
||||||
|
# it's stored in feedback.data['rating']
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(
|
||||||
|
Feedback.data["rating"].as_string().asc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.order_by(
|
||||||
|
Feedback.data["rating"].as_string().desc()
|
||||||
|
)
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Feedback.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Feedback.updated_at.desc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = query.order_by(Feedback.created_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
feedbacks = []
|
||||||
|
for feedback, user in items:
|
||||||
|
feedback_model = FeedbackModel.model_validate(feedback)
|
||||||
|
user_model = UserResponse.model_validate(user)
|
||||||
|
feedbacks.append(
|
||||||
|
FeedbackUserResponse(**feedback_model.model_dump(), user=user_model)
|
||||||
|
)
|
||||||
|
|
||||||
|
return FeedbackListResponse(items=feedbacks, total=total)
|
||||||
|
|
||||||
def get_all_feedbacks(self) -> list[FeedbackModel]:
|
def get_all_feedbacks(self) -> list[FeedbackModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
|
|
||||||
class File(Base):
|
class File(Base):
|
||||||
__tablename__ = "file"
|
__tablename__ = "file"
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True, unique=True)
|
||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
hash = Column(Text, nullable=True)
|
hash = Column(Text, nullable=True)
|
||||||
|
|
||||||
@@ -82,6 +82,7 @@ class FileModelResponse(BaseModel):
|
|||||||
|
|
||||||
class FileMetadataResponse(BaseModel):
|
class FileMetadataResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
hash: Optional[str] = None
|
||||||
meta: dict
|
meta: dict
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
@@ -97,6 +98,12 @@ class FileForm(BaseModel):
|
|||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpdateForm(BaseModel):
|
||||||
|
hash: Optional[str] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class FilesTable:
|
class FilesTable:
|
||||||
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
|
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -130,12 +137,24 @@ class FilesTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
try:
|
||||||
|
file = db.query(File).filter_by(id=id, user_id=user_id).first()
|
||||||
|
if file:
|
||||||
|
return FileModel.model_validate(file)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
|
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
try:
|
try:
|
||||||
file = db.get(File, id)
|
file = db.get(File, id)
|
||||||
return FileMetadataResponse(
|
return FileMetadataResponse(
|
||||||
id=file.id,
|
id=file.id,
|
||||||
|
hash=file.hash,
|
||||||
meta=file.meta,
|
meta=file.meta,
|
||||||
created_at=file.created_at,
|
created_at=file.created_at,
|
||||||
updated_at=file.updated_at,
|
updated_at=file.updated_at,
|
||||||
@@ -147,6 +166,15 @@ class FilesTable:
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
return [FileModel.model_validate(file) for file in db.query(File).all()]
|
return [FileModel.model_validate(file) for file in db.query(File).all()]
|
||||||
|
|
||||||
|
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
|
||||||
|
file = self.get_file_by_id(id)
|
||||||
|
if not file:
|
||||||
|
return False
|
||||||
|
if file.user_id == user_id:
|
||||||
|
return True
|
||||||
|
# Implement additional access control logic here as needed
|
||||||
|
return False
|
||||||
|
|
||||||
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
|
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
return [
|
return [
|
||||||
@@ -162,11 +190,14 @@ class FilesTable:
|
|||||||
return [
|
return [
|
||||||
FileMetadataResponse(
|
FileMetadataResponse(
|
||||||
id=file.id,
|
id=file.id,
|
||||||
|
hash=file.hash,
|
||||||
meta=file.meta,
|
meta=file.meta,
|
||||||
created_at=file.created_at,
|
created_at=file.created_at,
|
||||||
updated_at=file.updated_at,
|
updated_at=file.updated_at,
|
||||||
)
|
)
|
||||||
for file in db.query(File)
|
for file in db.query(
|
||||||
|
File.id, File.hash, File.meta, File.created_at, File.updated_at
|
||||||
|
)
|
||||||
.filter(File.id.in_(ids))
|
.filter(File.id.in_(ids))
|
||||||
.order_by(File.updated_at.desc())
|
.order_by(File.updated_at.desc())
|
||||||
.all()
|
.all()
|
||||||
@@ -179,6 +210,29 @@ class FilesTable:
|
|||||||
for file in db.query(File).filter_by(user_id=user_id).all()
|
for file in db.query(File).filter_by(user_id=user_id).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def update_file_by_id(
|
||||||
|
self, id: str, form_data: FileUpdateForm
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
try:
|
||||||
|
file = db.query(File).filter_by(id=id).first()
|
||||||
|
|
||||||
|
if form_data.hash is not None:
|
||||||
|
file.hash = form_data.hash
|
||||||
|
|
||||||
|
if form_data.data is not None:
|
||||||
|
file.data = {**(file.data if file.data else {}), **form_data.data}
|
||||||
|
|
||||||
|
if form_data.meta is not None:
|
||||||
|
file.meta = {**(file.meta if file.meta else {}), **form_data.meta}
|
||||||
|
|
||||||
|
file.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
return FileModel.model_validate(file)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error updating file completely by id: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]:
|
def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
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.internal.db import Base, get_db
|
||||||
from open_webui.models.chats import Chats
|
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
|
||||||
from open_webui.utils.access_control import get_permissions
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -23,12 +23,13 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
|
|
||||||
class Folder(Base):
|
class Folder(Base):
|
||||||
__tablename__ = "folder"
|
__tablename__ = "folder"
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
parent_id = Column(Text, nullable=True)
|
parent_id = Column(Text, nullable=True)
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
items = Column(JSON, nullable=True)
|
items = Column(JSON, nullable=True)
|
||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
|
data = Column(JSON, nullable=True)
|
||||||
is_expanded = Column(Boolean, default=False)
|
is_expanded = Column(Boolean, default=False)
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
@@ -41,6 +42,7 @@ class FolderModel(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
items: Optional[dict] = None
|
items: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
is_expanded: bool = False
|
is_expanded: bool = False
|
||||||
created_at: int
|
created_at: int
|
||||||
updated_at: int
|
updated_at: int
|
||||||
@@ -48,6 +50,20 @@ class FolderModel(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderMetadataResponse(BaseModel):
|
||||||
|
icon: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderNameIdResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
meta: Optional[FolderMetadataResponse] = None
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
is_expanded: bool = False
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Forms
|
# Forms
|
||||||
####################
|
####################
|
||||||
@@ -55,12 +71,21 @@ class FolderModel(BaseModel):
|
|||||||
|
|
||||||
class FolderForm(BaseModel):
|
class FolderForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class FolderUpdateForm(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class FolderTable:
|
class FolderTable:
|
||||||
def insert_new_folder(
|
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]:
|
) -> Optional[FolderModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
id = str(uuid.uuid4())
|
id = str(uuid.uuid4())
|
||||||
@@ -68,7 +93,7 @@ class FolderTable:
|
|||||||
**{
|
**{
|
||||||
"id": id,
|
"id": id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"name": name,
|
**(form_data.model_dump(exclude_unset=True) or {}),
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
@@ -103,7 +128,7 @@ class FolderTable:
|
|||||||
|
|
||||||
def get_children_folders_by_id_and_user_id(
|
def get_children_folders_by_id_and_user_id(
|
||||||
self, id: str, user_id: str
|
self, id: str, user_id: str
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[list[FolderModel]]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
folders = []
|
folders = []
|
||||||
@@ -187,8 +212,8 @@ class FolderTable:
|
|||||||
log.error(f"update_folder: {e}")
|
log.error(f"update_folder: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
def update_folder_name_by_id_and_user_id(
|
def update_folder_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, name: str
|
self, id: str, user_id: str, form_data: FolderUpdateForm
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -197,18 +222,35 @@ class FolderTable:
|
|||||||
if not folder:
|
if not folder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
form_data = form_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
existing_folder = (
|
existing_folder = (
|
||||||
db.query(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()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_folder:
|
if existing_folder and existing_folder.id != id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
folder.name = name
|
folder.name = form_data.get("name", folder.name)
|
||||||
folder.updated_at = int(time.time())
|
if "data" in form_data:
|
||||||
|
folder.data = {
|
||||||
|
**(folder.data or {}),
|
||||||
|
**form_data["data"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if "meta" in form_data:
|
||||||
|
folder.meta = {
|
||||||
|
**(folder.meta or {}),
|
||||||
|
**form_data["meta"],
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return FolderModel.model_validate(folder)
|
return FolderModel.model_validate(folder)
|
||||||
@@ -236,18 +278,15 @@ class FolderTable:
|
|||||||
log.error(f"update_folder: {e}")
|
log.error(f"update_folder: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
def delete_folder_by_id_and_user_id(
|
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
|
||||||
self, id: str, user_id: str, delete_chats=True
|
|
||||||
) -> bool:
|
|
||||||
try:
|
try:
|
||||||
|
folder_ids = []
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
|
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
|
||||||
if not folder:
|
if not folder:
|
||||||
return False
|
return folder_ids
|
||||||
|
|
||||||
if delete_chats:
|
folder_ids.append(folder.id)
|
||||||
# Delete all chats in the folder
|
|
||||||
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
|
|
||||||
|
|
||||||
# Delete all children folders
|
# Delete all children folders
|
||||||
def delete_children(folder):
|
def delete_children(folder):
|
||||||
@@ -255,12 +294,9 @@ class FolderTable:
|
|||||||
folder.id, user_id
|
folder.id, user_id
|
||||||
)
|
)
|
||||||
for folder_child in folder_children:
|
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)
|
delete_children(folder_child)
|
||||||
|
folder_ids.append(folder_child.id)
|
||||||
|
|
||||||
folder = db.query(Folder).filter_by(id=folder_child.id).first()
|
folder = db.query(Folder).filter_by(id=folder_child.id).first()
|
||||||
db.delete(folder)
|
db.delete(folder)
|
||||||
@@ -269,10 +305,62 @@ class FolderTable:
|
|||||||
delete_children(folder)
|
delete_children(folder)
|
||||||
db.delete(folder)
|
db.delete(folder)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return folder_ids
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"delete_folder: {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()
|
Folders = FolderTable()
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import time
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from open_webui.internal.db import Base, JSONField, get_db
|
||||||
from open_webui.models.users import Users
|
from open_webui.models.users import Users, UserModel
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from pydantic import BaseModel, ConfigDict
|
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 = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||||
@@ -19,7 +19,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
class Function(Base):
|
class Function(Base):
|
||||||
__tablename__ = "function"
|
__tablename__ = "function"
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True, unique=True)
|
||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
type = Column(Text)
|
type = Column(Text)
|
||||||
@@ -31,10 +31,13 @@ class Function(Base):
|
|||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
|
|
||||||
|
__table_args__ = (Index("is_global_idx", "is_global"),)
|
||||||
|
|
||||||
|
|
||||||
class FunctionMeta(BaseModel):
|
class FunctionMeta(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
manifest: Optional[dict] = {}
|
manifest: Optional[dict] = {}
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class FunctionModel(BaseModel):
|
class FunctionModel(BaseModel):
|
||||||
@@ -52,11 +55,31 @@ class FunctionModel(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionWithValvesModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
content: str
|
||||||
|
meta: FunctionMeta
|
||||||
|
valves: Optional[dict] = None
|
||||||
|
is_active: bool = False
|
||||||
|
is_global: bool = False
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Forms
|
# Forms
|
||||||
####################
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionUserResponse(FunctionModel):
|
||||||
|
user: Optional[UserModel] = None
|
||||||
|
|
||||||
|
|
||||||
class FunctionResponse(BaseModel):
|
class FunctionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
@@ -108,6 +131,54 @@ class FunctionsTable:
|
|||||||
log.exception(f"Error creating a new function: {e}")
|
log.exception(f"Error creating a new function: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def sync_functions(
|
||||||
|
self, user_id: str, functions: list[FunctionWithValvesModel]
|
||||||
|
) -> list[FunctionWithValvesModel]:
|
||||||
|
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
# 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]:
|
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -116,19 +187,48 @@ class FunctionsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_functions(self, active_only=False) -> list[FunctionModel]:
|
def get_functions(
|
||||||
|
self, active_only=False, include_valves=False
|
||||||
|
) -> list[FunctionModel | FunctionWithValvesModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
if active_only:
|
if active_only:
|
||||||
|
functions = db.query(Function).filter_by(is_active=True).all()
|
||||||
|
|
||||||
|
else:
|
||||||
|
functions = db.query(Function).all()
|
||||||
|
|
||||||
|
if include_valves:
|
||||||
return [
|
return [
|
||||||
FunctionModel.model_validate(function)
|
FunctionWithValvesModel.model_validate(function)
|
||||||
for function in db.query(Function).filter_by(is_active=True).all()
|
for function in functions
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
return [
|
return [
|
||||||
FunctionModel.model_validate(function)
|
FunctionModel.model_validate(function) for function in functions
|
||||||
for function in db.query(Function).all()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_function_list(self) -> list[FunctionUserResponse]:
|
||||||
|
with get_db() as db:
|
||||||
|
functions = db.query(Function).order_by(Function.updated_at.desc()).all()
|
||||||
|
user_ids = list(set(func.user_id for func in functions))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
|
return [
|
||||||
|
FunctionUserResponse.model_validate(
|
||||||
|
{
|
||||||
|
**FunctionModel.model_validate(func).model_dump(),
|
||||||
|
"user": (
|
||||||
|
users_dict.get(func.user_id).model_dump()
|
||||||
|
if func.user_id in users_dict
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for func in functions
|
||||||
|
]
|
||||||
|
|
||||||
def get_functions_by_type(
|
def get_functions_by_type(
|
||||||
self, type: str, active_only=False
|
self, type: str, active_only=False
|
||||||
) -> list[FunctionModel]:
|
) -> list[FunctionModel]:
|
||||||
@@ -187,6 +287,29 @@ class FunctionsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def update_function_metadata_by_id(
|
||||||
|
self, id: str, metadata: dict
|
||||||
|
) -> Optional[FunctionModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
try:
|
||||||
|
function = db.get(Function, id)
|
||||||
|
|
||||||
|
if function:
|
||||||
|
if function.meta:
|
||||||
|
function.meta = {**function.meta, **metadata}
|
||||||
|
else:
|
||||||
|
function.meta = metadata
|
||||||
|
|
||||||
|
function.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
db.refresh(function)
|
||||||
|
return self.get_function_by_id(id)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error updating function metadata by id {id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_user_valves_by_id_and_user_id(
|
def get_user_valves_by_id_and_user_id(
|
||||||
self, id: str, user_id: str
|
self, id: str, user_id: str
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
@@ -202,9 +325,7 @@ class FunctionsTable:
|
|||||||
|
|
||||||
return user_settings["functions"]["valves"].get(id, {})
|
return user_settings["functions"]["valves"].get(id, {})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(
|
log.exception(f"Error getting user values by id {id} and user id {user_id}")
|
||||||
f"Error getting user values by id {id} and user id {user_id}: {e}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_valves_by_id_and_user_id(
|
def update_user_valves_by_id_and_user_id(
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ from open_webui.models.files import FileMetadataResponse
|
|||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON, func
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Column,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
JSON,
|
||||||
|
and_,
|
||||||
|
func,
|
||||||
|
ForeignKey,
|
||||||
|
cast,
|
||||||
|
or_,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -35,14 +46,12 @@ class Group(Base):
|
|||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
|
|
||||||
permissions = Column(JSON, nullable=True)
|
permissions = Column(JSON, nullable=True)
|
||||||
user_ids = Column(JSON, nullable=True)
|
|
||||||
|
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
class GroupModel(BaseModel):
|
class GroupModel(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|
||||||
@@ -53,38 +62,62 @@ class GroupModel(BaseModel):
|
|||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
permissions: Optional[dict] = None
|
permissions: Optional[dict] = None
|
||||||
user_ids: list[str] = []
|
|
||||||
|
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(Base):
|
||||||
|
__tablename__ = "group_member"
|
||||||
|
|
||||||
|
id = Column(Text, unique=True, primary_key=True)
|
||||||
|
group_id = Column(
|
||||||
|
Text,
|
||||||
|
ForeignKey("group.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
created_at = Column(BigInteger, nullable=True)
|
||||||
|
updated_at = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
group_id: str
|
||||||
|
user_id: str
|
||||||
|
created_at: Optional[int] = None # timestamp in epoch
|
||||||
|
updated_at: Optional[int] = None # timestamp in epoch
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Forms
|
# Forms
|
||||||
####################
|
####################
|
||||||
|
|
||||||
|
|
||||||
class GroupResponse(BaseModel):
|
class GroupResponse(GroupModel):
|
||||||
id: str
|
member_count: Optional[int] = None
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
permissions: Optional[dict] = None
|
|
||||||
data: Optional[dict] = None
|
|
||||||
meta: Optional[dict] = None
|
|
||||||
user_ids: list[str] = []
|
|
||||||
created_at: int # timestamp in epoch
|
|
||||||
updated_at: int # timestamp in epoch
|
|
||||||
|
|
||||||
|
|
||||||
class GroupForm(BaseModel):
|
class GroupForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
permissions: Optional[dict] = None
|
permissions: Optional[dict] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdsForm(BaseModel):
|
||||||
|
user_ids: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class GroupUpdateForm(GroupForm):
|
class GroupUpdateForm(GroupForm):
|
||||||
user_ids: Optional[list[str]] = None
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GroupListResponse(BaseModel):
|
||||||
|
items: list[GroupResponse] = []
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
class GroupTable:
|
class GroupTable:
|
||||||
@@ -115,24 +148,94 @@ class GroupTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_groups(self) -> list[GroupModel]:
|
def get_all_groups(self) -> list[GroupModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
groups = db.query(Group).order_by(Group.updated_at.desc()).all()
|
||||||
|
return [GroupModel.model_validate(group) for group in groups]
|
||||||
|
|
||||||
|
def get_groups(self, filter) -> list[GroupResponse]:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Group)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
if "query" in filter:
|
||||||
|
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
|
||||||
|
if "member_id" in filter:
|
||||||
|
query = query.join(
|
||||||
|
GroupMember, GroupMember.group_id == Group.id
|
||||||
|
).filter(GroupMember.user_id == filter["member_id"])
|
||||||
|
|
||||||
|
if "share" in filter:
|
||||||
|
share_value = filter["share"]
|
||||||
|
json_share = Group.data["config"]["share"].as_boolean()
|
||||||
|
|
||||||
|
if share_value:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Group.data.is_(None),
|
||||||
|
json_share.is_(None),
|
||||||
|
json_share == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.filter(
|
||||||
|
and_(Group.data.isnot(None), json_share == False)
|
||||||
|
)
|
||||||
|
groups = query.order_by(Group.updated_at.desc()).all()
|
||||||
return [
|
return [
|
||||||
GroupModel.model_validate(group)
|
GroupResponse.model_validate(
|
||||||
for group in db.query(Group).order_by(Group.updated_at.desc()).all()
|
{
|
||||||
|
**GroupModel.model_validate(group).model_dump(),
|
||||||
|
"member_count": self.get_group_member_count_by_id(group.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def search_groups(
|
||||||
|
self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30
|
||||||
|
) -> GroupListResponse:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Group)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
if "query" in filter:
|
||||||
|
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
|
||||||
|
if "member_id" in filter:
|
||||||
|
query = query.join(
|
||||||
|
GroupMember, GroupMember.group_id == Group.id
|
||||||
|
).filter(GroupMember.user_id == filter["member_id"])
|
||||||
|
|
||||||
|
if "share" in filter:
|
||||||
|
# 'share' is stored in data JSON, support both sqlite and postgres
|
||||||
|
share_value = filter["share"]
|
||||||
|
print("Filtering by share:", share_value)
|
||||||
|
query = query.filter(
|
||||||
|
Group.data.op("->>")("share") == str(share_value)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
query = query.order_by(Group.updated_at.desc())
|
||||||
|
groups = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
GroupResponse.model_validate(
|
||||||
|
**GroupModel.model_validate(group).model_dump(),
|
||||||
|
member_count=self.get_group_member_count_by_id(group.id),
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
|
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
return [
|
return [
|
||||||
GroupModel.model_validate(group)
|
GroupModel.model_validate(group)
|
||||||
for group in db.query(Group)
|
for group in db.query(Group)
|
||||||
.filter(
|
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||||
func.json_array_length(Group.user_ids) > 0
|
.filter(GroupMember.user_id == user_id)
|
||||||
) # Ensure array exists
|
|
||||||
.filter(
|
|
||||||
Group.user_ids.cast(String).like(f'%"{user_id}"%')
|
|
||||||
) # String-based check
|
|
||||||
.order_by(Group.updated_at.desc())
|
.order_by(Group.updated_at.desc())
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
@@ -145,12 +248,63 @@ class GroupTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_group_user_ids_by_id(self, id: str) -> Optional[str]:
|
def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]:
|
||||||
group = self.get_group_by_id(id)
|
with get_db() as db:
|
||||||
if group:
|
members = (
|
||||||
return group.user_ids
|
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
|
||||||
else:
|
)
|
||||||
return None
|
|
||||||
|
if not members:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return [m[0] for m in members]
|
||||||
|
|
||||||
|
def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str]]:
|
||||||
|
with get_db() as db:
|
||||||
|
members = (
|
||||||
|
db.query(GroupMember.group_id, GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id.in_(group_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
group_user_ids: dict[str, list[str]] = {
|
||||||
|
group_id: [] for group_id in group_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
for group_id, user_id in members:
|
||||||
|
group_user_ids[group_id].append(user_id)
|
||||||
|
|
||||||
|
return group_user_ids
|
||||||
|
|
||||||
|
def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None:
|
||||||
|
with get_db() as db:
|
||||||
|
# Delete existing members
|
||||||
|
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
|
||||||
|
|
||||||
|
# Insert new members
|
||||||
|
now = int(time.time())
|
||||||
|
new_members = [
|
||||||
|
GroupMember(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
group_id=group_id,
|
||||||
|
user_id=user_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
for user_id in user_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
db.add_all(new_members)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def get_group_member_count_by_id(self, id: str) -> int:
|
||||||
|
with get_db() as db:
|
||||||
|
count = (
|
||||||
|
db.query(func.count(GroupMember.user_id))
|
||||||
|
.filter(GroupMember.group_id == id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
return count if count else 0
|
||||||
|
|
||||||
def update_group_by_id(
|
def update_group_by_id(
|
||||||
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
|
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
|
||||||
@@ -191,21 +345,189 @@ class GroupTable:
|
|||||||
def remove_user_from_all_groups(self, user_id: str) -> bool:
|
def remove_user_from_all_groups(self, user_id: str) -> bool:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
try:
|
try:
|
||||||
groups = self.get_groups_by_member_id(user_id)
|
# Find all groups the user belongs to
|
||||||
|
groups = (
|
||||||
|
db.query(Group)
|
||||||
|
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||||
|
.filter(GroupMember.user_id == user_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the user from each group
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group.user_ids.remove(user_id)
|
db.query(GroupMember).filter(
|
||||||
db.query(Group).filter_by(id=group.id).update(
|
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||||
{
|
).delete()
|
||||||
"user_ids": group.user_ids,
|
|
||||||
"updated_at": int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
db.query(Group).filter_by(id=group.id).update(
|
||||||
|
{"updated_at": int(time.time())}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
return False
|
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_all_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:
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
# 1. Groups that SHOULD contain the user
|
||||||
|
target_groups = (
|
||||||
|
db.query(Group).filter(Group.name.in_(group_names)).all()
|
||||||
|
)
|
||||||
|
target_group_ids = {g.id for g in target_groups}
|
||||||
|
|
||||||
|
# 2. Groups the user is CURRENTLY in
|
||||||
|
existing_group_ids = {
|
||||||
|
g.id
|
||||||
|
for g in db.query(Group)
|
||||||
|
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||||
|
.filter(GroupMember.user_id == user_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Determine adds + removals
|
||||||
|
groups_to_add = target_group_ids - existing_group_ids
|
||||||
|
groups_to_remove = existing_group_ids - target_group_ids
|
||||||
|
|
||||||
|
# 4. Remove in one bulk delete
|
||||||
|
if groups_to_remove:
|
||||||
|
db.query(GroupMember).filter(
|
||||||
|
GroupMember.user_id == user_id,
|
||||||
|
GroupMember.group_id.in_(groups_to_remove),
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
db.query(Group).filter(Group.id.in_(groups_to_remove)).update(
|
||||||
|
{"updated_at": now}, synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Bulk insert missing memberships
|
||||||
|
for group_id in groups_to_add:
|
||||||
|
db.add(
|
||||||
|
GroupMember(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
group_id=group_id,
|
||||||
|
user_id=user_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if groups_to_add:
|
||||||
|
db.query(Group).filter(Group.id.in_(groups_to_add)).update(
|
||||||
|
{"updated_at": now}, synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
db.rollback()
|
||||||
|
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
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
for user_id in user_ids or []:
|
||||||
|
try:
|
||||||
|
db.add(
|
||||||
|
GroupMember(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
group_id=id,
|
||||||
|
user_id=user_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush() # Detect unique constraint violation early
|
||||||
|
except Exception:
|
||||||
|
db.rollback() # Clear failed INSERT
|
||||||
|
db.begin() # Start a new transaction
|
||||||
|
continue # Duplicate → ignore
|
||||||
|
|
||||||
|
group.updated_at = now
|
||||||
|
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 user_ids:
|
||||||
|
return GroupModel.model_validate(group)
|
||||||
|
|
||||||
|
# Remove each user from group_member
|
||||||
|
for user_id in user_ids:
|
||||||
|
db.query(GroupMember).filter(
|
||||||
|
GroupMember.group_id == id, GroupMember.user_id == user_id
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# Update group timestamp
|
||||||
|
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()
|
Groups = GroupTable()
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ import uuid
|
|||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
from open_webui.models.files import FileMetadataResponse
|
from open_webui.models.files import File, FileModel, FileMetadataResponse
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
JSON,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
|
|
||||||
@@ -33,9 +42,7 @@ class Knowledge(Base):
|
|||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
|
|
||||||
data = Column(JSON, nullable=True)
|
|
||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
|
|
||||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||||
# Defines access control rules for this entry.
|
# Defines access control rules for this entry.
|
||||||
# - `None`: Public access, available to all users with the "user" role.
|
# - `None`: Public access, available to all users with the "user" role.
|
||||||
@@ -66,7 +73,6 @@ class KnowledgeModel(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
|
|
||||||
data: Optional[dict] = None
|
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
@@ -75,11 +81,42 @@ class KnowledgeModel(BaseModel):
|
|||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFile(Base):
|
||||||
|
__tablename__ = "knowledge_file"
|
||||||
|
|
||||||
|
id = Column(Text, unique=True, primary_key=True)
|
||||||
|
|
||||||
|
knowledge_id = Column(
|
||||||
|
Text, ForeignKey("knowledge.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
updated_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFileModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
knowledge_id: str
|
||||||
|
file_id: str
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Forms
|
# Forms
|
||||||
####################
|
####################
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeUserModel(KnowledgeModel):
|
class KnowledgeUserModel(KnowledgeModel):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
@@ -95,7 +132,6 @@ class KnowledgeUserResponse(KnowledgeUserModel):
|
|||||||
class KnowledgeForm(BaseModel):
|
class KnowledgeForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
data: Optional[dict] = None
|
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -128,11 +164,18 @@ class KnowledgeTable:
|
|||||||
|
|
||||||
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
|
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
knowledge_bases = []
|
all_knowledge = (
|
||||||
for knowledge in (
|
|
||||||
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
||||||
):
|
)
|
||||||
user = Users.get_user_by_id(knowledge.user_id)
|
|
||||||
|
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
|
knowledge_bases = []
|
||||||
|
for knowledge in all_knowledge:
|
||||||
|
user = users_dict.get(knowledge.user_id)
|
||||||
knowledge_bases.append(
|
knowledge_bases.append(
|
||||||
KnowledgeUserModel.model_validate(
|
KnowledgeUserModel.model_validate(
|
||||||
{
|
{
|
||||||
@@ -143,15 +186,27 @@ class KnowledgeTable:
|
|||||||
)
|
)
|
||||||
return knowledge_bases
|
return knowledge_bases
|
||||||
|
|
||||||
|
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
|
||||||
|
knowledge = self.get_knowledge_by_id(id)
|
||||||
|
if not knowledge:
|
||||||
|
return False
|
||||||
|
if knowledge.user_id == user_id:
|
||||||
|
return True
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||||
|
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
|
||||||
|
|
||||||
def get_knowledge_bases_by_user_id(
|
def get_knowledge_bases_by_user_id(
|
||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write"
|
||||||
) -> list[KnowledgeUserModel]:
|
) -> list[KnowledgeUserModel]:
|
||||||
knowledge_bases = self.get_knowledge_bases()
|
knowledge_bases = self.get_knowledge_bases()
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||||
return [
|
return [
|
||||||
knowledge_base
|
knowledge_base
|
||||||
for knowledge_base in knowledge_bases
|
for knowledge_base in knowledge_bases
|
||||||
if knowledge_base.user_id == user_id
|
if knowledge_base.user_id == user_id
|
||||||
or has_access(user_id, permission, knowledge_base.access_control)
|
or has_access(
|
||||||
|
user_id, permission, knowledge_base.access_control, user_group_ids
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
|
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
|
||||||
@@ -162,6 +217,100 @@ class KnowledgeTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
knowledges = (
|
||||||
|
db.query(Knowledge)
|
||||||
|
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
|
||||||
|
.filter(KnowledgeFile.file_id == file_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
KnowledgeModel.model_validate(knowledge) for knowledge in knowledges
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
files = (
|
||||||
|
db.query(File)
|
||||||
|
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||||
|
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [FileModel.model_validate(file) for file in files]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_file_metadatas_by_id(self, knowledge_id: str) -> list[FileMetadataResponse]:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
files = self.get_files_by_id(knowledge_id)
|
||||||
|
return [FileMetadataResponse(**file.model_dump()) for file in files]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def add_file_to_knowledge_by_id(
|
||||||
|
self, knowledge_id: str, file_id: str, user_id: str
|
||||||
|
) -> Optional[KnowledgeFileModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
knowledge_file = KnowledgeFileModel(
|
||||||
|
**{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"knowledge_id": knowledge_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = KnowledgeFile(**knowledge_file.model_dump())
|
||||||
|
db.add(result)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(result)
|
||||||
|
if result:
|
||||||
|
return KnowledgeFileModel.model_validate(result)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(KnowledgeFile).filter_by(
|
||||||
|
knowledge_id=knowledge_id, file_id=file_id
|
||||||
|
).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
# Delete all knowledge_file entries for this knowledge_id
|
||||||
|
db.query(KnowledgeFile).filter_by(knowledge_id=id).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update the knowledge entry's updated_at timestamp
|
||||||
|
db.query(Knowledge).filter_by(id=id).update(
|
||||||
|
{
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return self.get_knowledge_by_id(id=id)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
return None
|
||||||
|
|
||||||
def update_knowledge_by_id(
|
def update_knowledge_by_id(
|
||||||
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
|
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
|
||||||
) -> Optional[KnowledgeModel]:
|
) -> Optional[KnowledgeModel]:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy import BigInteger, Column, String, Text
|
|||||||
class Memory(Base):
|
class Memory(Base):
|
||||||
__tablename__ = "memory"
|
__tablename__ = "memory"
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True, unique=True)
|
||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
@@ -71,9 +71,13 @@ class MemoriesTable:
|
|||||||
) -> Optional[MemoryModel]:
|
) -> Optional[MemoryModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
try:
|
try:
|
||||||
db.query(Memory).filter_by(id=id, user_id=user_id).update(
|
memory = db.get(Memory, id)
|
||||||
{"content": content, "updated_at": int(time.time())}
|
if not memory or memory.user_id != user_id:
|
||||||
)
|
return None
|
||||||
|
|
||||||
|
memory.content = content
|
||||||
|
memory.updated_at = int(time.time())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return self.get_memory_by_id(id)
|
return self.get_memory_by_id(id)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -127,7 +131,12 @@ class MemoriesTable:
|
|||||||
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
|
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
try:
|
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()
|
db.commit()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from typing import Optional
|
|||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.models.tags import TagModel, Tag, Tags
|
from open_webui.models.tags import TagModel, Tag, Tags
|
||||||
|
from open_webui.models.users import Users, User, UserNameResponse
|
||||||
|
from open_webui.models.channels import Channels, ChannelMember
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
@@ -19,7 +21,7 @@ from sqlalchemy.sql import exists
|
|||||||
|
|
||||||
class MessageReaction(Base):
|
class MessageReaction(Base):
|
||||||
__tablename__ = "message_reaction"
|
__tablename__ = "message_reaction"
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
message_id = Column(Text)
|
message_id = Column(Text)
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
@@ -38,13 +40,19 @@ class MessageReactionModel(BaseModel):
|
|||||||
|
|
||||||
class Message(Base):
|
class Message(Base):
|
||||||
__tablename__ = "message"
|
__tablename__ = "message"
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
|
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
channel_id = Column(Text, nullable=True)
|
channel_id = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
reply_to_id = Column(Text, nullable=True)
|
||||||
parent_id = Column(Text, nullable=True)
|
parent_id = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Pins
|
||||||
|
is_pinned = Column(Boolean, nullable=False, default=False)
|
||||||
|
pinned_at = Column(BigInteger, nullable=True)
|
||||||
|
pinned_by = Column(Text, nullable=True)
|
||||||
|
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
data = Column(JSON, nullable=True)
|
data = Column(JSON, nullable=True)
|
||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
@@ -60,14 +68,20 @@ class MessageModel(BaseModel):
|
|||||||
user_id: str
|
user_id: str
|
||||||
channel_id: Optional[str] = None
|
channel_id: Optional[str] = None
|
||||||
|
|
||||||
|
reply_to_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
|
|
||||||
|
# Pins
|
||||||
|
is_pinned: bool = False
|
||||||
|
pinned_by: Optional[str] = None
|
||||||
|
pinned_at: Optional[int] = None # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch (time_ns)
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch (time_ns)
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@@ -76,7 +90,9 @@ class MessageModel(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MessageForm(BaseModel):
|
class MessageForm(BaseModel):
|
||||||
|
temp_id: Optional[str] = None
|
||||||
content: str
|
content: str
|
||||||
|
reply_to_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
@@ -84,11 +100,23 @@ class MessageForm(BaseModel):
|
|||||||
|
|
||||||
class Reactions(BaseModel):
|
class Reactions(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
user_ids: list[str]
|
users: list[dict]
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(MessageModel):
|
class MessageUserResponse(MessageModel):
|
||||||
|
user: Optional[UserNameResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReplyToResponse(MessageUserResponse):
|
||||||
|
reply_to_message: Optional[MessageUserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageWithReactionsResponse(MessageUserResponse):
|
||||||
|
reactions: list[Reactions]
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(MessageReplyToResponse):
|
||||||
latest_reply_at: Optional[int]
|
latest_reply_at: Optional[int]
|
||||||
reply_count: int
|
reply_count: int
|
||||||
reactions: list[Reactions]
|
reactions: list[Reactions]
|
||||||
@@ -99,15 +127,21 @@ class MessageTable:
|
|||||||
self, form_data: MessageForm, channel_id: str, user_id: str
|
self, form_data: MessageForm, channel_id: str, user_id: str
|
||||||
) -> Optional[MessageModel]:
|
) -> Optional[MessageModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
id = str(uuid.uuid4())
|
channel_member = Channels.join_channel(channel_id, user_id)
|
||||||
|
|
||||||
|
id = str(uuid.uuid4())
|
||||||
ts = int(time.time_ns())
|
ts = int(time.time_ns())
|
||||||
|
|
||||||
message = MessageModel(
|
message = MessageModel(
|
||||||
**{
|
**{
|
||||||
"id": id,
|
"id": id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
|
"reply_to_id": form_data.reply_to_id,
|
||||||
"parent_id": form_data.parent_id,
|
"parent_id": form_data.parent_id,
|
||||||
|
"is_pinned": False,
|
||||||
|
"pinned_at": None,
|
||||||
|
"pinned_by": None,
|
||||||
"content": form_data.content,
|
"content": form_data.content,
|
||||||
"data": form_data.data,
|
"data": form_data.data,
|
||||||
"meta": form_data.meta,
|
"meta": form_data.meta,
|
||||||
@@ -115,8 +149,8 @@ class MessageTable:
|
|||||||
"updated_at": ts,
|
"updated_at": ts,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
result = Message(**message.model_dump())
|
result = Message(**message.model_dump())
|
||||||
|
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
@@ -128,19 +162,32 @@ class MessageTable:
|
|||||||
if not message:
|
if not message:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
reactions = self.get_reactions_by_message_id(id)
|
reply_to_message = (
|
||||||
replies = self.get_replies_by_message_id(id)
|
self.get_message_by_id(message.reply_to_id)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return MessageResponse(
|
reactions = self.get_reactions_by_message_id(id)
|
||||||
**{
|
thread_replies = self.get_thread_replies_by_message_id(id)
|
||||||
|
|
||||||
|
user = Users.get_user_by_id(message.user_id)
|
||||||
|
return MessageResponse.model_validate(
|
||||||
|
{
|
||||||
**MessageModel.model_validate(message).model_dump(),
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
"latest_reply_at": replies[0].created_at if replies else None,
|
"user": user.model_dump() if user else None,
|
||||||
"reply_count": len(replies),
|
"reply_to_message": (
|
||||||
|
reply_to_message.model_dump() if reply_to_message else None
|
||||||
|
),
|
||||||
|
"latest_reply_at": (
|
||||||
|
thread_replies[0].created_at if thread_replies else None
|
||||||
|
),
|
||||||
|
"reply_count": len(thread_replies),
|
||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
|
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_messages = (
|
all_messages = (
|
||||||
db.query(Message)
|
db.query(Message)
|
||||||
@@ -148,7 +195,27 @@ class MessageTable:
|
|||||||
.order_by(Message.created_at.desc())
|
.order_by(Message.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [MessageModel.model_validate(message) for message in all_messages]
|
|
||||||
|
messages = []
|
||||||
|
for message in all_messages:
|
||||||
|
reply_to_message = (
|
||||||
|
self.get_message_by_id(message.reply_to_id)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
MessageReplyToResponse.model_validate(
|
||||||
|
{
|
||||||
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"reply_to_message": (
|
||||||
|
reply_to_message.model_dump()
|
||||||
|
if reply_to_message
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
|
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -159,7 +226,7 @@ class MessageTable:
|
|||||||
|
|
||||||
def get_messages_by_channel_id(
|
def get_messages_by_channel_id(
|
||||||
self, channel_id: str, skip: int = 0, limit: int = 50
|
self, channel_id: str, skip: int = 0, limit: int = 50
|
||||||
) -> list[MessageModel]:
|
) -> list[MessageReplyToResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_messages = (
|
all_messages = (
|
||||||
db.query(Message)
|
db.query(Message)
|
||||||
@@ -169,11 +236,31 @@ class MessageTable:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [MessageModel.model_validate(message) for message in all_messages]
|
|
||||||
|
messages = []
|
||||||
|
for message in all_messages:
|
||||||
|
reply_to_message = (
|
||||||
|
self.get_message_by_id(message.reply_to_id)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
MessageReplyToResponse.model_validate(
|
||||||
|
{
|
||||||
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"reply_to_message": (
|
||||||
|
reply_to_message.model_dump()
|
||||||
|
if reply_to_message
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
def get_messages_by_parent_id(
|
def get_messages_by_parent_id(
|
||||||
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
|
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
|
||||||
) -> list[MessageModel]:
|
) -> list[MessageReplyToResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
message = db.get(Message, parent_id)
|
message = db.get(Message, parent_id)
|
||||||
|
|
||||||
@@ -193,6 +280,49 @@ class MessageTable:
|
|||||||
if len(all_messages) < limit:
|
if len(all_messages) < limit:
|
||||||
all_messages.append(message)
|
all_messages.append(message)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for message in all_messages:
|
||||||
|
reply_to_message = (
|
||||||
|
self.get_message_by_id(message.reply_to_id)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
MessageReplyToResponse.model_validate(
|
||||||
|
{
|
||||||
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"reply_to_message": (
|
||||||
|
reply_to_message.model_dump()
|
||||||
|
if reply_to_message
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
message = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter_by(channel_id=channel_id)
|
||||||
|
.order_by(Message.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return MessageModel.model_validate(message) if message else None
|
||||||
|
|
||||||
|
def get_pinned_messages_by_channel_id(
|
||||||
|
self, channel_id: str, skip: int = 0, limit: int = 50
|
||||||
|
) -> list[MessageModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
all_messages = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter_by(channel_id=channel_id, is_pinned=True)
|
||||||
|
.order_by(Message.pinned_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
return [MessageModel.model_validate(message) for message in all_messages]
|
return [MessageModel.model_validate(message) for message in all_messages]
|
||||||
|
|
||||||
def update_message_by_id(
|
def update_message_by_id(
|
||||||
@@ -201,17 +331,57 @@ class MessageTable:
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
message = db.get(Message, id)
|
message = db.get(Message, id)
|
||||||
message.content = form_data.content
|
message.content = form_data.content
|
||||||
message.data = form_data.data
|
message.data = {
|
||||||
message.meta = form_data.meta
|
**(message.data if message.data else {}),
|
||||||
|
**(form_data.data if form_data.data else {}),
|
||||||
|
}
|
||||||
|
message.meta = {
|
||||||
|
**(message.meta if message.meta else {}),
|
||||||
|
**(form_data.meta if form_data.meta else {}),
|
||||||
|
}
|
||||||
message.updated_at = int(time.time_ns())
|
message.updated_at = int(time.time_ns())
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(message)
|
db.refresh(message)
|
||||||
return MessageModel.model_validate(message) if message else None
|
return MessageModel.model_validate(message) if message else None
|
||||||
|
|
||||||
|
def update_is_pinned_by_id(
|
||||||
|
self, id: str, is_pinned: bool, pinned_by: Optional[str] = None
|
||||||
|
) -> Optional[MessageModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
message = db.get(Message, id)
|
||||||
|
message.is_pinned = is_pinned
|
||||||
|
message.pinned_at = int(time.time_ns()) if is_pinned else None
|
||||||
|
message.pinned_by = pinned_by if is_pinned else None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(message)
|
||||||
|
return MessageModel.model_validate(message) if message else None
|
||||||
|
|
||||||
|
def get_unread_message_count(
|
||||||
|
self, channel_id: str, user_id: str, last_read_at: Optional[int] = None
|
||||||
|
) -> int:
|
||||||
|
with get_db() as db:
|
||||||
|
query = db.query(Message).filter(
|
||||||
|
Message.channel_id == channel_id,
|
||||||
|
Message.parent_id == None, # only count top-level messages
|
||||||
|
Message.created_at > (last_read_at if last_read_at else 0),
|
||||||
|
)
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Message.user_id != user_id)
|
||||||
|
return query.count()
|
||||||
|
|
||||||
def add_reaction_to_message(
|
def add_reaction_to_message(
|
||||||
self, id: str, user_id: str, name: str
|
self, id: str, user_id: str, name: str
|
||||||
) -> Optional[MessageReactionModel]:
|
) -> Optional[MessageReactionModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
# check for existing reaction
|
||||||
|
existing_reaction = (
|
||||||
|
db.query(MessageReaction)
|
||||||
|
.filter_by(message_id=id, user_id=user_id, name=name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_reaction:
|
||||||
|
return MessageReactionModel.model_validate(existing_reaction)
|
||||||
|
|
||||||
reaction_id = str(uuid.uuid4())
|
reaction_id = str(uuid.uuid4())
|
||||||
reaction = MessageReactionModel(
|
reaction = MessageReactionModel(
|
||||||
id=reaction_id,
|
id=reaction_id,
|
||||||
@@ -228,17 +398,30 @@ class MessageTable:
|
|||||||
|
|
||||||
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
|
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
|
# JOIN User so all user info is fetched in one query
|
||||||
|
results = (
|
||||||
|
db.query(MessageReaction, User)
|
||||||
|
.join(User, MessageReaction.user_id == User.id)
|
||||||
|
.filter(MessageReaction.message_id == id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
reactions = {}
|
reactions = {}
|
||||||
for reaction in all_reactions:
|
|
||||||
|
for reaction, user in results:
|
||||||
if reaction.name not in reactions:
|
if reaction.name not in reactions:
|
||||||
reactions[reaction.name] = {
|
reactions[reaction.name] = {
|
||||||
"name": reaction.name,
|
"name": reaction.name,
|
||||||
"user_ids": [],
|
"users": [],
|
||||||
"count": 0,
|
"count": 0,
|
||||||
}
|
}
|
||||||
reactions[reaction.name]["user_ids"].append(reaction.user_id)
|
|
||||||
|
reactions[reaction.name]["users"].append(
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"name": user.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
reactions[reaction.name]["count"] += 1
|
reactions[reaction.name]["count"] += 1
|
||||||
|
|
||||||
return [Reactions(**reaction) for reaction in reactions.values()]
|
return [Reactions(**reaction) for reaction in reactions.values()]
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ from typing import Optional
|
|||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from open_webui.internal.db import Base, JSONField, get_db
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.groups import Groups
|
||||||
|
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
from sqlalchemy import or_, and_, func
|
from sqlalchemy import String, cast, or_, and_, func
|
||||||
from sqlalchemy.dialects import postgresql, sqlite
|
from sqlalchemy.dialects import postgresql, sqlite
|
||||||
|
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ class ModelMeta(BaseModel):
|
|||||||
class Model(Base):
|
class Model(Base):
|
||||||
__tablename__ = "model"
|
__tablename__ = "model"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
"""
|
"""
|
||||||
The model's id as used in the API. If set to an existing model, it will override the model.
|
The model's id as used in the API. If set to an existing model, it will override the model.
|
||||||
"""
|
"""
|
||||||
@@ -132,6 +135,11 @@ class ModelResponse(ModelModel):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelListResponse(BaseModel):
|
||||||
|
items: list[ModelUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class ModelForm(BaseModel):
|
class ModelForm(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
base_model_id: Optional[str] = None
|
base_model_id: Optional[str] = None
|
||||||
@@ -175,9 +183,16 @@ class ModelsTable:
|
|||||||
|
|
||||||
def get_models(self) -> list[ModelUserResponse]:
|
def get_models(self) -> list[ModelUserResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
all_models = db.query(Model).filter(Model.base_model_id != None).all()
|
||||||
|
|
||||||
|
user_ids = list(set(model.user_id for model in all_models))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
models = []
|
models = []
|
||||||
for model in db.query(Model).filter(Model.base_model_id != None).all():
|
for model in all_models:
|
||||||
user = Users.get_user_by_id(model.user_id)
|
user = users_dict.get(model.user_id)
|
||||||
models.append(
|
models.append(
|
||||||
ModelUserResponse.model_validate(
|
ModelUserResponse.model_validate(
|
||||||
{
|
{
|
||||||
@@ -199,13 +214,143 @@ class ModelsTable:
|
|||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write"
|
||||||
) -> list[ModelUserResponse]:
|
) -> list[ModelUserResponse]:
|
||||||
models = self.get_models()
|
models = self.get_models()
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||||
return [
|
return [
|
||||||
model
|
model
|
||||||
for model in models
|
for model in models
|
||||||
if model.user_id == user_id
|
if model.user_id == user_id
|
||||||
or has_access(user_id, permission, model.access_control)
|
or has_access(user_id, permission, model.access_control, user_group_ids)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||||
|
group_ids = filter.get("group_ids", [])
|
||||||
|
user_id = filter.get("user_id")
|
||||||
|
|
||||||
|
dialect_name = db.bind.dialect.name
|
||||||
|
|
||||||
|
# Public access
|
||||||
|
conditions = []
|
||||||
|
if group_ids or user_id:
|
||||||
|
conditions.extend(
|
||||||
|
[
|
||||||
|
Model.access_control.is_(None),
|
||||||
|
cast(Model.access_control, String) == "null",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# User-level permission
|
||||||
|
if user_id:
|
||||||
|
conditions.append(Model.user_id == user_id)
|
||||||
|
|
||||||
|
# Group-level permission
|
||||||
|
if group_ids:
|
||||||
|
group_conditions = []
|
||||||
|
for gid in group_ids:
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
group_conditions.append(
|
||||||
|
Model.access_control[permission]["group_ids"].contains([gid])
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
group_conditions.append(
|
||||||
|
cast(
|
||||||
|
Model.access_control[permission]["group_ids"],
|
||||||
|
JSONB,
|
||||||
|
).contains([gid])
|
||||||
|
)
|
||||||
|
conditions.append(or_(*group_conditions))
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query = query.filter(or_(*conditions))
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
def search_models(
|
||||||
|
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
|
||||||
|
) -> ModelListResponse:
|
||||||
|
with get_db() as db:
|
||||||
|
# Join GroupMember so we can order by group_id when requested
|
||||||
|
query = db.query(Model, User).outerjoin(User, User.id == Model.user_id)
|
||||||
|
query = query.filter(Model.base_model_id != None)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Model.name.ilike(f"%{query_key}%"),
|
||||||
|
Model.base_model_id.ilike(f"%{query_key}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Model.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(Model.user_id != user_id)
|
||||||
|
|
||||||
|
# Apply access control filtering
|
||||||
|
query = self._has_permission(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
filter,
|
||||||
|
permission="write",
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = filter.get("tag")
|
||||||
|
if tag:
|
||||||
|
# TODO: This is a simple implementation and should be improved for performance
|
||||||
|
like_pattern = f'%"{tag.lower()}"%' # `"tag"` inside JSON array
|
||||||
|
meta_text = func.lower(cast(Model.meta, String))
|
||||||
|
|
||||||
|
query = query.filter(meta_text.like(like_pattern))
|
||||||
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by == "name":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Model.name.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Model.name.desc())
|
||||||
|
elif order_by == "created_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Model.created_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Model.created_at.desc())
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Model.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Model.updated_at.desc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = query.order_by(Model.created_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for model, user in items:
|
||||||
|
models.append(
|
||||||
|
ModelUserResponse(
|
||||||
|
**ModelModel.model_validate(model).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ModelListResponse(items=models, total=total)
|
||||||
|
|
||||||
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
|
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -235,11 +380,9 @@ class ModelsTable:
|
|||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
# update only the fields that are present in the model
|
# update only the fields that are present in the model
|
||||||
result = (
|
data = model.model_dump(exclude={"id"})
|
||||||
db.query(Model)
|
result = db.query(Model).filter_by(id=id).update(data)
|
||||||
.filter_by(id=id)
|
|
||||||
.update(model.model_dump(exclude={"id"}))
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
model = db.get(Model, id)
|
model = db.get(Model, id)
|
||||||
@@ -269,5 +412,49 @@ class ModelsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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()
|
Models = ModelsTable()
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ from sqlalchemy.sql import exists
|
|||||||
class Note(Base):
|
class Note(Base):
|
||||||
__tablename__ = "note"
|
__tablename__ = "note"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
|
|
||||||
title = Column(Text)
|
title = Column(Text)
|
||||||
@@ -62,6 +64,13 @@ class NoteForm(BaseModel):
|
|||||||
access_control: 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):
|
class NoteUserResponse(NoteModel):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
@@ -89,37 +98,111 @@ class NoteTable:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return note
|
return note
|
||||||
|
|
||||||
def get_notes(self) -> list[NoteModel]:
|
def get_notes(
|
||||||
|
self, skip: Optional[int] = None, limit: Optional[int] = None
|
||||||
|
) -> list[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
|
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||||
|
if skip is not None:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
notes = query.all()
|
||||||
return [NoteModel.model_validate(note) for note in notes]
|
return [NoteModel.model_validate(note) for note in notes]
|
||||||
|
|
||||||
def get_notes_by_user_id(
|
def get_notes_by_user_id(
|
||||||
self, user_id: str, permission: str = "write"
|
self,
|
||||||
|
user_id: str,
|
||||||
|
skip: Optional[int] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
) -> list[NoteModel]:
|
) -> list[NoteModel]:
|
||||||
notes = self.get_notes()
|
with get_db() as db:
|
||||||
return [
|
query = db.query(Note).filter(Note.user_id == user_id)
|
||||||
note
|
query = query.order_by(Note.updated_at.desc())
|
||||||
for note in notes
|
|
||||||
if note.user_id == user_id
|
if skip is not None:
|
||||||
or has_access(user_id, permission, note.access_control)
|
query = query.offset(skip)
|
||||||
]
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
notes = query.all()
|
||||||
|
return [NoteModel.model_validate(note) for note in notes]
|
||||||
|
|
||||||
|
def get_notes_by_permission(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
permission: str = "write",
|
||||||
|
skip: Optional[int] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list[NoteModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||||
|
user_group_ids = {group.id for group in user_groups}
|
||||||
|
|
||||||
|
# Order newest-first. We stream to keep memory usage low.
|
||||||
|
query = (
|
||||||
|
db.query(Note)
|
||||||
|
.order_by(Note.updated_at.desc())
|
||||||
|
.execution_options(stream_results=True)
|
||||||
|
.yield_per(256)
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[NoteModel] = []
|
||||||
|
n_skipped = 0
|
||||||
|
|
||||||
|
for note in query:
|
||||||
|
# Fast-pass #1: owner
|
||||||
|
if note.user_id == user_id:
|
||||||
|
permitted = True
|
||||||
|
# Fast-pass #2: public/open
|
||||||
|
elif note.access_control is None:
|
||||||
|
# Technically this should mean public access for both read and write, but we'll only do read for now
|
||||||
|
# We might want to change this behavior later
|
||||||
|
permitted = permission == "read"
|
||||||
|
else:
|
||||||
|
permitted = has_access(
|
||||||
|
user_id, permission, note.access_control, user_group_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
if not permitted:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply skip AFTER permission filtering so it counts only accessible notes
|
||||||
|
if skip and n_skipped < skip:
|
||||||
|
n_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(NoteModel.model_validate(note))
|
||||||
|
if limit is not None and len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
return NoteModel.model_validate(note) if note else None
|
return NoteModel.model_validate(note) if note else None
|
||||||
|
|
||||||
def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
|
def update_note_by_id(
|
||||||
|
self, id: str, form_data: NoteUpdateForm
|
||||||
|
) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
if not note:
|
if not note:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
note.title = form_data.title
|
form_data = form_data.model_dump(exclude_unset=True)
|
||||||
note.data = form_data.data
|
|
||||||
note.meta = form_data.meta
|
if "title" in form_data:
|
||||||
note.access_control = form_data.access_control
|
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())
|
note.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, List
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from open_webui.internal.db import Base, get_db
|
||||||
|
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger, Column, String, Text, Index
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||||
|
|
||||||
|
####################
|
||||||
|
# DB MODEL
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSession(Base):
|
||||||
|
__tablename__ = "oauth_session"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
provider = Column(Text, nullable=False)
|
||||||
|
token = Column(
|
||||||
|
Text, nullable=False
|
||||||
|
) # JSON with access_token, id_token, refresh_token
|
||||||
|
expires_at = Column(BigInteger, nullable=False)
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
updated_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
# Add indexes for better performance
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_oauth_session_user_id", "user_id"),
|
||||||
|
Index("idx_oauth_session_expires_at", "expires_at"),
|
||||||
|
Index("idx_oauth_session_user_provider", "user_id", "provider"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSessionModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
provider: str
|
||||||
|
token: dict
|
||||||
|
expires_at: int # timestamp in epoch
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Forms
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSessionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
provider: str
|
||||||
|
expires_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSessionTable:
|
||||||
|
def __init__(self):
|
||||||
|
self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
|
||||||
|
if not self.encryption_key:
|
||||||
|
raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
|
||||||
|
|
||||||
|
# check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
|
||||||
|
if len(self.encryption_key) != 44:
|
||||||
|
key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
|
||||||
|
self.encryption_key = base64.urlsafe_b64encode(key_bytes)
|
||||||
|
else:
|
||||||
|
self.encryption_key = self.encryption_key.encode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.fernet = Fernet(self.encryption_key)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error initializing Fernet with provided key: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _encrypt_token(self, token) -> str:
|
||||||
|
"""Encrypt OAuth tokens for storage"""
|
||||||
|
try:
|
||||||
|
token_json = json.dumps(token)
|
||||||
|
encrypted = self.fernet.encrypt(token_json.encode()).decode()
|
||||||
|
return encrypted
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error encrypting tokens: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _decrypt_token(self, token: str):
|
||||||
|
"""Decrypt OAuth tokens from storage"""
|
||||||
|
try:
|
||||||
|
decrypted = self.fernet.decrypt(token.encode()).decode()
|
||||||
|
return json.loads(decrypted)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error decrypting tokens: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
provider: str,
|
||||||
|
token: dict,
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Create a new OAuth session"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
current_time = int(time.time())
|
||||||
|
id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
result = OAuthSession(
|
||||||
|
**{
|
||||||
|
"id": id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"provider": provider,
|
||||||
|
"token": self._encrypt_token(token),
|
||||||
|
"expires_at": token.get("expires_at"),
|
||||||
|
"created_at": current_time,
|
||||||
|
"updated_at": current_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(result)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(result)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
result.token = token # Return decrypted token
|
||||||
|
return OAuthSessionModel.model_validate(result)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error creating OAuth session: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Get OAuth session by ID"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
session = db.query(OAuthSession).filter_by(id=session_id).first()
|
||||||
|
if session:
|
||||||
|
session.token = self._decrypt_token(session.token)
|
||||||
|
return OAuthSessionModel.model_validate(session)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error getting OAuth session by ID: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_session_by_id_and_user_id(
|
||||||
|
self, session_id: str, user_id: str
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Get OAuth session by ID and user ID"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
session = (
|
||||||
|
db.query(OAuthSession)
|
||||||
|
.filter_by(id=session_id, user_id=user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if session:
|
||||||
|
session.token = self._decrypt_token(session.token)
|
||||||
|
return OAuthSessionModel.model_validate(session)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error getting OAuth session by ID: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_session_by_provider_and_user_id(
|
||||||
|
self, provider: str, user_id: str
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Get OAuth session by provider and user ID"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
session = (
|
||||||
|
db.query(OAuthSession)
|
||||||
|
.filter_by(provider=provider, user_id=user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if session:
|
||||||
|
session.token = self._decrypt_token(session.token)
|
||||||
|
return OAuthSessionModel.model_validate(session)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error getting OAuth session by provider and user ID: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
|
||||||
|
"""Get all OAuth sessions for a user"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for session in sessions:
|
||||||
|
session.token = self._decrypt_token(session.token)
|
||||||
|
results.append(OAuthSessionModel.model_validate(session))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error getting OAuth sessions by user ID: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_session_by_id(
|
||||||
|
self, session_id: str, token: dict
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Update OAuth session tokens"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
current_time = int(time.time())
|
||||||
|
|
||||||
|
db.query(OAuthSession).filter_by(id=session_id).update(
|
||||||
|
{
|
||||||
|
"token": self._encrypt_token(token),
|
||||||
|
"expires_at": token.get("expires_at"),
|
||||||
|
"updated_at": current_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
session = db.query(OAuthSession).filter_by(id=session_id).first()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
session.token = self._decrypt_token(session.token)
|
||||||
|
return OAuthSessionModel.model_validate(session)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error updating OAuth session tokens: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_session_by_id(self, session_id: str) -> bool:
|
||||||
|
"""Delete an OAuth session"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
result = db.query(OAuthSession).filter_by(id=session_id).delete()
|
||||||
|
db.commit()
|
||||||
|
return result > 0
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error deleting OAuth session: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_sessions_by_user_id(self, user_id: str) -> bool:
|
||||||
|
"""Delete all OAuth sessions for a user"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error deleting OAuth sessions by user ID: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_sessions_by_provider(self, provider: str) -> bool:
|
||||||
|
"""Delete all OAuth sessions for a provider"""
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(OAuthSession).filter_by(provider=provider).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error deleting OAuth sessions by provider {provider}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
OAuthSessions = OAuthSessionTable()
|
||||||
@@ -2,6 +2,7 @@ import time
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
@@ -103,10 +104,16 @@ class PromptsTable:
|
|||||||
|
|
||||||
def get_prompts(self) -> list[PromptUserResponse]:
|
def get_prompts(self) -> list[PromptUserResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
prompts = []
|
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
|
||||||
|
|
||||||
for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all():
|
user_ids = list(set(prompt.user_id for prompt in all_prompts))
|
||||||
user = Users.get_user_by_id(prompt.user_id)
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
|
prompts = []
|
||||||
|
for prompt in all_prompts:
|
||||||
|
user = users_dict.get(prompt.user_id)
|
||||||
prompts.append(
|
prompts.append(
|
||||||
PromptUserResponse.model_validate(
|
PromptUserResponse.model_validate(
|
||||||
{
|
{
|
||||||
@@ -122,12 +129,13 @@ class PromptsTable:
|
|||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write"
|
||||||
) -> list[PromptUserResponse]:
|
) -> list[PromptUserResponse]:
|
||||||
prompts = self.get_prompts()
|
prompts = self.get_prompts()
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
prompt
|
prompt
|
||||||
for prompt in prompts
|
for prompt in prompts
|
||||||
if prompt.user_id == user_id
|
if prompt.user_id == user_id
|
||||||
or has_access(user_id, permission, prompt.access_control)
|
or has_access(user_id, permission, prompt.access_control, user_group_ids)
|
||||||
]
|
]
|
||||||
|
|
||||||
def update_prompt_by_command(
|
def update_prompt_by_command(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from open_webui.internal.db import Base, get_db
|
|||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from pydantic import BaseModel, ConfigDict
|
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 = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||||
@@ -24,6 +24,11 @@ class Tag(Base):
|
|||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
meta = Column(JSON, nullable=True)
|
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
|
# Unique constraint ensuring (id, user_id) is unique, not just the `id` column
|
||||||
__table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),)
|
__table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from typing import Optional
|
|||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from open_webui.internal.db import Base, JSONField, get_db
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||||
@@ -22,7 +24,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
class Tool(Base):
|
class Tool(Base):
|
||||||
__tablename__ = "tool"
|
__tablename__ = "tool"
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True, unique=True)
|
||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
@@ -93,6 +95,8 @@ class ToolResponse(BaseModel):
|
|||||||
class ToolUserResponse(ToolResponse):
|
class ToolUserResponse(ToolResponse):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class ToolForm(BaseModel):
|
class ToolForm(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -144,9 +148,16 @@ class ToolsTable:
|
|||||||
|
|
||||||
def get_tools(self) -> list[ToolUserModel]:
|
def get_tools(self) -> list[ToolUserModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
|
||||||
|
|
||||||
|
user_ids = list(set(tool.user_id for tool in all_tools))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
tools = []
|
tools = []
|
||||||
for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all():
|
for tool in all_tools:
|
||||||
user = Users.get_user_by_id(tool.user_id)
|
user = users_dict.get(tool.user_id)
|
||||||
tools.append(
|
tools.append(
|
||||||
ToolUserModel.model_validate(
|
ToolUserModel.model_validate(
|
||||||
{
|
{
|
||||||
@@ -161,12 +172,13 @@ class ToolsTable:
|
|||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write"
|
||||||
) -> list[ToolUserModel]:
|
) -> list[ToolUserModel]:
|
||||||
tools = self.get_tools()
|
tools = self.get_tools()
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
tool
|
tool
|
||||||
for tool in tools
|
for tool in tools
|
||||||
if tool.user_id == user_id
|
if tool.user_id == user_id
|
||||||
or has_access(user_id, permission, tool.access_control)
|
or has_access(user_id, permission, tool.access_control, user_group_ids)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
|
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
|
||||||
@@ -175,7 +187,7 @@ class ToolsTable:
|
|||||||
tool = db.get(Tool, id)
|
tool = db.get(Tool, id)
|
||||||
return tool.valves if tool.valves else {}
|
return tool.valves if tool.valves else {}
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
|
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
|
||||||
|
|||||||
@@ -4,62 +4,139 @@ from typing import Optional
|
|||||||
from open_webui.internal.db import Base, JSONField, get_db
|
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.chats import Chats
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups, GroupMember
|
||||||
|
from open_webui.models.channels import ChannelMember
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.utils.misc import throttle
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, String, Text
|
from sqlalchemy import (
|
||||||
from sqlalchemy import or_
|
BigInteger,
|
||||||
|
JSON,
|
||||||
|
Column,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Text,
|
||||||
|
Date,
|
||||||
|
exists,
|
||||||
|
select,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
|
from sqlalchemy import or_, case
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# User DB Schema
|
# User DB Schema
|
||||||
####################
|
####################
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "user"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
|
||||||
name = Column(String)
|
|
||||||
email = Column(String)
|
|
||||||
role = Column(String)
|
|
||||||
profile_image_url = Column(Text)
|
|
||||||
|
|
||||||
last_active_at = Column(BigInteger)
|
|
||||||
updated_at = Column(BigInteger)
|
|
||||||
created_at = Column(BigInteger)
|
|
||||||
|
|
||||||
api_key = Column(String, nullable=True, unique=True)
|
|
||||||
settings = Column(JSONField, nullable=True)
|
|
||||||
info = Column(JSONField, nullable=True)
|
|
||||||
|
|
||||||
oauth_sub = Column(Text, unique=True)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSettings(BaseModel):
|
class UserSettings(BaseModel):
|
||||||
ui: Optional[dict] = {}
|
ui: Optional[dict] = {}
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "user"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, unique=True)
|
||||||
|
email = Column(String)
|
||||||
|
username = Column(String(50), nullable=True)
|
||||||
|
role = Column(String)
|
||||||
|
|
||||||
|
name = Column(String)
|
||||||
|
|
||||||
|
profile_image_url = Column(Text)
|
||||||
|
profile_banner_image_url = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
bio = Column(Text, nullable=True)
|
||||||
|
gender = Column(Text, nullable=True)
|
||||||
|
date_of_birth = Column(Date, nullable=True)
|
||||||
|
timezone = Column(String, nullable=True)
|
||||||
|
|
||||||
|
presence_state = Column(String, nullable=True)
|
||||||
|
status_emoji = Column(String, nullable=True)
|
||||||
|
status_message = Column(Text, nullable=True)
|
||||||
|
status_expires_at = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
info = Column(JSON, nullable=True)
|
||||||
|
settings = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
oauth = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
last_active_at = Column(BigInteger)
|
||||||
|
updated_at = Column(BigInteger)
|
||||||
|
created_at = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
class UserModel(BaseModel):
|
class UserModel(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
|
||||||
email: str
|
email: str
|
||||||
|
username: Optional[str] = None
|
||||||
role: str = "pending"
|
role: str = "pending"
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
profile_image_url: str
|
profile_image_url: str
|
||||||
|
profile_banner_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
bio: Optional[str] = None
|
||||||
|
gender: Optional[str] = None
|
||||||
|
date_of_birth: Optional[datetime.date] = None
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
|
||||||
|
presence_state: Optional[str] = None
|
||||||
|
status_emoji: Optional[str] = None
|
||||||
|
status_message: Optional[str] = None
|
||||||
|
status_expires_at: Optional[int] = None
|
||||||
|
|
||||||
|
info: Optional[dict] = None
|
||||||
|
settings: Optional[UserSettings] = None
|
||||||
|
|
||||||
|
oauth: Optional[dict] = None
|
||||||
|
|
||||||
last_active_at: int # timestamp in epoch
|
last_active_at: int # timestamp in epoch
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
|
|
||||||
api_key: Optional[str] = None
|
model_config = ConfigDict(from_attributes=True)
|
||||||
settings: Optional[UserSettings] = None
|
|
||||||
info: Optional[dict] = None
|
|
||||||
|
|
||||||
oauth_sub: Optional[str] = None
|
|
||||||
|
class UserStatusModel(UserModel):
|
||||||
|
is_active: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
__tablename__ = "api_key"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True, unique=True)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
key = Column(Text, unique=True, nullable=False)
|
||||||
|
data = Column(JSON, nullable=True)
|
||||||
|
expires_at = Column(BigInteger, nullable=True)
|
||||||
|
last_used_at = Column(BigInteger, nullable=True)
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
updated_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
key: str
|
||||||
|
data: Optional[dict] = None
|
||||||
|
expires_at: Optional[int] = None
|
||||||
|
last_used_at: Optional[int] = None
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -69,23 +146,78 @@ 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 UserGroupIdsModel(UserModel):
|
||||||
|
group_ids: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class UserModelResponse(UserModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(BaseModel):
|
class UserListResponse(BaseModel):
|
||||||
users: list[UserModel]
|
users: list[UserModelResponse]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserGroupIdsListResponse(BaseModel):
|
||||||
|
users: list[UserGroupIdsModel]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatus(BaseModel):
|
||||||
|
status_emoji: Optional[str] = None
|
||||||
|
status_message: Optional[str] = None
|
||||||
|
status_expires_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfoResponse(UserStatus):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
email: str
|
email: str
|
||||||
role: str
|
role: str
|
||||||
profile_image_url: str
|
|
||||||
|
|
||||||
|
class UserIdNameResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdNameStatusResponse(UserStatus):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfoListResponse(BaseModel):
|
||||||
|
users: list[UserInfoResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdNameListResponse(BaseModel):
|
||||||
|
users: list[UserIdNameResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class UserNameResponse(BaseModel):
|
class UserNameResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(UserNameResponse):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileImageResponse(UserNameResponse):
|
||||||
|
email: str
|
||||||
profile_image_url: str
|
profile_image_url: str
|
||||||
|
|
||||||
|
|
||||||
@@ -95,6 +227,7 @@ class UserRoleUpdateForm(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserUpdateForm(BaseModel):
|
class UserUpdateForm(BaseModel):
|
||||||
|
role: str
|
||||||
name: str
|
name: str
|
||||||
email: str
|
email: str
|
||||||
profile_image_url: str
|
profile_image_url: str
|
||||||
@@ -109,20 +242,20 @@ class UsersTable:
|
|||||||
email: str,
|
email: str,
|
||||||
profile_image_url: str = "/user.png",
|
profile_image_url: str = "/user.png",
|
||||||
role: str = "pending",
|
role: str = "pending",
|
||||||
oauth_sub: Optional[str] = None,
|
oauth: Optional[dict] = None,
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
user = UserModel(
|
user = UserModel(
|
||||||
**{
|
**{
|
||||||
"id": id,
|
"id": id,
|
||||||
"name": name,
|
|
||||||
"email": email,
|
"email": email,
|
||||||
|
"name": name,
|
||||||
"role": role,
|
"role": role,
|
||||||
"profile_image_url": profile_image_url,
|
"profile_image_url": profile_image_url,
|
||||||
"last_active_at": int(time.time()),
|
"last_active_at": int(time.time()),
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
"oauth_sub": oauth_sub,
|
"oauth": oauth,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = User(**user.model_dump())
|
result = User(**user.model_dump())
|
||||||
@@ -145,8 +278,13 @@ class UsersTable:
|
|||||||
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
|
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
user = db.query(User).filter_by(api_key=api_key).first()
|
user = (
|
||||||
return UserModel.model_validate(user)
|
db.query(User)
|
||||||
|
.join(ApiKey, User.id == ApiKey.user_id)
|
||||||
|
.filter(ApiKey.key == api_key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return UserModel.model_validate(user) if user else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -158,12 +296,23 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]:
|
def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db: # type: Session
|
||||||
user = db.query(User).filter_by(oauth_sub=sub).first()
|
dialect_name = db.bind.dialect.name
|
||||||
return UserModel.model_validate(user)
|
|
||||||
except Exception:
|
query = db.query(User)
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
query = query.filter(User.oauth.contains({provider: {"sub": sub}}))
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
query = query.filter(
|
||||||
|
User.oauth[provider].cast(JSONB)["sub"].astext == sub
|
||||||
|
)
|
||||||
|
|
||||||
|
user = query.first()
|
||||||
|
return UserModel.model_validate(user) if user else None
|
||||||
|
except Exception as e:
|
||||||
|
# You may want to log the exception here
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_users(
|
def get_users(
|
||||||
@@ -171,8 +320,9 @@ class UsersTable:
|
|||||||
filter: Optional[dict] = None,
|
filter: Optional[dict] = None,
|
||||||
skip: Optional[int] = None,
|
skip: Optional[int] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
) -> UserListResponse:
|
) -> dict:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
# Join GroupMember so we can order by group_id when requested
|
||||||
query = db.query(User)
|
query = db.query(User)
|
||||||
|
|
||||||
if filter:
|
if filter:
|
||||||
@@ -185,14 +335,76 @@ class UsersTable:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
channel_id = filter.get("channel_id")
|
||||||
|
if channel_id:
|
||||||
|
query = query.filter(
|
||||||
|
exists(
|
||||||
|
select(ChannelMember.id).where(
|
||||||
|
ChannelMember.user_id == User.id,
|
||||||
|
ChannelMember.channel_id == channel_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ids = filter.get("user_ids")
|
||||||
|
group_ids = filter.get("group_ids")
|
||||||
|
|
||||||
|
if isinstance(user_ids, list) and isinstance(group_ids, list):
|
||||||
|
# If both are empty lists, return no users
|
||||||
|
if not user_ids and not group_ids:
|
||||||
|
return {"users": [], "total": 0}
|
||||||
|
|
||||||
|
if user_ids:
|
||||||
|
query = query.filter(User.id.in_(user_ids))
|
||||||
|
|
||||||
|
if group_ids:
|
||||||
|
query = query.filter(
|
||||||
|
exists(
|
||||||
|
select(GroupMember.id).where(
|
||||||
|
GroupMember.user_id == User.id,
|
||||||
|
GroupMember.group_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
roles = filter.get("roles")
|
||||||
|
if roles:
|
||||||
|
include_roles = [role for role in roles if not role.startswith("!")]
|
||||||
|
exclude_roles = [role[1:] for role in roles if role.startswith("!")]
|
||||||
|
|
||||||
|
if include_roles:
|
||||||
|
query = query.filter(User.role.in_(include_roles))
|
||||||
|
if exclude_roles:
|
||||||
|
query = query.filter(~User.role.in_(exclude_roles))
|
||||||
|
|
||||||
order_by = filter.get("order_by")
|
order_by = filter.get("order_by")
|
||||||
direction = filter.get("direction")
|
direction = filter.get("direction")
|
||||||
|
|
||||||
if order_by == "name":
|
if order_by and order_by.startswith("group_id:"):
|
||||||
|
group_id = order_by.split(":", 1)[1]
|
||||||
|
|
||||||
|
# Subquery that checks if the user belongs to the group
|
||||||
|
membership_exists = exists(
|
||||||
|
select(GroupMember.id).where(
|
||||||
|
GroupMember.user_id == User.id,
|
||||||
|
GroupMember.group_id == group_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# CASE: user in group → 1, user not in group → 0
|
||||||
|
group_sort = case((membership_exists, 1), else_=0)
|
||||||
|
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(group_sort.asc(), User.name.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(group_sort.desc(), User.name.asc())
|
||||||
|
|
||||||
|
elif order_by == "name":
|
||||||
if direction == "asc":
|
if direction == "asc":
|
||||||
query = query.order_by(User.name.asc())
|
query = query.order_by(User.name.asc())
|
||||||
else:
|
else:
|
||||||
query = query.order_by(User.name.desc())
|
query = query.order_by(User.name.desc())
|
||||||
|
|
||||||
elif order_by == "email":
|
elif order_by == "email":
|
||||||
if direction == "asc":
|
if direction == "asc":
|
||||||
query = query.order_by(User.email.asc())
|
query = query.order_by(User.email.asc())
|
||||||
@@ -225,18 +437,32 @@ class UsersTable:
|
|||||||
else:
|
else:
|
||||||
query = query.order_by(User.created_at.desc())
|
query = query.order_by(User.created_at.desc())
|
||||||
|
|
||||||
if skip:
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# correct pagination logic
|
||||||
|
if skip is not None:
|
||||||
query = query.offset(skip)
|
query = query.offset(skip)
|
||||||
if limit:
|
if limit is not None:
|
||||||
query = query.limit(limit)
|
query = query.limit(limit)
|
||||||
|
|
||||||
users = query.all()
|
users = query.all()
|
||||||
return {
|
return {
|
||||||
"users": [UserModel.model_validate(user) for user in users],
|
"users": [UserModel.model_validate(user) for user in users],
|
||||||
"total": db.query(User).count(),
|
"total": total,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
|
def get_users_by_group_id(self, group_id: str) -> list[UserModel]:
|
||||||
|
with get_db() as db:
|
||||||
|
users = (
|
||||||
|
db.query(User)
|
||||||
|
.join(GroupMember, User.id == GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [UserModel.model_validate(user) for user in users]
|
||||||
|
|
||||||
|
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||||
return [UserModel.model_validate(user) for user in users]
|
return [UserModel.model_validate(user) for user in users]
|
||||||
@@ -245,6 +471,10 @@ class UsersTable:
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
return db.query(User).count()
|
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:
|
def get_first_user(self) -> UserModel:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -269,6 +499,15 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_num_users_active_today(self) -> Optional[int]:
|
||||||
|
with get_db() as db:
|
||||||
|
current_timestamp = int(datetime.datetime.now().timestamp())
|
||||||
|
today_midnight_timestamp = current_timestamp - (current_timestamp % 86400)
|
||||||
|
query = db.query(User).filter(
|
||||||
|
User.last_active_at > today_midnight_timestamp
|
||||||
|
)
|
||||||
|
return query.count()
|
||||||
|
|
||||||
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
|
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@@ -279,6 +518,21 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def update_user_status_by_id(
|
||||||
|
self, id: str, form_data: UserStatus
|
||||||
|
) -> Optional[UserModel]:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(User).filter_by(id=id).update(
|
||||||
|
{**form_data.model_dump(exclude_none=True)}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
return UserModel.model_validate(user)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def update_user_profile_image_url_by_id(
|
def update_user_profile_image_url_by_id(
|
||||||
self, id: str, profile_image_url: str
|
self, id: str, profile_image_url: str
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
@@ -294,7 +548,8 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
|
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||||
|
def update_last_active_by_id(self, id: str) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
db.query(User).filter_by(id=id).update(
|
db.query(User).filter_by(id=id).update(
|
||||||
@@ -307,16 +562,35 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_oauth_sub_by_id(
|
def update_user_oauth_by_id(
|
||||||
self, id: str, oauth_sub: str
|
self, id: str, provider: str, sub: str
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
|
"""
|
||||||
|
Update or insert an OAuth provider/sub pair into the user's oauth JSON field.
|
||||||
|
Example resulting structure:
|
||||||
|
{
|
||||||
|
"google": { "sub": "123" },
|
||||||
|
"github": { "sub": "abc" }
|
||||||
|
}
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub})
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load existing oauth JSON or create empty
|
||||||
|
oauth = user.oauth or {}
|
||||||
|
|
||||||
|
# Update or insert provider entry
|
||||||
|
oauth[provider] = {"sub": sub}
|
||||||
|
|
||||||
|
# Persist updated JSON
|
||||||
|
db.query(User).filter_by(id=id).update({"oauth": oauth})
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
user = db.query(User).filter_by(id=id).first()
|
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -329,7 +603,8 @@ class UsersTable:
|
|||||||
user = db.query(User).filter_by(id=id).first()
|
user = db.query(User).filter_by(id=id).first()
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
# return UserModel(**user.dict())
|
# return UserModel(**user.dict())
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
|
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
|
||||||
@@ -369,23 +644,45 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_user_api_key_by_id(self, id: str, api_key: str) -> str:
|
|
||||||
try:
|
|
||||||
with get_db() as db:
|
|
||||||
result = db.query(User).filter_by(id=id).update({"api_key": api_key})
|
|
||||||
db.commit()
|
|
||||||
return True if result == 1 else False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
|
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
user = db.query(User).filter_by(id=id).first()
|
api_key = db.query(ApiKey).filter_by(user_id=id).first()
|
||||||
return user.api_key
|
return api_key.key if api_key else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(ApiKey).filter_by(user_id=id).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
new_api_key = ApiKey(
|
||||||
|
id=f"key_{id}",
|
||||||
|
user_id=id,
|
||||||
|
key=api_key,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(new_api_key)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_user_api_key_by_id(self, id: str) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.query(ApiKey).filter_by(user_id=id).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
|
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||||
@@ -399,5 +696,23 @@ class UsersTable:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_active_user_count(self) -> int:
|
||||||
|
with get_db() as db:
|
||||||
|
# Consider user active if last_active_at within the last 3 minutes
|
||||||
|
three_minutes_ago = int(time.time()) - 180
|
||||||
|
count = (
|
||||||
|
db.query(User).filter(User.last_active_at >= three_minutes_ago).count()
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def is_user_active(self, user_id: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
user = db.query(User).filter_by(id=user_id).first()
|
||||||
|
if user and user.last_active_at:
|
||||||
|
# Consider user active if last_active_at within the last 3 minutes
|
||||||
|
three_minutes_ago = int(time.time()) - 180
|
||||||
|
return user.last_active_at >= three_minutes_ago
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
Users = UsersTable()
|
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}/{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}",
|
||||||
|
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,91 @@
|
|||||||
|
import requests
|
||||||
|
import logging, os
|
||||||
|
from typing import Iterator, List, Union
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from langchain_core.document_loaders import BaseLoader
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
|
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,
|
||||||
|
user=None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
self.url = url
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
self.file_path = file_path
|
||||||
|
self.mime_type = mime_type
|
||||||
|
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
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"] = quote(os.path.basename(self.file_path))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.user is not None:
|
||||||
|
headers = include_user_info_headers(headers, self.user)
|
||||||
|
|
||||||
|
url = self.url
|
||||||
|
if url.endswith("/"):
|
||||||
|
url = url[:-1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.put(f"{url}/process", data=data, headers=headers)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error connecting to endpoint: {e}")
|
||||||
|
raise Exception(f"Error connecting to endpoint: {e}")
|
||||||
|
|
||||||
|
if response.ok:
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if response_data:
|
||||||
|
if isinstance(response_data, dict):
|
||||||
|
return [
|
||||||
|
Document(
|
||||||
|
page_content=response_data.get("page_content"),
|
||||||
|
metadata=response_data.get("metadata"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
elif isinstance(response_data, list):
|
||||||
|
documents = []
|
||||||
|
for document in response_data:
|
||||||
|
documents.append(
|
||||||
|
Document(
|
||||||
|
page_content=document.get("page_content"),
|
||||||
|
metadata=document.get("metadata"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return documents
|
||||||
|
else:
|
||||||
|
raise Exception("Error loading document: Unable to parse content")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise Exception("Error loading document: No content returned")
|
||||||
|
else:
|
||||||
|
raise Exception(
|
||||||
|
f"Error loading document: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
+2
-2
@@ -10,7 +10,7 @@ log = logging.getLogger(__name__)
|
|||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
class ExternalLoader(BaseLoader):
|
class ExternalWebLoader(BaseLoader):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
web_paths: Union[str, List[str]],
|
web_paths: Union[str, List[str]],
|
||||||
@@ -32,7 +32,7 @@ class ExternalLoader(BaseLoader):
|
|||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.external_url,
|
self.external_url,
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) External Web Loader",
|
||||||
"Authorization": f"Bearer {self.external_api_key}",
|
"Authorization": f"Bearer {self.external_api_key}",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
@@ -2,7 +2,9 @@ import requests
|
|||||||
import logging
|
import logging
|
||||||
import ftfy
|
import ftfy
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
from azure.identity import DefaultAzureCredential
|
||||||
from langchain_community.document_loaders import (
|
from langchain_community.document_loaders import (
|
||||||
AzureAIDocumentIntelligenceLoader,
|
AzureAIDocumentIntelligenceLoader,
|
||||||
BSHTMLLoader,
|
BSHTMLLoader,
|
||||||
@@ -13,7 +15,7 @@ from langchain_community.document_loaders import (
|
|||||||
TextLoader,
|
TextLoader,
|
||||||
UnstructuredEPubLoader,
|
UnstructuredEPubLoader,
|
||||||
UnstructuredExcelLoader,
|
UnstructuredExcelLoader,
|
||||||
UnstructuredMarkdownLoader,
|
UnstructuredODTLoader,
|
||||||
UnstructuredPowerPointLoader,
|
UnstructuredPowerPointLoader,
|
||||||
UnstructuredRSTLoader,
|
UnstructuredRSTLoader,
|
||||||
UnstructuredXMLLoader,
|
UnstructuredXMLLoader,
|
||||||
@@ -21,7 +23,12 @@ from langchain_community.document_loaders import (
|
|||||||
)
|
)
|
||||||
from langchain_core.documents import Document
|
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.mistral import MistralLoader
|
||||||
|
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
|
||||||
|
from open_webui.retrieval.loaders.mineru import MinerULoader
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||||
|
|
||||||
@@ -72,7 +79,6 @@ known_source_ext = [
|
|||||||
"swift",
|
"swift",
|
||||||
"vue",
|
"vue",
|
||||||
"svelte",
|
"svelte",
|
||||||
"msg",
|
|
||||||
"ex",
|
"ex",
|
||||||
"exs",
|
"exs",
|
||||||
"erl",
|
"erl",
|
||||||
@@ -126,17 +132,20 @@ class TikaLoader:
|
|||||||
|
|
||||||
|
|
||||||
class DoclingLoader:
|
class DoclingLoader:
|
||||||
def __init__(
|
def __init__(self, url, api_key=None, file_path=None, mime_type=None, params=None):
|
||||||
self, url, file_path=None, mime_type=None, ocr_engine=None, ocr_lang=None
|
|
||||||
):
|
|
||||||
self.url = url.rstrip("/")
|
self.url = url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.mime_type = mime_type
|
self.mime_type = mime_type
|
||||||
self.ocr_engine = ocr_engine
|
|
||||||
self.ocr_lang = ocr_lang
|
self.params = params or {}
|
||||||
|
|
||||||
def load(self) -> list[Document]:
|
def load(self) -> list[Document]:
|
||||||
with open(self.file_path, "rb") as f:
|
with open(self.file_path, "rb") as f:
|
||||||
|
headers = {}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
files = {
|
files = {
|
||||||
"files": (
|
"files": (
|
||||||
self.file_path,
|
self.file_path,
|
||||||
@@ -145,20 +154,15 @@ class DoclingLoader:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
params = {
|
r = requests.post(
|
||||||
"image_export_mode": "placeholder",
|
f"{self.url}/v1/convert/file",
|
||||||
"table_mode": "accurate",
|
files=files,
|
||||||
}
|
data={
|
||||||
|
"image_export_mode": "placeholder",
|
||||||
if self.ocr_engine and self.ocr_lang:
|
**self.params,
|
||||||
params["ocr_engine"] = self.ocr_engine
|
},
|
||||||
params["ocr_lang"] = [
|
headers=headers,
|
||||||
lang.strip() for lang in self.ocr_lang.split(",") if lang.strip()
|
)
|
||||||
]
|
|
||||||
|
|
||||||
endpoint = f"{self.url}/v1alpha/convert/file"
|
|
||||||
r = requests.post(endpoint, files=files, data=params)
|
|
||||||
|
|
||||||
if r.ok:
|
if r.ok:
|
||||||
result = r.json()
|
result = r.json()
|
||||||
document_data = result.get("document", {})
|
document_data = result.get("document", {})
|
||||||
@@ -167,7 +171,6 @@ class DoclingLoader:
|
|||||||
metadata = {"Content-Type": self.mime_type} if self.mime_type else {}
|
metadata = {"Content-Type": self.mime_type} if self.mime_type else {}
|
||||||
|
|
||||||
log.debug("Docling extracted text: %s", text)
|
log.debug("Docling extracted text: %s", text)
|
||||||
|
|
||||||
return [Document(page_content=text, metadata=metadata)]
|
return [Document(page_content=text, metadata=metadata)]
|
||||||
else:
|
else:
|
||||||
error_msg = f"Error calling Docling API: {r.reason}"
|
error_msg = f"Error calling Docling API: {r.reason}"
|
||||||
@@ -184,6 +187,7 @@ class DoclingLoader:
|
|||||||
class Loader:
|
class Loader:
|
||||||
def __init__(self, engine: str = "", **kwargs):
|
def __init__(self, engine: str = "", **kwargs):
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
|
self.user = kwargs.get("user", None)
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
def load(
|
def load(
|
||||||
@@ -201,53 +205,141 @@ class Loader:
|
|||||||
|
|
||||||
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
||||||
return file_ext in known_source_ext or (
|
return file_ext in known_source_ext or (
|
||||||
file_content_type and file_content_type.find("text/") >= 0
|
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):
|
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
||||||
file_ext = filename.split(".")[-1].lower()
|
file_ext = filename.split(".")[-1].lower()
|
||||||
|
|
||||||
if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
|
if (
|
||||||
|
self.engine == "external"
|
||||||
|
and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL")
|
||||||
|
and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY")
|
||||||
|
):
|
||||||
|
loader = ExternalDocumentLoader(
|
||||||
|
file_path=file_path,
|
||||||
|
url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"),
|
||||||
|
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
|
||||||
|
mime_type=file_content_type,
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
|
||||||
if self._is_text_file(file_ext, file_content_type):
|
if self._is_text_file(file_ext, file_content_type):
|
||||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||||
else:
|
else:
|
||||||
loader = TikaLoader(
|
loader = TikaLoader(
|
||||||
url=self.kwargs.get("TIKA_SERVER_URL"),
|
url=self.kwargs.get("TIKA_SERVER_URL"),
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
mime_type=file_content_type,
|
|
||||||
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
|
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
self.engine == "datalab_marker"
|
||||||
|
and self.kwargs.get("DATALAB_MARKER_API_KEY")
|
||||||
|
and file_ext
|
||||||
|
in [
|
||||||
|
"pdf",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"ods",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"odt",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"odp",
|
||||||
|
"html",
|
||||||
|
"epub",
|
||||||
|
"png",
|
||||||
|
"jpeg",
|
||||||
|
"jpg",
|
||||||
|
"webp",
|
||||||
|
"gif",
|
||||||
|
"tiff",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
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/marker" # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349
|
||||||
|
|
||||||
|
loader = DatalabMarkerLoader(
|
||||||
|
file_path=file_path,
|
||||||
|
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"):
|
elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
|
||||||
if self._is_text_file(file_ext, file_content_type):
|
if self._is_text_file(file_ext, file_content_type):
|
||||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||||
else:
|
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(
|
loader = DoclingLoader(
|
||||||
url=self.kwargs.get("DOCLING_SERVER_URL"),
|
url=self.kwargs.get("DOCLING_SERVER_URL"),
|
||||||
|
api_key=self.kwargs.get("DOCLING_API_KEY", None),
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
mime_type=file_content_type,
|
mime_type=file_content_type,
|
||||||
ocr_engine=self.kwargs.get("DOCLING_OCR_ENGINE"),
|
params=params,
|
||||||
ocr_lang=self.kwargs.get("DOCLING_OCR_LANG"),
|
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
self.engine == "document_intelligence"
|
self.engine == "document_intelligence"
|
||||||
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
|
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
|
||||||
and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != ""
|
|
||||||
and (
|
and (
|
||||||
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
|
file_ext in ["pdf", "docx", "ppt", "pptx"]
|
||||||
or file_content_type
|
or file_content_type
|
||||||
in [
|
in [
|
||||||
"application/vnd.ms-excel",
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
"application/vnd.ms-powerpoint",
|
"application/vnd.ms-powerpoint",
|
||||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
loader = AzureAIDocumentIntelligenceLoader(
|
if self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "":
|
||||||
|
loader = AzureAIDocumentIntelligenceLoader(
|
||||||
|
file_path=file_path,
|
||||||
|
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||||
|
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
|
||||||
|
api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
loader = AzureAIDocumentIntelligenceLoader(
|
||||||
|
file_path=file_path,
|
||||||
|
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||||
|
azure_credential=DefaultAzureCredential(),
|
||||||
|
api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"),
|
||||||
|
)
|
||||||
|
elif self.engine == "mineru" and file_ext in [
|
||||||
|
"pdf"
|
||||||
|
]: # MinerU currently only supports PDF
|
||||||
|
loader = MinerULoader(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
api_mode=self.kwargs.get("MINERU_API_MODE", "local"),
|
||||||
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
|
api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"),
|
||||||
|
api_key=self.kwargs.get("MINERU_API_KEY", ""),
|
||||||
|
params=self.kwargs.get("MINERU_PARAMS", {}),
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
self.engine == "mistral_ocr"
|
self.engine == "mistral_ocr"
|
||||||
@@ -256,7 +348,9 @@ class Loader:
|
|||||||
in ["pdf"] # Mistral OCR currently only supports PDF and images
|
in ["pdf"] # Mistral OCR currently only supports PDF and images
|
||||||
):
|
):
|
||||||
loader = MistralLoader(
|
loader = MistralLoader(
|
||||||
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
|
base_url=self.kwargs.get("MISTRAL_OCR_API_BASE_URL"),
|
||||||
|
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"),
|
||||||
|
file_path=file_path,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if file_ext == "pdf":
|
if file_ext == "pdf":
|
||||||
@@ -293,6 +387,8 @@ class Loader:
|
|||||||
loader = UnstructuredPowerPointLoader(file_path)
|
loader = UnstructuredPowerPointLoader(file_path)
|
||||||
elif file_ext == "msg":
|
elif file_ext == "msg":
|
||||||
loader = OutlookMessageLoader(file_path)
|
loader = OutlookMessageLoader(file_path)
|
||||||
|
elif file_ext == "odt":
|
||||||
|
loader = UnstructuredODTLoader(file_path)
|
||||||
elif self._is_text_file(file_ext, file_content_type):
|
elif self._is_text_file(file_ext, file_content_type):
|
||||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from typing import List, Optional
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MinerULoader:
|
||||||
|
"""
|
||||||
|
MinerU document parser loader supporting both Cloud API and Local API modes.
|
||||||
|
|
||||||
|
Cloud API: Uses MinerU managed service with async task-based processing
|
||||||
|
Local API: Uses self-hosted MinerU API with synchronous processing
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
api_mode: str = "local",
|
||||||
|
api_url: str = "http://localhost:8000",
|
||||||
|
api_key: str = "",
|
||||||
|
params: dict = None,
|
||||||
|
):
|
||||||
|
self.file_path = file_path
|
||||||
|
self.api_mode = api_mode.lower()
|
||||||
|
self.api_url = api_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
# Parse params dict with defaults
|
||||||
|
self.params = params or {}
|
||||||
|
self.enable_ocr = params.get("enable_ocr", False)
|
||||||
|
self.enable_formula = params.get("enable_formula", True)
|
||||||
|
self.enable_table = params.get("enable_table", True)
|
||||||
|
self.language = params.get("language", "en")
|
||||||
|
self.model_version = params.get("model_version", "pipeline")
|
||||||
|
|
||||||
|
self.page_ranges = self.params.pop("page_ranges", "")
|
||||||
|
|
||||||
|
# Validate API mode
|
||||||
|
if self.api_mode not in ["local", "cloud"]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid API mode: {self.api_mode}. Must be 'local' or 'cloud'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate Cloud API requirements
|
||||||
|
if self.api_mode == "cloud" and not self.api_key:
|
||||||
|
raise ValueError("API key is required for Cloud API mode")
|
||||||
|
|
||||||
|
def load(self) -> List[Document]:
|
||||||
|
"""
|
||||||
|
Main entry point for loading and parsing the document.
|
||||||
|
Routes to Cloud or Local API based on api_mode.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self.api_mode == "cloud":
|
||||||
|
return self._load_cloud_api()
|
||||||
|
else:
|
||||||
|
return self._load_local_api()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error loading document with MinerU: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _load_local_api(self) -> List[Document]:
|
||||||
|
"""
|
||||||
|
Load document using Local API (synchronous).
|
||||||
|
Posts file to /file_parse endpoint and gets immediate response.
|
||||||
|
"""
|
||||||
|
log.info(f"Using MinerU Local API at {self.api_url}")
|
||||||
|
|
||||||
|
filename = os.path.basename(self.file_path)
|
||||||
|
|
||||||
|
# Build form data for Local API
|
||||||
|
form_data = {
|
||||||
|
**self.params,
|
||||||
|
"return_md": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page ranges (Local API uses start_page_id and end_page_id)
|
||||||
|
if self.page_ranges:
|
||||||
|
# For simplicity, if page_ranges is specified, log a warning
|
||||||
|
# Full page range parsing would require parsing the string
|
||||||
|
log.warning(
|
||||||
|
f"Page ranges '{self.page_ranges}' specified but Local API uses different format. "
|
||||||
|
"Consider using start_page_id/end_page_id parameters if needed."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.file_path, "rb") as f:
|
||||||
|
files = {"files": (filename, f, "application/octet-stream")}
|
||||||
|
|
||||||
|
log.info(f"Sending file to MinerU Local API: {filename}")
|
||||||
|
log.debug(f"Local API parameters: {form_data}")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.api_url}/file_parse",
|
||||||
|
data=form_data,
|
||||||
|
files=files,
|
||||||
|
timeout=300, # 5 minute timeout for large documents
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
|
||||||
|
)
|
||||||
|
except requests.Timeout:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail="MinerU Local API request timed out",
|
||||||
|
)
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
error_detail = f"MinerU Local API request failed: {e}"
|
||||||
|
if e.response is not None:
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
error_detail += f" - {error_data}"
|
||||||
|
except:
|
||||||
|
error_detail += f" - {e.response.text}"
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error calling MinerU Local API: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Invalid JSON response from MinerU Local API: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract markdown content from response
|
||||||
|
if "results" not in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="MinerU Local API response missing 'results' field",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = result["results"]
|
||||||
|
if not results:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="MinerU returned empty results",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the first (and typically only) result
|
||||||
|
file_result = list(results.values())[0]
|
||||||
|
markdown_content = file_result.get("md_content", "")
|
||||||
|
|
||||||
|
if not markdown_content:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="MinerU returned empty markdown content",
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f"Successfully parsed document with MinerU Local API: {filename}")
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"source": filename,
|
||||||
|
"api_mode": "local",
|
||||||
|
"backend": result.get("backend", "unknown"),
|
||||||
|
"version": result.get("version", "unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Document(page_content=markdown_content, metadata=metadata)]
|
||||||
|
|
||||||
|
def _load_cloud_api(self) -> List[Document]:
|
||||||
|
"""
|
||||||
|
Load document using Cloud API (asynchronous).
|
||||||
|
Uses batch upload endpoint to avoid need for public file URLs.
|
||||||
|
"""
|
||||||
|
log.info(f"Using MinerU Cloud API at {self.api_url}")
|
||||||
|
|
||||||
|
filename = os.path.basename(self.file_path)
|
||||||
|
|
||||||
|
# Step 1: Request presigned upload URL
|
||||||
|
batch_id, upload_url = self._request_upload_url(filename)
|
||||||
|
|
||||||
|
# Step 2: Upload file to presigned URL
|
||||||
|
self._upload_to_presigned_url(upload_url)
|
||||||
|
|
||||||
|
# Step 3: Poll for results
|
||||||
|
result = self._poll_batch_status(batch_id, filename)
|
||||||
|
|
||||||
|
# Step 4: Download and extract markdown from ZIP
|
||||||
|
markdown_content = self._download_and_extract_zip(
|
||||||
|
result["full_zip_url"], filename
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f"Successfully parsed document with MinerU Cloud API: {filename}")
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"source": filename,
|
||||||
|
"api_mode": "cloud",
|
||||||
|
"batch_id": batch_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Document(page_content=markdown_content, metadata=metadata)]
|
||||||
|
|
||||||
|
def _request_upload_url(self, filename: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Request presigned upload URL from Cloud API.
|
||||||
|
Returns (batch_id, upload_url).
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build request body
|
||||||
|
request_body = {
|
||||||
|
**self.params,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": filename,
|
||||||
|
"is_ocr": self.enable_ocr,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add page ranges if specified
|
||||||
|
if self.page_ranges:
|
||||||
|
request_body["files"][0]["page_ranges"] = self.page_ranges
|
||||||
|
|
||||||
|
log.info(f"Requesting upload URL for: {filename}")
|
||||||
|
log.debug(f"Cloud API request body: {request_body}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.api_url}/file-urls/batch",
|
||||||
|
headers=headers,
|
||||||
|
json=request_body,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
error_detail = f"Failed to request upload URL: {e}"
|
||||||
|
if e.response is not None:
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
error_detail += f" - {error_data.get('msg', error_data)}"
|
||||||
|
except:
|
||||||
|
error_detail += f" - {e.response.text}"
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error requesting upload URL: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Invalid JSON response: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for API error response
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
batch_id = data.get("batch_id")
|
||||||
|
file_urls = data.get("file_urls", [])
|
||||||
|
|
||||||
|
if not batch_id or not file_urls:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="MinerU Cloud API response missing batch_id or file_urls",
|
||||||
|
)
|
||||||
|
|
||||||
|
upload_url = file_urls[0]
|
||||||
|
log.info(f"Received upload URL for batch: {batch_id}")
|
||||||
|
|
||||||
|
return batch_id, upload_url
|
||||||
|
|
||||||
|
def _upload_to_presigned_url(self, upload_url: str) -> None:
|
||||||
|
"""
|
||||||
|
Upload file to presigned URL (no authentication needed).
|
||||||
|
"""
|
||||||
|
log.info(f"Uploading file to presigned URL")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.file_path, "rb") as f:
|
||||||
|
response = requests.put(
|
||||||
|
upload_url,
|
||||||
|
data=f,
|
||||||
|
timeout=300, # 5 minute timeout for large files
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
|
||||||
|
)
|
||||||
|
except requests.Timeout:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail="File upload to presigned URL timed out",
|
||||||
|
)
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to upload file to presigned URL: {e}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error uploading file: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("File uploaded successfully")
|
||||||
|
|
||||||
|
def _poll_batch_status(self, batch_id: str, filename: str) -> dict:
|
||||||
|
"""
|
||||||
|
Poll batch status until completion.
|
||||||
|
Returns the result dict for the file.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
max_iterations = 300 # 10 minutes max (2 seconds per iteration)
|
||||||
|
poll_interval = 2 # seconds
|
||||||
|
|
||||||
|
log.info(f"Polling batch status: {batch_id}")
|
||||||
|
|
||||||
|
for iteration in range(max_iterations):
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{self.api_url}/extract-results/batch/{batch_id}",
|
||||||
|
headers=headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
error_detail = f"Failed to poll batch status: {e}"
|
||||||
|
if e.response is not None:
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
error_detail += f" - {error_data.get('msg', error_data)}"
|
||||||
|
except:
|
||||||
|
error_detail += f" - {e.response.text}"
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error polling batch status: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Invalid JSON response while polling: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for API error response
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
extract_result = data.get("extract_result", [])
|
||||||
|
|
||||||
|
# Find our file in the batch results
|
||||||
|
file_result = None
|
||||||
|
for item in extract_result:
|
||||||
|
if item.get("file_name") == filename:
|
||||||
|
file_result = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not file_result:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"File {filename} not found in batch results",
|
||||||
|
)
|
||||||
|
|
||||||
|
state = file_result.get("state")
|
||||||
|
|
||||||
|
if state == "done":
|
||||||
|
log.info(f"Processing complete for {filename}")
|
||||||
|
return file_result
|
||||||
|
elif state == "failed":
|
||||||
|
error_msg = file_result.get("err_msg", "Unknown error")
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"MinerU processing failed: {error_msg}",
|
||||||
|
)
|
||||||
|
elif state in ["waiting-file", "pending", "running", "converting"]:
|
||||||
|
# Still processing
|
||||||
|
if iteration % 10 == 0: # Log every 20 seconds
|
||||||
|
log.info(
|
||||||
|
f"Processing status: {state} (iteration {iteration + 1}/{max_iterations})"
|
||||||
|
)
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
else:
|
||||||
|
log.warning(f"Unknown state: {state}")
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail="MinerU processing timed out after 10 minutes",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _download_and_extract_zip(self, zip_url: str, filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Download ZIP file from CDN and extract markdown content.
|
||||||
|
Returns the markdown content as a string.
|
||||||
|
"""
|
||||||
|
log.info(f"Downloading results from: {zip_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(zip_url, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to download results ZIP: {e}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error downloading results: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save ZIP to temporary file and extract
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
||||||
|
tmp_zip.write(response.content)
|
||||||
|
tmp_zip_path = tmp_zip.name
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Extract ZIP
|
||||||
|
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
|
||||||
|
zip_ref.extractall(tmp_dir)
|
||||||
|
|
||||||
|
# Find markdown file - search recursively for any .md file
|
||||||
|
markdown_content = None
|
||||||
|
found_md_path = None
|
||||||
|
|
||||||
|
# First, list all files in the ZIP for debugging
|
||||||
|
all_files = []
|
||||||
|
for root, dirs, files in os.walk(tmp_dir):
|
||||||
|
for file in files:
|
||||||
|
full_path = os.path.join(root, file)
|
||||||
|
all_files.append(full_path)
|
||||||
|
# Look for any .md file
|
||||||
|
if file.endswith(".md"):
|
||||||
|
found_md_path = full_path
|
||||||
|
log.info(f"Found markdown file at: {full_path}")
|
||||||
|
try:
|
||||||
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
|
markdown_content = f.read()
|
||||||
|
if (
|
||||||
|
markdown_content
|
||||||
|
): # Use the first non-empty markdown file
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to read {full_path}: {e}")
|
||||||
|
if markdown_content:
|
||||||
|
break
|
||||||
|
|
||||||
|
if markdown_content is None:
|
||||||
|
log.error(f"Available files in ZIP: {all_files}")
|
||||||
|
# Try to provide more helpful error message
|
||||||
|
md_files = [f for f in all_files if f.endswith(".md")]
|
||||||
|
if md_files:
|
||||||
|
error_msg = (
|
||||||
|
f"Found .md files but couldn't read them: {md_files}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_msg = (
|
||||||
|
f"No .md files found in ZIP. Available files: {all_files}"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=error_msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up temporary ZIP file
|
||||||
|
os.unlink(tmp_zip_path)
|
||||||
|
|
||||||
|
except zipfile.BadZipFile as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Invalid ZIP file received: {e}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error extracting ZIP: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not markdown_content:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Extracted markdown content is empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f"Successfully extracted markdown content ({len(markdown_content)} characters)"
|
||||||
|
)
|
||||||
|
return markdown_content
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import requests
|
import requests
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||||
@@ -14,27 +18,87 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|||||||
|
|
||||||
class MistralLoader:
|
class MistralLoader:
|
||||||
"""
|
"""
|
||||||
|
Enhanced Mistral OCR loader with both sync and async support.
|
||||||
Loads documents by processing them through the Mistral OCR API.
|
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,
|
||||||
def __init__(self, api_key: str, file_path: str):
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
file_path: str,
|
||||||
|
timeout: int = 300, # 5 minutes default
|
||||||
|
max_retries: int = 3,
|
||||||
|
enable_debug_logging: bool = False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initializes the loader.
|
Initializes the loader with enhanced features.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Your Mistral API key.
|
api_key: Your Mistral API key.
|
||||||
file_path: The local path to the PDF file to process.
|
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:
|
if not api_key:
|
||||||
raise ValueError("API key cannot be empty.")
|
raise ValueError("API key cannot be empty.")
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise FileNotFoundError(f"File not found at {file_path}")
|
raise FileNotFoundError(f"File not found at {file_path}")
|
||||||
|
|
||||||
|
self.base_url = (
|
||||||
|
base_url.rstrip("/") if base_url else "https://api.mistral.ai/v1"
|
||||||
|
)
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.headers = {"Authorization": f"Bearer {self.api_key}"}
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.debug = enable_debug_logging
|
||||||
|
|
||||||
|
# PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations
|
||||||
|
# This prevents long-running OCR operations from affecting quick operations
|
||||||
|
# and improves user experience by failing fast on operations that should be quick
|
||||||
|
self.upload_timeout = min(
|
||||||
|
timeout, 120
|
||||||
|
) # Cap upload at 2 minutes - prevents hanging on large files
|
||||||
|
self.url_timeout = (
|
||||||
|
30 # URL requests should be fast - fail quickly if API is slow
|
||||||
|
)
|
||||||
|
self.ocr_timeout = (
|
||||||
|
timeout # OCR can take the full timeout - this is the heavy operation
|
||||||
|
)
|
||||||
|
self.cleanup_timeout = (
|
||||||
|
30 # Cleanup should be quick - don't hang on file deletion
|
||||||
|
)
|
||||||
|
|
||||||
|
# PERFORMANCE OPTIMIZATION: Pre-compute file info to avoid repeated filesystem calls
|
||||||
|
# This avoids multiple os.path.basename() and os.path.getsize() calls during processing
|
||||||
|
self.file_name = os.path.basename(file_path)
|
||||||
|
self.file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# ENHANCEMENT: Added User-Agent for better API tracking and debugging
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"User-Agent": "OpenWebUI-MistralLoader/2.0", # Helps API provider track usage
|
||||||
|
}
|
||||||
|
|
||||||
|
def _debug_log(self, message: str, *args) -> None:
|
||||||
|
"""
|
||||||
|
PERFORMANCE OPTIMIZATION: Conditional debug logging for performance.
|
||||||
|
|
||||||
|
Only processes debug messages when debug mode is enabled, avoiding
|
||||||
|
string formatting overhead in production environments.
|
||||||
|
"""
|
||||||
|
if self.debug:
|
||||||
|
log.debug(message, *args)
|
||||||
|
|
||||||
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
||||||
"""Checks response status and returns JSON content."""
|
"""Checks response status and returns JSON content."""
|
||||||
@@ -54,24 +118,154 @@ class MistralLoader:
|
|||||||
log.error(f"JSON decode error: {json_err} - Response: {response.text}")
|
log.error(f"JSON decode error: {json_err} - Response: {response.text}")
|
||||||
raise # Re-raise after logging
|
raise # Re-raise after logging
|
||||||
|
|
||||||
def _upload_file(self) -> str:
|
async def _handle_response_async(
|
||||||
"""Uploads the file to Mistral for OCR processing."""
|
self, response: aiohttp.ClientResponse
|
||||||
log.info("Uploading file to Mistral API")
|
) -> Dict[str, Any]:
|
||||||
url = f"{self.BASE_API_URL}/files"
|
"""Async version of response handling with better error info."""
|
||||||
file_name = os.path.basename(self.file_path)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.file_path, "rb") as f:
|
response.raise_for_status()
|
||||||
files = {"file": (file_name, f, "application/pdf")}
|
|
||||||
data = {"purpose": "ocr"}
|
|
||||||
|
|
||||||
upload_headers = self.headers.copy() # Avoid modifying self.headers
|
# Check content type
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
response = requests.post(
|
if "application/json" not in content_type:
|
||||||
url, headers=upload_headers, files=files, data=data
|
if response.status == 204:
|
||||||
|
return {}
|
||||||
|
text = await response.text()
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected content type: {content_type}, body: {text[:200]}..."
|
||||||
)
|
)
|
||||||
|
|
||||||
response_data = self._handle_response(response)
|
return await response.json()
|
||||||
|
|
||||||
|
except aiohttp.ClientResponseError as e:
|
||||||
|
error_text = await response.text() if response else "No response"
|
||||||
|
log.error(f"HTTP {e.status}: {e.message} - Response: {error_text[:500]}")
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
log.error(f"Client error: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Unexpected error processing response: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _is_retryable_error(self, error: Exception) -> bool:
|
||||||
|
"""
|
||||||
|
ENHANCEMENT: Intelligent error classification for retry logic.
|
||||||
|
|
||||||
|
Determines if an error is retryable based on its type and status code.
|
||||||
|
This prevents wasting time retrying errors that will never succeed
|
||||||
|
(like authentication errors) while ensuring transient errors are retried.
|
||||||
|
|
||||||
|
Retryable errors:
|
||||||
|
- Network connection errors (temporary network issues)
|
||||||
|
- Timeouts (server might be temporarily overloaded)
|
||||||
|
- Server errors (5xx status codes - server-side issues)
|
||||||
|
- Rate limiting (429 status - temporary throttling)
|
||||||
|
|
||||||
|
Non-retryable errors:
|
||||||
|
- Authentication errors (401, 403 - won't fix with retry)
|
||||||
|
- Bad request errors (400 - malformed request)
|
||||||
|
- Not found errors (404 - resource doesn't exist)
|
||||||
|
"""
|
||||||
|
if isinstance(error, requests.exceptions.ConnectionError):
|
||||||
|
return True # Network issues are usually temporary
|
||||||
|
if isinstance(error, requests.exceptions.Timeout):
|
||||||
|
return True # Timeouts might resolve on retry
|
||||||
|
if isinstance(error, requests.exceptions.HTTPError):
|
||||||
|
# Only retry on server errors (5xx) or rate limits (429)
|
||||||
|
if hasattr(error, "response") and error.response is not None:
|
||||||
|
status_code = error.response.status_code
|
||||||
|
return status_code >= 500 or status_code == 429
|
||||||
|
return False
|
||||||
|
if isinstance(
|
||||||
|
error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError)
|
||||||
|
):
|
||||||
|
return True # Async network/timeout errors are retryable
|
||||||
|
if isinstance(error, aiohttp.ClientResponseError):
|
||||||
|
return error.status >= 500 or error.status == 429
|
||||||
|
return False # All other errors are non-retryable
|
||||||
|
|
||||||
|
def _retry_request_sync(self, request_func, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
ENHANCEMENT: Synchronous retry logic with intelligent error classification.
|
||||||
|
|
||||||
|
Uses exponential backoff with jitter to avoid thundering herd problems.
|
||||||
|
The wait time increases exponentially but is capped at 30 seconds to
|
||||||
|
prevent excessive delays. Only retries errors that are likely to succeed
|
||||||
|
on subsequent attempts.
|
||||||
|
"""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
return request_func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == self.max_retries - 1 or not self._is_retryable_error(e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
# PERFORMANCE OPTIMIZATION: Exponential backoff with cap
|
||||||
|
# Prevents overwhelming the server while ensuring reasonable retry delays
|
||||||
|
wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds
|
||||||
|
log.warning(
|
||||||
|
f"Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. "
|
||||||
|
f"Retrying in {wait_time}s..."
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
async def _retry_request_async(self, request_func, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
ENHANCEMENT: Async retry logic with intelligent error classification.
|
||||||
|
|
||||||
|
Async version of retry logic that doesn't block the event loop during
|
||||||
|
wait periods. Uses the same exponential backoff strategy as sync version.
|
||||||
|
"""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
return await request_func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == self.max_retries - 1 or not self._is_retryable_error(e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
# PERFORMANCE OPTIMIZATION: Non-blocking exponential backoff
|
||||||
|
wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds
|
||||||
|
log.warning(
|
||||||
|
f"Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. "
|
||||||
|
f"Retrying in {wait_time}s..."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait_time) # Non-blocking wait
|
||||||
|
|
||||||
|
def _upload_file(self) -> str:
|
||||||
|
"""
|
||||||
|
PERFORMANCE OPTIMIZATION: Enhanced file upload with streaming consideration.
|
||||||
|
|
||||||
|
Uploads the file to Mistral for OCR processing (sync version).
|
||||||
|
Uses context manager for file handling to ensure proper resource cleanup.
|
||||||
|
Although streaming is not enabled for this endpoint, the file is opened
|
||||||
|
in a context manager to minimize memory usage duration.
|
||||||
|
"""
|
||||||
|
log.info("Uploading file to Mistral API")
|
||||||
|
url = f"{self.base_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")
|
file_id = response_data.get("id")
|
||||||
if not file_id:
|
if not file_id:
|
||||||
raise ValueError("File ID not found in upload response.")
|
raise ValueError("File ID not found in upload response.")
|
||||||
@@ -81,16 +275,66 @@ class MistralLoader:
|
|||||||
log.error(f"Failed to upload file: {e}")
|
log.error(f"Failed to upload file: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
|
||||||
|
"""Async file upload with streaming for better memory efficiency."""
|
||||||
|
url = f"{self.base_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:
|
def _get_signed_url(self, file_id: str) -> str:
|
||||||
"""Retrieves a temporary signed URL for the uploaded file."""
|
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
|
||||||
log.info(f"Getting signed URL for file ID: {file_id}")
|
log.info(f"Getting signed URL for file ID: {file_id}")
|
||||||
url = f"{self.BASE_API_URL}/files/{file_id}/url"
|
url = f"{self.base_url}/files/{file_id}/url"
|
||||||
params = {"expiry": 1}
|
params = {"expiry": 1}
|
||||||
signed_url_headers = {**self.headers, "Accept": "application/json"}
|
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:
|
try:
|
||||||
response = requests.get(url, headers=signed_url_headers, params=params)
|
response_data = self._retry_request_sync(url_request)
|
||||||
response_data = self._handle_response(response)
|
|
||||||
signed_url = response_data.get("url")
|
signed_url = response_data.get("url")
|
||||||
if not signed_url:
|
if not signed_url:
|
||||||
raise ValueError("Signed URL not found in response.")
|
raise ValueError("Signed URL not found in response.")
|
||||||
@@ -100,10 +344,38 @@ class MistralLoader:
|
|||||||
log.error(f"Failed to get signed URL: {e}")
|
log.error(f"Failed to get signed URL: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _get_signed_url_async(
|
||||||
|
self, session: aiohttp.ClientSession, file_id: str
|
||||||
|
) -> str:
|
||||||
|
"""Async signed URL retrieval."""
|
||||||
|
url = f"{self.base_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]:
|
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
|
||||||
"""Sends the signed URL to the OCR endpoint for processing."""
|
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
|
||||||
log.info("Processing OCR via Mistral API")
|
log.info("Processing OCR via Mistral API")
|
||||||
url = f"{self.BASE_API_URL}/ocr"
|
url = f"{self.base_url}/ocr"
|
||||||
ocr_headers = {
|
ocr_headers = {
|
||||||
**self.headers,
|
**self.headers,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -118,43 +390,218 @@ class MistralLoader:
|
|||||||
"include_image_base64": False,
|
"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:
|
try:
|
||||||
response = requests.post(url, headers=ocr_headers, json=payload)
|
ocr_response = self._retry_request_sync(ocr_request)
|
||||||
ocr_response = self._handle_response(response)
|
|
||||||
log.info("OCR processing done.")
|
log.info("OCR processing done.")
|
||||||
log.debug("OCR response: %s", ocr_response)
|
self._debug_log("OCR response: %s", ocr_response)
|
||||||
return ocr_response
|
return ocr_response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed during OCR processing: {e}")
|
log.error(f"Failed during OCR processing: {e}")
|
||||||
raise
|
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_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:
|
def _delete_file(self, file_id: str) -> None:
|
||||||
"""Deletes the file from Mistral storage."""
|
"""Deletes the file from Mistral storage (sync version)."""
|
||||||
log.info(f"Deleting uploaded file ID: {file_id}")
|
log.info(f"Deleting uploaded file ID: {file_id}")
|
||||||
url = f"{self.BASE_API_URL}/files/{file_id}"
|
url = f"{self.base_url}/files/{file_id}"
|
||||||
# No specific Accept header needed, default or Authorization is usually sufficient
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.delete(url, headers=self.headers)
|
response = requests.delete(
|
||||||
delete_response = self._handle_response(
|
url, headers=self.headers, timeout=self.cleanup_timeout
|
||||||
response
|
)
|
||||||
) # Check status, ignore response body unless needed
|
delete_response = self._handle_response(response)
|
||||||
log.info(
|
log.info(f"File deleted successfully: {delete_response}")
|
||||||
f"File deleted successfully: {delete_response}"
|
|
||||||
) # Log the response if available
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error but don't necessarily halt execution if deletion fails
|
# Log error but don't necessarily halt execution if deletion fails
|
||||||
log.error(f"Failed to delete file ID {file_id}: {e}")
|
log.error(f"Failed to delete file ID {file_id}: {e}")
|
||||||
# Depending on requirements, you might choose to raise the error here
|
|
||||||
|
async def _delete_file_async(
|
||||||
|
self, session: aiohttp.ClientSession, file_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Async file deletion with error tolerance."""
|
||||||
|
try:
|
||||||
|
|
||||||
|
async def delete_request():
|
||||||
|
self._debug_log(f"Deleting file ID: {file_id}")
|
||||||
|
async with session.delete(
|
||||||
|
url=f"{self.base_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]:
|
def load(self) -> List[Document]:
|
||||||
"""
|
"""
|
||||||
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
|
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
|
||||||
|
Synchronous version for backward compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of Document objects, one for each page processed.
|
A list of Document objects, one for each page processed.
|
||||||
"""
|
"""
|
||||||
file_id = None
|
file_id = None
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Upload file
|
# 1. Upload file
|
||||||
file_id = self._upload_file()
|
file_id = self._upload_file()
|
||||||
@@ -166,53 +613,30 @@ class MistralLoader:
|
|||||||
ocr_response = self._process_ocr(signed_url)
|
ocr_response = self._process_ocr(signed_url)
|
||||||
|
|
||||||
# 4. Process results
|
# 4. Process results
|
||||||
pages_data = ocr_response.get("pages")
|
documents = self._process_results(ocr_response)
|
||||||
if not pages_data:
|
|
||||||
log.warning("No pages found in OCR response.")
|
|
||||||
return [Document(page_content="No text content found", metadata={})]
|
|
||||||
|
|
||||||
documents = []
|
total_time = time.time() - start_time
|
||||||
total_pages = len(pages_data)
|
log.info(
|
||||||
for page_data in pages_data:
|
f"Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
|
||||||
page_content = page_data.get("markdown")
|
)
|
||||||
page_index = page_data.get("index") # API uses 0-based index
|
|
||||||
|
|
||||||
if page_content is not None and page_index is not None:
|
|
||||||
documents.append(
|
|
||||||
Document(
|
|
||||||
page_content=page_content,
|
|
||||||
metadata={
|
|
||||||
"page": page_index, # 0-based index from API
|
|
||||||
"page_label": page_index
|
|
||||||
+ 1, # 1-based label for convenience
|
|
||||||
"total_pages": total_pages,
|
|
||||||
# Add other relevant metadata from page_data if available/needed
|
|
||||||
# e.g., page_data.get('width'), page_data.get('height')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not documents:
|
|
||||||
# Case where pages existed but none had valid markdown/index
|
|
||||||
log.warning(
|
|
||||||
"OCR response contained pages, but none had valid content/index."
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
Document(
|
|
||||||
page_content="No text content found in valid pages", metadata={}
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"An error occurred during the loading process: {e}")
|
total_time = time.time() - start_time
|
||||||
# Return an empty list or a specific error document on failure
|
log.error(
|
||||||
return [Document(page_content=f"Error during processing: {e}", metadata={})]
|
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:
|
finally:
|
||||||
# 5. Delete file (attempt even if prior steps failed after upload)
|
# 5. Delete file (attempt even if prior steps failed after upload)
|
||||||
if file_id:
|
if file_id:
|
||||||
@@ -223,3 +647,124 @@ class MistralLoader:
|
|||||||
log.error(
|
log.error(
|
||||||
f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}"
|
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
|
import logging
|
||||||
|
from xml.etree.ElementTree import ParseError
|
||||||
|
|
||||||
from typing import Any, Dict, Generator, List, Optional, Sequence, Union
|
from typing import Any, Dict, Generator, List, Optional, Sequence, Union
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
@@ -82,6 +83,7 @@ class YoutubeLoader:
|
|||||||
TranscriptsDisabled,
|
TranscriptsDisabled,
|
||||||
YouTubeTranscriptApi,
|
YouTubeTranscriptApi,
|
||||||
)
|
)
|
||||||
|
from youtube_transcript_api.proxies import GenericProxyConfig
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
'Could not import "youtube_transcript_api" Python package. '
|
'Could not import "youtube_transcript_api" Python package. '
|
||||||
@@ -89,19 +91,16 @@ class YoutubeLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.proxy_url:
|
if self.proxy_url:
|
||||||
youtube_proxies = {
|
youtube_proxies = GenericProxyConfig(
|
||||||
"http": self.proxy_url,
|
http_url=self.proxy_url, https_url=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]}...")
|
log.debug(f"Using proxy URL: {self.proxy_url[:14]}...")
|
||||||
else:
|
else:
|
||||||
youtube_proxies = None
|
youtube_proxies = None
|
||||||
|
|
||||||
|
transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
|
||||||
try:
|
try:
|
||||||
transcript_list = YouTubeTranscriptApi.list_transcripts(
|
transcript_list = transcript_api.list(self.video_id)
|
||||||
self.video_id, proxies=youtube_proxies
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Loading YouTube transcript failed")
|
log.exception("Loading YouTube transcript failed")
|
||||||
return []
|
return []
|
||||||
@@ -110,11 +109,37 @@ class YoutubeLoader:
|
|||||||
for lang in self.language:
|
for lang in self.language:
|
||||||
try:
|
try:
|
||||||
transcript = transcript_list.find_transcript([lang])
|
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
|
||||||
|
|
||||||
log.debug(f"Found transcript for language '{lang}'")
|
log.debug(f"Found transcript for language '{lang}'")
|
||||||
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
|
try:
|
||||||
|
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
|
||||||
|
except ParseError:
|
||||||
|
log.debug(f"Empty or invalid transcript for language '{lang}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not transcript_pieces:
|
||||||
|
log.debug(f"Empty transcript for language '{lang}'")
|
||||||
|
continue
|
||||||
|
|
||||||
transcript_text = " ".join(
|
transcript_text = " ".join(
|
||||||
map(
|
map(
|
||||||
lambda transcript_piece: transcript_piece.text.strip(" "),
|
lambda transcript_piece: (
|
||||||
|
transcript_piece.text.strip(" ")
|
||||||
|
if hasattr(transcript_piece, "text")
|
||||||
|
else ""
|
||||||
|
),
|
||||||
transcript_pieces,
|
transcript_pieces,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -131,6 +156,11 @@ class YoutubeLoader:
|
|||||||
log.warning(
|
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."
|
f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed."
|
||||||
)
|
)
|
||||||
raise NoTranscriptFound(
|
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
|
||||||
f"No transcript found for any supported language. Verify if the video has transcripts, add more languages if needed."
|
|
||||||
)
|
async def aload(self) -> Generator[Document, None, None]:
|
||||||
|
"""Asynchronously load YouTube transcripts into `Document` objects."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self.load)
|
||||||
|
|||||||
@@ -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.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
class ColBERT:
|
class ColBERT(BaseReranker):
|
||||||
def __init__(self, name, **kwargs) -> None:
|
def __init__(self, name, **kwargs) -> None:
|
||||||
log.info("ColBERT: Loading model", name)
|
log.info("ColBERT: Loading model", name)
|
||||||
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from typing import Optional, List, Tuple
|
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
|
||||||
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
class ExternalReranker:
|
class ExternalReranker(BaseReranker):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
@@ -19,7 +24,9 @@ class ExternalReranker:
|
|||||||
self.url = url
|
self.url = url
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
|
def predict(
|
||||||
|
self, sentences: List[Tuple[str, str]], user=None
|
||||||
|
) -> Optional[List[float]]:
|
||||||
query = sentences[0][0]
|
query = sentences[0][0]
|
||||||
docs = [i[1] for i in sentences]
|
docs = [i[1] for i in sentences]
|
||||||
|
|
||||||
@@ -34,12 +41,17 @@ class ExternalReranker:
|
|||||||
log.info(f"ExternalReranker:predict:model {self.model}")
|
log.info(f"ExternalReranker:predict:model {self.model}")
|
||||||
log.info(f"ExternalReranker:predict:query {query}")
|
log.info(f"ExternalReranker:predict:query {query}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||||
|
headers = include_user_info_headers(headers, user)
|
||||||
|
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{self.url}",
|
f"{self.url}",
|
||||||
headers={
|
headers=headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
|
||||||
},
|
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
|||||||
from open_webui.config import VECTOR_DB
|
|
||||||
|
|
||||||
if VECTOR_DB == "milvus":
|
|
||||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = MilvusClient()
|
|
||||||
elif VECTOR_DB == "qdrant":
|
|
||||||
from open_webui.retrieval.vector.dbs.qdrant import QdrantClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = QdrantClient()
|
|
||||||
elif VECTOR_DB == "opensearch":
|
|
||||||
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = OpenSearchClient()
|
|
||||||
elif VECTOR_DB == "pgvector":
|
|
||||||
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = PgvectorClient()
|
|
||||||
elif VECTOR_DB == "elasticsearch":
|
|
||||||
from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = ElasticsearchClient()
|
|
||||||
elif VECTOR_DB == "pinecone":
|
|
||||||
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = PineconeClient()
|
|
||||||
else:
|
|
||||||
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
|
|
||||||
|
|
||||||
VECTOR_DB_CLIENT = ChromaClient()
|
|
||||||
@@ -11,6 +11,8 @@ from open_webui.retrieval.vector.main import (
|
|||||||
SearchResult,
|
SearchResult,
|
||||||
GetResult,
|
GetResult,
|
||||||
)
|
)
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
|
|
||||||
from open_webui.config import (
|
from open_webui.config import (
|
||||||
CHROMA_DATA_PATH,
|
CHROMA_DATA_PATH,
|
||||||
CHROMA_HTTP_HOST,
|
CHROMA_HTTP_HOST,
|
||||||
@@ -144,7 +146,7 @@ class ChromaClient(VectorDBBase):
|
|||||||
ids = [item["id"] for item in items]
|
ids = [item["id"] for item in items]
|
||||||
documents = [item["text"] for item in items]
|
documents = [item["text"] for item in items]
|
||||||
embeddings = [item["vector"] for item in items]
|
embeddings = [item["vector"] for item in items]
|
||||||
metadatas = [item["metadata"] for item in items]
|
metadatas = [process_metadata(item["metadata"]) for item in items]
|
||||||
|
|
||||||
for batch in create_batches(
|
for batch in create_batches(
|
||||||
api=self.client,
|
api=self.client,
|
||||||
@@ -164,7 +166,7 @@ class ChromaClient(VectorDBBase):
|
|||||||
ids = [item["id"] for item in items]
|
ids = [item["id"] for item in items]
|
||||||
documents = [item["text"] for item in items]
|
documents = [item["text"] for item in items]
|
||||||
embeddings = [item["vector"] for item in items]
|
embeddings = [item["vector"] for item in items]
|
||||||
metadatas = [item["metadata"] for item in items]
|
metadatas = [process_metadata(item["metadata"]) for item in items]
|
||||||
|
|
||||||
collection.upsert(
|
collection.upsert(
|
||||||
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
|
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from elasticsearch import Elasticsearch, BadRequestError
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import ssl
|
import ssl
|
||||||
from elasticsearch.helpers import bulk, scan
|
from elasticsearch.helpers import bulk, scan
|
||||||
|
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
VectorDBBase,
|
VectorDBBase,
|
||||||
VectorItem,
|
VectorItem,
|
||||||
@@ -243,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
|
|||||||
"collection": collection_name,
|
"collection": collection_name,
|
||||||
"vector": item["vector"],
|
"vector": item["vector"],
|
||||||
"text": item["text"],
|
"text": item["text"],
|
||||||
"metadata": item["metadata"],
|
"metadata": process_metadata(item["metadata"]),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for item in batch
|
for item in batch
|
||||||
@@ -264,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
|
|||||||
"collection": collection_name,
|
"collection": collection_name,
|
||||||
"vector": item["vector"],
|
"vector": item["vector"],
|
||||||
"text": item["text"],
|
"text": item["text"],
|
||||||
"metadata": item["metadata"],
|
"metadata": process_metadata(item["metadata"]),
|
||||||
},
|
},
|
||||||
"doc_as_upsert": True,
|
"doc_as_upsert": True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
from pymilvus import MilvusClient as Client
|
from pymilvus import MilvusClient as Client
|
||||||
from pymilvus import FieldSchema, DataType
|
from pymilvus import FieldSchema, DataType
|
||||||
|
from pymilvus import connections, Collection
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
VectorDBBase,
|
VectorDBBase,
|
||||||
VectorItem,
|
VectorItem,
|
||||||
@@ -18,6 +22,8 @@ from open_webui.config import (
|
|||||||
MILVUS_HNSW_M,
|
MILVUS_HNSW_M,
|
||||||
MILVUS_HNSW_EFCONSTRUCTION,
|
MILVUS_HNSW_EFCONSTRUCTION,
|
||||||
MILVUS_IVF_FLAT_NLIST,
|
MILVUS_IVF_FLAT_NLIST,
|
||||||
|
MILVUS_DISKANN_MAX_DEGREE,
|
||||||
|
MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
@@ -127,12 +133,18 @@ class MilvusClient(VectorDBBase):
|
|||||||
elif index_type == "IVF_FLAT":
|
elif index_type == "IVF_FLAT":
|
||||||
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
||||||
log.info(f"IVF_FLAT params: {index_creation_params}")
|
log.info(f"IVF_FLAT params: {index_creation_params}")
|
||||||
|
elif index_type == "DISKANN":
|
||||||
|
index_creation_params = {
|
||||||
|
"max_degree": MILVUS_DISKANN_MAX_DEGREE,
|
||||||
|
"search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||||
|
}
|
||||||
|
log.info(f"DISKANN params: {index_creation_params}")
|
||||||
elif index_type in ["FLAT", "AUTOINDEX"]:
|
elif index_type in ["FLAT", "AUTOINDEX"]:
|
||||||
log.info(f"Using {index_type} index with no specific build-time params.")
|
log.info(f"Using {index_type} index with no specific build-time params.")
|
||||||
else:
|
else:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
|
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
|
||||||
f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. "
|
f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
|
||||||
f"Milvus will use its default for the collection if this type is not directly supported for index creation."
|
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.
|
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
|
||||||
@@ -185,86 +197,54 @@ class MilvusClient(VectorDBBase):
|
|||||||
)
|
)
|
||||||
return self._result_to_search_result(result)
|
return self._result_to_search_result(result)
|
||||||
|
|
||||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
def query(self, collection_name: str, filter: dict, limit: int = -1):
|
||||||
# Construct the filter string for querying
|
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||||
|
|
||||||
collection_name = collection_name.replace("-", "_")
|
collection_name = collection_name.replace("-", "_")
|
||||||
if not self.has_collection(collection_name):
|
if not self.has_collection(collection_name):
|
||||||
log.warning(
|
log.warning(
|
||||||
f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}"
|
f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
filter_string = " && ".join(
|
|
||||||
[
|
|
||||||
f'metadata["{key}"] == {json.dumps(value)}'
|
|
||||||
for key, value in filter.items()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
max_limit = 16383 # The maximum number of records per request
|
|
||||||
all_results = []
|
|
||||||
if limit is None:
|
|
||||||
# Milvus default limit for query if not specified is 16384, but docs mention iteration.
|
|
||||||
# Let's set a practical high number if "all" is intended, or handle true pagination.
|
|
||||||
# For now, if limit is None, we'll fetch in batches up to a very large number.
|
|
||||||
# This part could be refined based on expected use cases for "get all".
|
|
||||||
# For this function signature, None implies "as many as possible" up to Milvus limits.
|
|
||||||
limit = (
|
|
||||||
16384 * 10
|
|
||||||
) # A large number to signify fetching many, will be capped by actual data or max_limit per call.
|
|
||||||
log.info(
|
|
||||||
f"Limit not specified for query, fetching up to {limit} results in batches."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize offset and remaining to handle pagination
|
filter_expressions = []
|
||||||
offset = 0
|
for key, value in filter.items():
|
||||||
remaining = limit
|
if isinstance(value, str):
|
||||||
|
filter_expressions.append(f'metadata["{key}"] == "{value}"')
|
||||||
|
else:
|
||||||
|
filter_expressions.append(f'metadata["{key}"] == {value}')
|
||||||
|
|
||||||
|
filter_string = " && ".join(filter_expressions)
|
||||||
|
|
||||||
|
collection = Collection(f"{self.collection_prefix}_{collection_name}")
|
||||||
|
collection.load()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log.info(
|
log.info(
|
||||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||||
)
|
)
|
||||||
# Loop until there are no more items to fetch or the desired limit is reached
|
|
||||||
while remaining > 0:
|
|
||||||
current_fetch = min(
|
|
||||||
max_limit, remaining if isinstance(remaining, int) else max_limit
|
|
||||||
)
|
|
||||||
log.debug(
|
|
||||||
f"Querying with offset: {offset}, current_fetch: {current_fetch}"
|
|
||||||
)
|
|
||||||
|
|
||||||
results = self.client.query(
|
iterator = collection.query_iterator(
|
||||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
expr=filter_string,
|
||||||
filter=filter_string,
|
output_fields=[
|
||||||
output_fields=[
|
"id",
|
||||||
"id",
|
"data",
|
||||||
"data",
|
"metadata",
|
||||||
"metadata",
|
],
|
||||||
], # Explicitly list needed fields. Vector not usually needed in query.
|
limit=limit if limit > 0 else -1,
|
||||||
limit=current_fetch,
|
)
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not results:
|
all_results = []
|
||||||
log.debug("No more results from query.")
|
while True:
|
||||||
|
batch = iterator.next()
|
||||||
|
if not batch:
|
||||||
|
iterator.close()
|
||||||
break
|
break
|
||||||
|
all_results.extend(batch)
|
||||||
|
|
||||||
all_results.extend(results)
|
log.debug(f"Total results from query: {len(all_results)}")
|
||||||
results_count = len(results)
|
return self._result_to_get_result([all_results] if all_results else [[]])
|
||||||
log.debug(f"Fetched {results_count} results in this batch.")
|
|
||||||
|
|
||||||
if isinstance(remaining, int):
|
|
||||||
remaining -= results_count
|
|
||||||
|
|
||||||
offset += results_count
|
|
||||||
|
|
||||||
# Break the loop if the results returned are less than the requested fetch count (means end of data)
|
|
||||||
if results_count < current_fetch:
|
|
||||||
log.debug(
|
|
||||||
"Fetched less than requested, assuming end of results for this query."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
log.info(f"Total results from query: {len(all_results)}")
|
|
||||||
return self._result_to_get_result([all_results])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(
|
log.exception(
|
||||||
f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}"
|
f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}"
|
||||||
@@ -279,7 +259,7 @@ class MilvusClient(VectorDBBase):
|
|||||||
)
|
)
|
||||||
# Using query with a trivial filter to get all items.
|
# Using query with a trivial filter to get all items.
|
||||||
# This will use the paginated query logic.
|
# This will use the paginated query logic.
|
||||||
return self.query(collection_name=collection_name, filter={}, limit=None)
|
return self.query(collection_name=collection_name, filter={}, limit=-1)
|
||||||
|
|
||||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
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.
|
# Insert the items into the collection, if the collection does not exist, it will be created.
|
||||||
@@ -311,7 +291,7 @@ class MilvusClient(VectorDBBase):
|
|||||||
"id": item["id"],
|
"id": item["id"],
|
||||||
"vector": item["vector"],
|
"vector": item["vector"],
|
||||||
"data": {"text": item["text"]},
|
"data": {"text": item["text"]},
|
||||||
"metadata": item["metadata"],
|
"metadata": process_metadata(item["metadata"]),
|
||||||
}
|
}
|
||||||
for item in items
|
for item in items
|
||||||
],
|
],
|
||||||
@@ -347,7 +327,7 @@ class MilvusClient(VectorDBBase):
|
|||||||
"id": item["id"],
|
"id": item["id"],
|
||||||
"vector": item["vector"],
|
"vector": item["vector"],
|
||||||
"data": {"text": item["text"]},
|
"data": {"text": item["text"]},
|
||||||
"metadata": item["metadata"],
|
"metadata": process_metadata(item["metadata"]),
|
||||||
}
|
}
|
||||||
for item in items
|
for item in items
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple, List, Dict, Any
|
||||||
|
|
||||||
|
from open_webui.config import (
|
||||||
|
MILVUS_URI,
|
||||||
|
MILVUS_TOKEN,
|
||||||
|
MILVUS_DB,
|
||||||
|
MILVUS_COLLECTION_PREFIX,
|
||||||
|
MILVUS_INDEX_TYPE,
|
||||||
|
MILVUS_METRIC_TYPE,
|
||||||
|
MILVUS_HNSW_M,
|
||||||
|
MILVUS_HNSW_EFCONSTRUCTION,
|
||||||
|
MILVUS_IVF_FLAT_NLIST,
|
||||||
|
)
|
||||||
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
from open_webui.retrieval.vector.main import (
|
||||||
|
GetResult,
|
||||||
|
SearchResult,
|
||||||
|
VectorDBBase,
|
||||||
|
VectorItem,
|
||||||
|
)
|
||||||
|
from pymilvus import (
|
||||||
|
connections,
|
||||||
|
utility,
|
||||||
|
Collection,
|
||||||
|
CollectionSchema,
|
||||||
|
FieldSchema,
|
||||||
|
DataType,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
RESOURCE_ID_FIELD = "resource_id"
|
||||||
|
|
||||||
|
|
||||||
|
class MilvusClient(VectorDBBase):
|
||||||
|
def __init__(self):
|
||||||
|
# Milvus collection names can only contain numbers, letters, and underscores.
|
||||||
|
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
|
||||||
|
connections.connect(
|
||||||
|
alias="default",
|
||||||
|
uri=MILVUS_URI,
|
||||||
|
token=MILVUS_TOKEN,
|
||||||
|
db_name=MILVUS_DB,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main collection types for multi-tenancy
|
||||||
|
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
||||||
|
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
|
||||||
|
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
|
||||||
|
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
|
||||||
|
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
|
||||||
|
self.shared_collections = [
|
||||||
|
self.MEMORY_COLLECTION,
|
||||||
|
self.KNOWLEDGE_COLLECTION,
|
||||||
|
self.FILE_COLLECTION,
|
||||||
|
self.WEB_SEARCH_COLLECTION,
|
||||||
|
self.HASH_BASED_COLLECTION,
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Maps the traditional collection name to multi-tenant collection and resource ID.
|
||||||
|
|
||||||
|
WARNING: This mapping relies on current Open WebUI naming conventions for
|
||||||
|
collection names. If Open WebUI changes how it generates collection names
|
||||||
|
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
|
||||||
|
formats), this mapping will break and route data to incorrect collections.
|
||||||
|
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
|
||||||
|
DATA MAPPING INSIDE THE DATABASE.
|
||||||
|
"""
|
||||||
|
resource_id = collection_name
|
||||||
|
|
||||||
|
if collection_name.startswith("user-memory-"):
|
||||||
|
return self.MEMORY_COLLECTION, resource_id
|
||||||
|
elif collection_name.startswith("file-"):
|
||||||
|
return self.FILE_COLLECTION, resource_id
|
||||||
|
elif collection_name.startswith("web-search-"):
|
||||||
|
return self.WEB_SEARCH_COLLECTION, resource_id
|
||||||
|
elif len(collection_name) == 63 and all(
|
||||||
|
c in "0123456789abcdef" for c in collection_name
|
||||||
|
):
|
||||||
|
return self.HASH_BASED_COLLECTION, resource_id
|
||||||
|
else:
|
||||||
|
return self.KNOWLEDGE_COLLECTION, resource_id
|
||||||
|
|
||||||
|
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
|
||||||
|
fields = [
|
||||||
|
FieldSchema(
|
||||||
|
name="id",
|
||||||
|
dtype=DataType.VARCHAR,
|
||||||
|
is_primary=True,
|
||||||
|
auto_id=False,
|
||||||
|
max_length=36,
|
||||||
|
),
|
||||||
|
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||||
|
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
||||||
|
FieldSchema(name="metadata", dtype=DataType.JSON),
|
||||||
|
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
|
||||||
|
]
|
||||||
|
schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
|
||||||
|
collection = Collection(mt_collection_name, schema)
|
||||||
|
|
||||||
|
index_params = {
|
||||||
|
"metric_type": MILVUS_METRIC_TYPE,
|
||||||
|
"index_type": MILVUS_INDEX_TYPE,
|
||||||
|
"params": {},
|
||||||
|
}
|
||||||
|
if MILVUS_INDEX_TYPE == "HNSW":
|
||||||
|
index_params["params"] = {
|
||||||
|
"M": MILVUS_HNSW_M,
|
||||||
|
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
|
||||||
|
}
|
||||||
|
elif MILVUS_INDEX_TYPE == "IVF_FLAT":
|
||||||
|
index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
||||||
|
|
||||||
|
collection.create_index("vector", index_params)
|
||||||
|
collection.create_index(RESOURCE_ID_FIELD)
|
||||||
|
log.info(f"Created shared collection: {mt_collection_name}")
|
||||||
|
return collection
|
||||||
|
|
||||||
|
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||||
|
if not utility.has_collection(mt_collection_name):
|
||||||
|
self._create_shared_collection(mt_collection_name, dimension)
|
||||||
|
|
||||||
|
def has_collection(self, collection_name: str) -> bool:
|
||||||
|
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||||
|
collection_name
|
||||||
|
)
|
||||||
|
if not utility.has_collection(mt_collection):
|
||||||
|
return False
|
||||||
|
|
||||||
|
collection = Collection(mt_collection)
|
||||||
|
collection.load()
|
||||||
|
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
|
||||||
|
return len(res) > 0
|
||||||
|
|
||||||
|
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||||
|
collection_name
|
||||||
|
)
|
||||||
|
dimension = len(items[0]["vector"])
|
||||||
|
self._ensure_collection(mt_collection, dimension)
|
||||||
|
collection = Collection(mt_collection)
|
||||||
|
|
||||||
|
entities = [
|
||||||
|
{
|
||||||
|
"id": item["id"],
|
||||||
|
"vector": item["vector"],
|
||||||
|
"text": item["text"],
|
||||||
|
"metadata": item["metadata"],
|
||||||
|
RESOURCE_ID_FIELD: resource_id,
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
collection.insert(entities)
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, collection_name: str, vectors: List[List[float]], limit: int
|
||||||
|
) -> Optional[SearchResult]:
|
||||||
|
if not vectors:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||||
|
collection_name
|
||||||
|
)
|
||||||
|
if not utility.has_collection(mt_collection):
|
||||||
|
return None
|
||||||
|
|
||||||
|
collection = Collection(mt_collection)
|
||||||
|
collection.load()
|
||||||
|
|
||||||
|
search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
|
||||||
|
results = collection.search(
|
||||||
|
data=vectors,
|
||||||
|
anns_field="vector",
|
||||||
|
param=search_params,
|
||||||
|
limit=limit,
|
||||||
|
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||||
|
output_fields=["id", "text", "metadata"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ids, documents, metadatas, distances = [], [], [], []
|
||||||
|
for hits in results:
|
||||||
|
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
|
||||||
|
for hit in hits:
|
||||||
|
batch_ids.append(hit.entity.get("id"))
|
||||||
|
batch_docs.append(hit.entity.get("text"))
|
||||||
|
batch_metadatas.append(hit.entity.get("metadata"))
|
||||||
|
batch_dists.append(hit.distance)
|
||||||
|
ids.append(batch_ids)
|
||||||
|
documents.append(batch_docs)
|
||||||
|
metadatas.append(batch_metadatas)
|
||||||
|
distances.append(batch_dists)
|
||||||
|
|
||||||
|
return SearchResult(
|
||||||
|
ids=ids, documents=documents, metadatas=metadatas, distances=distances
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
ids: Optional[List[str]] = None,
|
||||||
|
filter: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||||
|
collection_name
|
||||||
|
)
|
||||||
|
if not utility.has_collection(mt_collection):
|
||||||
|
return
|
||||||
|
|
||||||
|
collection = Collection(mt_collection)
|
||||||
|
|
||||||
|
# Build expression
|
||||||
|
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||||
|
if ids:
|
||||||
|
# Milvus expects a string list for 'in' operator
|
||||||
|
id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
|
||||||
|
expr.append(f"id in [{id_list_str}]")
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
for key, value in filter.items():
|
||||||
|
expr.append(f"metadata['{key}'] == '{value}'")
|
||||||
|
|
||||||
|
collection.delete(" and ".join(expr))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
for collection_name in self.shared_collections:
|
||||||
|
if utility.has_collection(collection_name):
|
||||||
|
utility.drop_collection(collection_name)
|
||||||
|
|
||||||
|
def delete_collection(self, collection_name: str):
|
||||||
|
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||||
|
collection_name
|
||||||
|
)
|
||||||
|
if not utility.has_collection(mt_collection):
|
||||||
|
return
|
||||||
|
|
||||||
|
collection = Collection(mt_collection)
|
||||||
|
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
|
||||||
|
|
||||||
|
def query(
|
||||||
|
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||||
|
) -> Optional[GetResult]:
|
||||||
|
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||||
|
collection_name
|
||||||
|
)
|
||||||
|
if not utility.has_collection(mt_collection):
|
||||||
|
return None
|
||||||
|
|
||||||
|
collection = Collection(mt_collection)
|
||||||
|
collection.load()
|
||||||
|
|
||||||
|
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||||
|
if filter:
|
||||||
|
for key, value in filter.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
expr.append(f"metadata['{key}'] == '{value}'")
|
||||||
|
else:
|
||||||
|
expr.append(f"metadata['{key}'] == {value}")
|
||||||
|
|
||||||
|
iterator = collection.query_iterator(
|
||||||
|
expr=" and ".join(expr),
|
||||||
|
output_fields=["id", "text", "metadata"],
|
||||||
|
limit=limit if limit else -1,
|
||||||
|
)
|
||||||
|
|
||||||
|
all_results = []
|
||||||
|
while True:
|
||||||
|
batch = iterator.next()
|
||||||
|
if not batch:
|
||||||
|
iterator.close()
|
||||||
|
break
|
||||||
|
all_results.extend(batch)
|
||||||
|
|
||||||
|
ids = [res["id"] for res in all_results]
|
||||||
|
documents = [res["text"] for res in all_results]
|
||||||
|
metadatas = [res["metadata"] for res in all_results]
|
||||||
|
|
||||||
|
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||||
|
|
||||||
|
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||||
|
return self.query(collection_name, filter={}, limit=None)
|
||||||
|
|
||||||
|
def insert(self, collection_name: str, items: List[VectorItem]):
|
||||||
|
return self.upsert(collection_name, items)
|
||||||
@@ -2,6 +2,7 @@ from opensearchpy import OpenSearch
|
|||||||
from opensearchpy.helpers import bulk
|
from opensearchpy.helpers import bulk
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
VectorDBBase,
|
VectorDBBase,
|
||||||
VectorItem,
|
VectorItem,
|
||||||
@@ -157,10 +158,10 @@ class OpenSearchClient(VectorDBBase):
|
|||||||
|
|
||||||
for field, value in filter.items():
|
for field, value in filter.items():
|
||||||
query_body["query"]["bool"]["filter"].append(
|
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:
|
try:
|
||||||
result = self.client.search(
|
result = self.client.search(
|
||||||
@@ -200,12 +201,13 @@ class OpenSearchClient(VectorDBBase):
|
|||||||
"_source": {
|
"_source": {
|
||||||
"vector": item["vector"],
|
"vector": item["vector"],
|
||||||
"text": item["text"],
|
"text": item["text"],
|
||||||
"metadata": item["metadata"],
|
"metadata": process_metadata(item["metadata"]),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for item in batch
|
for item in batch
|
||||||
]
|
]
|
||||||
bulk(self.client, actions)
|
bulk(self.client, actions)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||||
self._create_index_if_not_exists(
|
self._create_index_if_not_exists(
|
||||||
@@ -221,13 +223,14 @@ class OpenSearchClient(VectorDBBase):
|
|||||||
"doc": {
|
"doc": {
|
||||||
"vector": item["vector"],
|
"vector": item["vector"],
|
||||||
"text": item["text"],
|
"text": item["text"],
|
||||||
"metadata": item["metadata"],
|
"metadata": process_metadata(item["metadata"]),
|
||||||
},
|
},
|
||||||
"doc_as_upsert": True,
|
"doc_as_upsert": True,
|
||||||
}
|
}
|
||||||
for item in batch
|
for item in batch
|
||||||
]
|
]
|
||||||
bulk(self.client, actions)
|
bulk(self.client, actions)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
@@ -251,11 +254,12 @@ class OpenSearchClient(VectorDBBase):
|
|||||||
}
|
}
|
||||||
for field, value in filter.items():
|
for field, value in filter.items():
|
||||||
query_body["query"]["bool"]["filter"].append(
|
query_body["query"]["bool"]["filter"].append(
|
||||||
{"match": {"metadata." + str(field): value}}
|
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||||
)
|
)
|
||||||
self.client.delete_by_query(
|
self.client.delete_by_query(
|
||||||
index=self._get_index_name(collection_name), body=query_body
|
index=self._get_index_name(collection_name), body=query_body
|
||||||
)
|
)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
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 = 1000 # Hardcoded limit for get operation
|
||||||
|
|
||||||
|
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
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
func,
|
||||||
|
literal,
|
||||||
cast,
|
cast,
|
||||||
column,
|
column,
|
||||||
create_engine,
|
create_engine,
|
||||||
Column,
|
Column,
|
||||||
Integer,
|
Integer,
|
||||||
MetaData,
|
MetaData,
|
||||||
|
LargeBinary,
|
||||||
select,
|
select,
|
||||||
text,
|
text,
|
||||||
Text,
|
Text,
|
||||||
@@ -14,39 +18,73 @@ from sqlalchemy import (
|
|||||||
values,
|
values,
|
||||||
)
|
)
|
||||||
from sqlalchemy.sql import true
|
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.orm import declarative_base, scoped_session, sessionmaker
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, array
|
from sqlalchemy.dialects.postgresql import JSONB, array
|
||||||
from pgvector.sqlalchemy import Vector
|
from pgvector.sqlalchemy import Vector, HALFVEC
|
||||||
from sqlalchemy.ext.mutable import MutableDict
|
from sqlalchemy.ext.mutable import MutableDict
|
||||||
from sqlalchemy.exc import NoSuchTableError
|
from sqlalchemy.exc import NoSuchTableError
|
||||||
|
|
||||||
|
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
VectorDBBase,
|
VectorDBBase,
|
||||||
VectorItem,
|
VectorItem,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
GetResult,
|
GetResult,
|
||||||
)
|
)
|
||||||
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
from open_webui.config import (
|
||||||
|
PGVECTOR_DB_URL,
|
||||||
|
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
||||||
|
PGVECTOR_CREATE_EXTENSION,
|
||||||
|
PGVECTOR_PGCRYPTO,
|
||||||
|
PGVECTOR_PGCRYPTO_KEY,
|
||||||
|
PGVECTOR_POOL_SIZE,
|
||||||
|
PGVECTOR_POOL_MAX_OVERFLOW,
|
||||||
|
PGVECTOR_POOL_TIMEOUT,
|
||||||
|
PGVECTOR_POOL_RECYCLE,
|
||||||
|
PGVECTOR_INDEX_METHOD,
|
||||||
|
PGVECTOR_HNSW_M,
|
||||||
|
PGVECTOR_HNSW_EF_CONSTRUCTION,
|
||||||
|
PGVECTOR_IVFFLAT_LISTS,
|
||||||
|
PGVECTOR_USE_HALFVEC,
|
||||||
|
)
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
||||||
|
USE_HALFVEC = PGVECTOR_USE_HALFVEC
|
||||||
|
|
||||||
|
VECTOR_TYPE_FACTORY = HALFVEC if USE_HALFVEC else Vector
|
||||||
|
VECTOR_OPCLASS = "halfvec_cosine_ops" if USE_HALFVEC else "vector_cosine_ops"
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
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):
|
class DocumentChunk(Base):
|
||||||
__tablename__ = "document_chunk"
|
__tablename__ = "document_chunk"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True)
|
vector = Column(VECTOR_TYPE_FACTORY(dim=VECTOR_LENGTH), nullable=True)
|
||||||
collection_name = Column(Text, nullable=False)
|
collection_name = Column(Text, nullable=False)
|
||||||
text = Column(Text, nullable=True)
|
|
||||||
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
|
if PGVECTOR_PGCRYPTO:
|
||||||
|
text = Column(LargeBinary, nullable=True)
|
||||||
|
vmetadata = Column(LargeBinary, nullable=True)
|
||||||
|
else:
|
||||||
|
text = Column(Text, nullable=True)
|
||||||
|
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class PgvectorClient(VectorDBBase):
|
class PgvectorClient(VectorDBBase):
|
||||||
@@ -58,9 +96,24 @@ class PgvectorClient(VectorDBBase):
|
|||||||
|
|
||||||
self.session = Session
|
self.session = Session
|
||||||
else:
|
else:
|
||||||
engine = create_engine(
|
if isinstance(PGVECTOR_POOL_SIZE, int):
|
||||||
PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
|
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(
|
SessionLocal = sessionmaker(
|
||||||
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
||||||
)
|
)
|
||||||
@@ -68,7 +121,41 @@ class PgvectorClient(VectorDBBase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Ensure the pgvector extension is available
|
# Ensure the pgvector extension is available
|
||||||
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
|
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||||
|
if PGVECTOR_CREATE_EXTENSION:
|
||||||
|
self.session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if PGVECTOR_PGCRYPTO:
|
||||||
|
# Ensure the pgcrypto extension is available for encryption
|
||||||
|
# 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
|
# Check vector length consistency
|
||||||
self.check_vector_length()
|
self.check_vector_length()
|
||||||
@@ -79,13 +166,9 @@ class PgvectorClient(VectorDBBase):
|
|||||||
connection = self.session.connection()
|
connection = self.session.connection()
|
||||||
Base.metadata.create_all(bind=connection)
|
Base.metadata.create_all(bind=connection)
|
||||||
|
|
||||||
# Create an index on the vector column if it doesn't exist
|
index_method, index_options = self._vector_index_configuration()
|
||||||
self.session.execute(
|
self._ensure_vector_index(index_method, index_options)
|
||||||
text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_document_chunk_vector "
|
|
||||||
"ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.session.execute(
|
self.session.execute(
|
||||||
text(
|
text(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name "
|
"CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name "
|
||||||
@@ -99,6 +182,78 @@ class PgvectorClient(VectorDBBase):
|
|||||||
log.exception(f"Error during initialization: {e}")
|
log.exception(f"Error during initialization: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_index_method(index_def: Optional[str]) -> Optional[str]:
|
||||||
|
if not index_def:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
after_using = index_def.lower().split("using ", 1)[1]
|
||||||
|
return after_using.split()[0]
|
||||||
|
except (IndexError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _vector_index_configuration(self) -> Tuple[str, str]:
|
||||||
|
if PGVECTOR_INDEX_METHOD:
|
||||||
|
index_method = PGVECTOR_INDEX_METHOD
|
||||||
|
log.info(
|
||||||
|
"Using vector index method '%s' from PGVECTOR_INDEX_METHOD.",
|
||||||
|
index_method,
|
||||||
|
)
|
||||||
|
elif USE_HALFVEC:
|
||||||
|
index_method = "hnsw"
|
||||||
|
log.info(
|
||||||
|
"VECTOR_LENGTH=%s exceeds 2000; using halfvec column type with hnsw index.",
|
||||||
|
VECTOR_LENGTH,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
index_method = "ivfflat"
|
||||||
|
|
||||||
|
if index_method == "hnsw":
|
||||||
|
index_options = f"WITH (m = {PGVECTOR_HNSW_M}, ef_construction = {PGVECTOR_HNSW_EF_CONSTRUCTION})"
|
||||||
|
else:
|
||||||
|
index_options = f"WITH (lists = {PGVECTOR_IVFFLAT_LISTS})"
|
||||||
|
|
||||||
|
return index_method, index_options
|
||||||
|
|
||||||
|
def _ensure_vector_index(self, index_method: str, index_options: str) -> None:
|
||||||
|
index_name = "idx_document_chunk_vector"
|
||||||
|
existing_index_def = self.session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = current_schema()
|
||||||
|
AND tablename = 'document_chunk'
|
||||||
|
AND indexname = :index_name
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"index_name": index_name},
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
existing_method = self._extract_index_method(existing_index_def)
|
||||||
|
if existing_method and existing_method != index_method:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Existing pgvector index '{index_name}' uses method '{existing_method}' but configuration now "
|
||||||
|
f"requires '{index_method}'. Automatic rebuild is disabled to prevent long-running maintenance. "
|
||||||
|
"Drop the index manually (optionally after tuning maintenance_work_mem/max_parallel_maintenance_workers) "
|
||||||
|
"and recreate it with the new method before restarting Open WebUI."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing_index_def:
|
||||||
|
index_sql = (
|
||||||
|
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||||
|
f"ON document_chunk USING {index_method} (vector {VECTOR_OPCLASS})"
|
||||||
|
)
|
||||||
|
if index_options:
|
||||||
|
index_sql = f"{index_sql} {index_options}"
|
||||||
|
self.session.execute(text(index_sql))
|
||||||
|
log.info(
|
||||||
|
"Ensured vector index '%s' using %s%s.",
|
||||||
|
index_name,
|
||||||
|
index_method,
|
||||||
|
f" {index_options}" if index_options else "",
|
||||||
|
)
|
||||||
|
|
||||||
def check_vector_length(self) -> None:
|
def check_vector_length(self) -> None:
|
||||||
"""
|
"""
|
||||||
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
|
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
|
||||||
@@ -118,16 +273,19 @@ class PgvectorClient(VectorDBBase):
|
|||||||
if "vector" in document_chunk_table.columns:
|
if "vector" in document_chunk_table.columns:
|
||||||
vector_column = document_chunk_table.columns["vector"]
|
vector_column = document_chunk_table.columns["vector"]
|
||||||
vector_type = vector_column.type
|
vector_type = vector_column.type
|
||||||
if isinstance(vector_type, Vector):
|
expected_type = HALFVEC if USE_HALFVEC else Vector
|
||||||
db_vector_length = vector_type.dim
|
|
||||||
if db_vector_length != VECTOR_LENGTH:
|
if not isinstance(vector_type, expected_type):
|
||||||
raise Exception(
|
|
||||||
f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
|
|
||||||
"Cannot change vector size after initialization without migrating the data."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise Exception(
|
raise Exception(
|
||||||
"The 'vector' column exists but is not of type 'Vector'."
|
"The 'vector' column type does not match the expected type "
|
||||||
|
f"('{expected_type.__name__}') for VECTOR_LENGTH {VECTOR_LENGTH}."
|
||||||
|
)
|
||||||
|
|
||||||
|
db_vector_length = getattr(vector_type, "dim", None)
|
||||||
|
if db_vector_length is not None and db_vector_length != VECTOR_LENGTH:
|
||||||
|
raise Exception(
|
||||||
|
f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
|
||||||
|
"Cannot change vector size after initialization without migrating the data."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
@@ -147,22 +305,54 @@ class PgvectorClient(VectorDBBase):
|
|||||||
|
|
||||||
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
try:
|
try:
|
||||||
new_items = []
|
if PGVECTOR_PGCRYPTO:
|
||||||
for item in items:
|
for item in items:
|
||||||
vector = self.adjust_vector_length(item["vector"])
|
vector = self.adjust_vector_length(item["vector"])
|
||||||
new_chunk = DocumentChunk(
|
# Use raw SQL for BYTEA/pgcrypto
|
||||||
id=item["id"],
|
# Ensure metadata is converted to its JSON text representation
|
||||||
vector=vector,
|
json_metadata = json.dumps(item["metadata"])
|
||||||
collection_name=collection_name,
|
self.session.execute(
|
||||||
text=item["text"],
|
text(
|
||||||
vmetadata=item["metadata"],
|
"""
|
||||||
|
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=process_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:
|
except Exception as e:
|
||||||
self.session.rollback()
|
self.session.rollback()
|
||||||
log.exception(f"Error during insert: {e}")
|
log.exception(f"Error during insert: {e}")
|
||||||
@@ -170,33 +360,66 @@ class PgvectorClient(VectorDBBase):
|
|||||||
|
|
||||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
try:
|
try:
|
||||||
for item in items:
|
if PGVECTOR_PGCRYPTO:
|
||||||
vector = self.adjust_vector_length(item["vector"])
|
for item in items:
|
||||||
existing = (
|
vector = self.adjust_vector_length(item["vector"])
|
||||||
self.session.query(DocumentChunk)
|
json_metadata = json.dumps(item["metadata"])
|
||||||
.filter(DocumentChunk.id == item["id"])
|
self.session.execute(
|
||||||
.first()
|
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 = process_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=process_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:
|
except Exception as e:
|
||||||
self.session.rollback()
|
self.session.rollback()
|
||||||
log.exception(f"Error during upsert: {e}")
|
log.exception(f"Error during upsert: {e}")
|
||||||
@@ -217,11 +440,11 @@ class PgvectorClient(VectorDBBase):
|
|||||||
num_queries = len(vectors)
|
num_queries = len(vectors)
|
||||||
|
|
||||||
def vector_expr(vector):
|
def vector_expr(vector):
|
||||||
return cast(array(vector), Vector(VECTOR_LENGTH))
|
return cast(array(vector), VECTOR_TYPE_FACTORY(VECTOR_LENGTH))
|
||||||
|
|
||||||
# Create the values for query vectors
|
# Create the values for query vectors
|
||||||
qid_col = column("qid", Integer)
|
qid_col = column("qid", Integer)
|
||||||
q_vector_col = column("q_vector", Vector(VECTOR_LENGTH))
|
q_vector_col = column("q_vector", VECTOR_TYPE_FACTORY(VECTOR_LENGTH))
|
||||||
query_vectors = (
|
query_vectors = (
|
||||||
values(qid_col, q_vector_col)
|
values(qid_col, q_vector_col)
|
||||||
.data(
|
.data(
|
||||||
@@ -230,16 +453,32 @@ class PgvectorClient(VectorDBBase):
|
|||||||
.alias("query_vectors")
|
.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
|
# Build the lateral subquery for each query vector
|
||||||
subq = (
|
subq = (
|
||||||
select(
|
select(*result_fields)
|
||||||
DocumentChunk.id,
|
|
||||||
DocumentChunk.text,
|
|
||||||
DocumentChunk.vmetadata,
|
|
||||||
(
|
|
||||||
DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)
|
|
||||||
).label("distance"),
|
|
||||||
)
|
|
||||||
.where(DocumentChunk.collection_name == collection_name)
|
.where(DocumentChunk.collection_name == collection_name)
|
||||||
.order_by(
|
.order_by(
|
||||||
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
|
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
|
||||||
@@ -288,10 +527,12 @@ class PgvectorClient(VectorDBBase):
|
|||||||
documents[qid].append(row.text)
|
documents[qid].append(row.text)
|
||||||
metadatas[qid].append(row.vmetadata)
|
metadatas[qid].append(row.vmetadata)
|
||||||
|
|
||||||
|
self.session.rollback() # read-only transaction
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
ids=ids, distances=distances, documents=documents, metadatas=metadatas
|
ids=ids, distances=distances, documents=documents, metadatas=metadatas
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.session.rollback()
|
||||||
log.exception(f"Error during search: {e}")
|
log.exception(f"Error during search: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -299,17 +540,43 @@ class PgvectorClient(VectorDBBase):
|
|||||||
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||||
) -> Optional[GetResult]:
|
) -> Optional[GetResult]:
|
||||||
try:
|
try:
|
||||||
query = self.session.query(DocumentChunk).filter(
|
if PGVECTOR_PGCRYPTO:
|
||||||
DocumentChunk.collection_name == collection_name
|
# 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():
|
for key, value in filter.items():
|
||||||
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
|
query = query.filter(
|
||||||
|
DocumentChunk.vmetadata[key].astext == str(value)
|
||||||
|
)
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
query = query.limit(limit)
|
query = query.limit(limit)
|
||||||
|
|
||||||
results = query.all()
|
results = query.all()
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return None
|
return None
|
||||||
@@ -318,12 +585,14 @@ class PgvectorClient(VectorDBBase):
|
|||||||
documents = [[result.text for result in results]]
|
documents = [[result.text for result in results]]
|
||||||
metadatas = [[result.vmetadata for result in results]]
|
metadatas = [[result.vmetadata for result in results]]
|
||||||
|
|
||||||
|
self.session.rollback() # read-only transaction
|
||||||
return GetResult(
|
return GetResult(
|
||||||
ids=ids,
|
ids=ids,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
metadatas=metadatas,
|
metadatas=metadatas,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.session.rollback()
|
||||||
log.exception(f"Error during query: {e}")
|
log.exception(f"Error during query: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -331,23 +600,43 @@ class PgvectorClient(VectorDBBase):
|
|||||||
self, collection_name: str, limit: Optional[int] = None
|
self, collection_name: str, limit: Optional[int] = None
|
||||||
) -> Optional[GetResult]:
|
) -> Optional[GetResult]:
|
||||||
try:
|
try:
|
||||||
query = self.session.query(DocumentChunk).filter(
|
if PGVECTOR_PGCRYPTO:
|
||||||
DocumentChunk.collection_name == collection_name
|
stmt = select(
|
||||||
)
|
DocumentChunk.id,
|
||||||
if limit is not None:
|
pgcrypto_decrypt(
|
||||||
query = query.limit(limit)
|
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:
|
results = query.all()
|
||||||
return None
|
|
||||||
|
|
||||||
ids = [[result.id for result in results]]
|
if not results:
|
||||||
documents = [[result.text for result in results]]
|
return None
|
||||||
metadatas = [[result.vmetadata for result in results]]
|
|
||||||
|
|
||||||
|
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)
|
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.session.rollback()
|
||||||
log.exception(f"Error during get: {e}")
|
log.exception(f"Error during get: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -358,17 +647,33 @@ class PgvectorClient(VectorDBBase):
|
|||||||
filter: Optional[Dict[str, Any]] = None,
|
filter: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
query = self.session.query(DocumentChunk).filter(
|
if PGVECTOR_PGCRYPTO:
|
||||||
DocumentChunk.collection_name == collection_name
|
wheres = [DocumentChunk.collection_name == collection_name]
|
||||||
)
|
if ids:
|
||||||
if ids:
|
wheres.append(DocumentChunk.id.in_(ids))
|
||||||
query = query.filter(DocumentChunk.id.in_(ids))
|
if filter:
|
||||||
if filter:
|
for key, value in filter.items():
|
||||||
for key, value in filter.items():
|
wheres.append(
|
||||||
query = query.filter(
|
pgcrypto_decrypt(
|
||||||
DocumentChunk.vmetadata[key].astext == str(value)
|
DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB
|
||||||
)
|
)[key].astext
|
||||||
deleted = query.delete(synchronize_session=False)
|
== 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()
|
self.session.commit()
|
||||||
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
|
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -399,8 +704,10 @@ class PgvectorClient(VectorDBBase):
|
|||||||
.first()
|
.first()
|
||||||
is not None
|
is not None
|
||||||
)
|
)
|
||||||
|
self.session.rollback() # read-only transaction
|
||||||
return exists
|
return exists
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.session.rollback()
|
||||||
log.exception(f"Error checking collection existence: {e}")
|
log.exception(f"Error checking collection existence: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
from typing import Optional, List, Dict, Any, Union
|
from typing import Optional, List, Dict, Any, Union
|
||||||
import logging
|
import logging
|
||||||
import time # for measuring elapsed time
|
import time # for measuring elapsed time
|
||||||
from pinecone import ServerlessSpec
|
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 asyncio # for async upserts
|
||||||
import functools # for partial binding in async tasks
|
import functools # for partial binding in async tasks
|
||||||
|
|
||||||
import concurrent.futures # for parallel batch upserts
|
import concurrent.futures # for parallel batch upserts
|
||||||
from pinecone.grpc import PineconeGRPC # use gRPC client for faster upserts
|
import random # for jitter in retry backoff
|
||||||
|
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
VectorDBBase,
|
VectorDBBase,
|
||||||
@@ -24,6 +32,8 @@ from open_webui.config import (
|
|||||||
PINECONE_CLOUD,
|
PINECONE_CLOUD,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
|
|
||||||
|
|
||||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||||
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
|
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
|
||||||
@@ -47,10 +57,25 @@ class PineconeClient(VectorDBBase):
|
|||||||
self.metric = PINECONE_METRIC
|
self.metric = PINECONE_METRIC
|
||||||
self.cloud = PINECONE_CLOUD
|
self.cloud = PINECONE_CLOUD
|
||||||
|
|
||||||
# Initialize Pinecone gRPC client for improved performance
|
# Initialize Pinecone client for improved performance
|
||||||
self.client = PineconeGRPC(
|
if GRPC_AVAILABLE:
|
||||||
api_key=self.api_key, environment=self.environment, cloud=self.cloud
|
# 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
|
# Persistent executor for batch operations
|
||||||
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5)
|
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5)
|
||||||
@@ -94,12 +119,53 @@ class PineconeClient(VectorDBBase):
|
|||||||
log.info(f"Using existing Pinecone index '{self.index_name}'")
|
log.info(f"Using existing Pinecone index '{self.index_name}'")
|
||||||
|
|
||||||
# Connect to the index
|
# Connect to the index
|
||||||
self.index = self.client.Index(self.index_name)
|
self.index = self.client.Index(
|
||||||
|
self.index_name,
|
||||||
|
pool_threads=20, # Enhanced connection pool for index operations
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to initialize Pinecone index: {e}")
|
log.error(f"Failed to initialize Pinecone index: {e}")
|
||||||
raise RuntimeError(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(
|
def _create_points(
|
||||||
self, items: List[VectorItem], collection_name_with_prefix: str
|
self, items: List[VectorItem], collection_name_with_prefix: str
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
@@ -119,7 +185,7 @@ class PineconeClient(VectorDBBase):
|
|||||||
point = {
|
point = {
|
||||||
"id": item["id"],
|
"id": item["id"],
|
||||||
"values": item["vector"],
|
"values": item["vector"],
|
||||||
"metadata": metadata,
|
"metadata": process_metadata(metadata),
|
||||||
}
|
}
|
||||||
points.append(point)
|
points.append(point)
|
||||||
return points
|
return points
|
||||||
@@ -147,8 +213,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
metadatas = []
|
metadatas = []
|
||||||
|
|
||||||
for match in matches:
|
for match in matches:
|
||||||
metadata = match.get("metadata", {})
|
metadata = getattr(match, "metadata", {}) or {}
|
||||||
ids.append(match["id"])
|
ids.append(match.id if hasattr(match, "id") else match["id"])
|
||||||
documents.append(metadata.get("text", ""))
|
documents.append(metadata.get("text", ""))
|
||||||
metadatas.append(metadata)
|
metadatas.append(metadata)
|
||||||
|
|
||||||
@@ -174,7 +240,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
filter={"collection_name": collection_name_with_prefix},
|
filter={"collection_name": collection_name_with_prefix},
|
||||||
include_metadata=False,
|
include_metadata=False,
|
||||||
)
|
)
|
||||||
return len(response.matches) > 0
|
matches = getattr(response, "matches", []) or []
|
||||||
|
return len(matches) > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(
|
log.exception(
|
||||||
f"Error checking collection '{collection_name_with_prefix}': {e}"
|
f"Error checking collection '{collection_name_with_prefix}': {e}"
|
||||||
@@ -225,7 +292,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
log.debug(f"Insert of {len(points)} vectors took {elapsed:.2f} seconds")
|
log.debug(f"Insert of {len(points)} vectors took {elapsed:.2f} seconds")
|
||||||
log.info(
|
log.info(
|
||||||
f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
|
f"Successfully inserted {len(points)} vectors in parallel batches "
|
||||||
|
f"into '{collection_name_with_prefix}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
@@ -256,7 +324,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
log.debug(f"Upsert of {len(points)} vectors took {elapsed:.2f} seconds")
|
log.debug(f"Upsert of {len(points)} vectors took {elapsed:.2f} seconds")
|
||||||
log.info(
|
log.info(
|
||||||
f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
|
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 def insert_async(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
@@ -287,7 +356,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
log.error(f"Error in async insert batch: {result}")
|
log.error(f"Error in async insert batch: {result}")
|
||||||
raise result
|
raise result
|
||||||
log.info(
|
log.info(
|
||||||
f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'"
|
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 def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
@@ -318,35 +388,10 @@ class PineconeClient(VectorDBBase):
|
|||||||
log.error(f"Error in async upsert batch: {result}")
|
log.error(f"Error in async upsert batch: {result}")
|
||||||
raise result
|
raise result
|
||||||
log.info(
|
log.info(
|
||||||
f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'"
|
f"Successfully async upserted {len(points)} vectors in batches "
|
||||||
|
f"into '{collection_name_with_prefix}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def streaming_upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
|
||||||
"""Perform a streaming upsert over gRPC for performance testing."""
|
|
||||||
if not items:
|
|
||||||
log.warning("No items to upsert via streaming")
|
|
||||||
return
|
|
||||||
|
|
||||||
collection_name_with_prefix = self._get_collection_name_with_prefix(
|
|
||||||
collection_name
|
|
||||||
)
|
|
||||||
points = self._create_points(items, collection_name_with_prefix)
|
|
||||||
|
|
||||||
# Open a streaming upsert channel
|
|
||||||
stream = self.index.streaming_upsert()
|
|
||||||
try:
|
|
||||||
for point in points:
|
|
||||||
# send each point over the stream
|
|
||||||
stream.send(point)
|
|
||||||
# close the stream to finalize
|
|
||||||
stream.close()
|
|
||||||
log.info(
|
|
||||||
f"Successfully streamed upsert of {len(points)} vectors into '{collection_name_with_prefix}'"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Error during streaming upsert: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
||||||
) -> Optional[SearchResult]:
|
) -> Optional[SearchResult]:
|
||||||
@@ -374,7 +419,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
filter={"collection_name": collection_name_with_prefix},
|
filter={"collection_name": collection_name_with_prefix},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not query_response.matches:
|
matches = getattr(query_response, "matches", []) or []
|
||||||
|
if not matches:
|
||||||
# Return empty result if no matches
|
# Return empty result if no matches
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
ids=[[]],
|
ids=[[]],
|
||||||
@@ -384,13 +430,13 @@ class PineconeClient(VectorDBBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Convert to GetResult format
|
# Convert to GetResult format
|
||||||
get_result = self._result_to_get_result(query_response.matches)
|
get_result = self._result_to_get_result(matches)
|
||||||
|
|
||||||
# Calculate normalized distances based on metric
|
# Calculate normalized distances based on metric
|
||||||
distances = [
|
distances = [
|
||||||
[
|
[
|
||||||
self._normalize_distance(match.score)
|
self._normalize_distance(getattr(match, "score", 0.0))
|
||||||
for match in query_response.matches
|
for match in matches
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -432,7 +478,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
include_metadata=True,
|
include_metadata=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._result_to_get_result(query_response.matches)
|
matches = getattr(query_response, "matches", []) or []
|
||||||
|
return self._result_to_get_result(matches)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error querying collection '{collection_name}': {e}")
|
log.error(f"Error querying collection '{collection_name}': {e}")
|
||||||
@@ -456,7 +503,8 @@ class PineconeClient(VectorDBBase):
|
|||||||
filter={"collection_name": collection_name_with_prefix},
|
filter={"collection_name": collection_name_with_prefix},
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._result_to_get_result(query_response.matches)
|
matches = getattr(query_response, "matches", []) or []
|
||||||
|
return self._result_to_get_result(matches)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error getting collection '{collection_name}': {e}")
|
log.error(f"Error getting collection '{collection_name}': {e}")
|
||||||
@@ -482,10 +530,12 @@ class PineconeClient(VectorDBBase):
|
|||||||
# This is a limitation of Pinecone - be careful with ID uniqueness
|
# This is a limitation of Pinecone - be careful with ID uniqueness
|
||||||
self.index.delete(ids=batch_ids)
|
self.index.delete(ids=batch_ids)
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'"
|
f"Deleted batch of {len(batch_ids)} vectors by ID "
|
||||||
|
f"from '{collection_name_with_prefix}'"
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'"
|
f"Successfully deleted {len(ids)} vectors by ID "
|
||||||
|
f"from '{collection_name_with_prefix}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
elif filter:
|
elif filter:
|
||||||
@@ -516,12 +566,12 @@ class PineconeClient(VectorDBBase):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Shut down the gRPC channel and thread pool."""
|
"""Shut down resources."""
|
||||||
try:
|
try:
|
||||||
self.client.close()
|
# The new Pinecone client doesn't need explicit closing
|
||||||
log.info("Pinecone gRPC channel closed.")
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Failed to close Pinecone gRPC channel: {e}")
|
log.warning(f"Failed to clean up Pinecone resources: {e}")
|
||||||
self._executor.shutdown(wait=True)
|
self._executor.shutdown(wait=True)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from open_webui.config import (
|
|||||||
QDRANT_ON_DISK,
|
QDRANT_ON_DISK,
|
||||||
QDRANT_GRPC_PORT,
|
QDRANT_GRPC_PORT,
|
||||||
QDRANT_PREFER_GRPC,
|
QDRANT_PREFER_GRPC,
|
||||||
|
QDRANT_COLLECTION_PREFIX,
|
||||||
|
QDRANT_TIMEOUT,
|
||||||
|
QDRANT_HNSW_M,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
@@ -29,12 +32,14 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|||||||
|
|
||||||
class QdrantClient(VectorDBBase):
|
class QdrantClient(VectorDBBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.collection_prefix = "open-webui"
|
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||||
self.QDRANT_URI = QDRANT_URI
|
self.QDRANT_URI = QDRANT_URI
|
||||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||||
self.PREFER_GRPC = QDRANT_PREFER_GRPC
|
self.PREFER_GRPC = QDRANT_PREFER_GRPC
|
||||||
self.GRPC_PORT = QDRANT_GRPC_PORT
|
self.GRPC_PORT = QDRANT_GRPC_PORT
|
||||||
|
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
|
||||||
|
self.QDRANT_HNSW_M = QDRANT_HNSW_M
|
||||||
|
|
||||||
if not self.QDRANT_URI:
|
if not self.QDRANT_URI:
|
||||||
self.client = None
|
self.client = None
|
||||||
@@ -52,9 +57,14 @@ class QdrantClient(VectorDBBase):
|
|||||||
grpc_port=self.GRPC_PORT,
|
grpc_port=self.GRPC_PORT,
|
||||||
prefer_grpc=self.PREFER_GRPC,
|
prefer_grpc=self.PREFER_GRPC,
|
||||||
api_key=self.QDRANT_API_KEY,
|
api_key=self.QDRANT_API_KEY,
|
||||||
|
timeout=self.QDRANT_TIMEOUT,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
self.client = Qclient(
|
||||||
|
url=self.QDRANT_URI,
|
||||||
|
api_key=self.QDRANT_API_KEY,
|
||||||
|
timeout=QDRANT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
def _result_to_get_result(self, points) -> GetResult:
|
def _result_to_get_result(self, points) -> GetResult:
|
||||||
ids = []
|
ids = []
|
||||||
@@ -84,8 +94,30 @@ class QdrantClient(VectorDBBase):
|
|||||||
distance=models.Distance.COSINE,
|
distance=models.Distance.COSINE,
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
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!")
|
log.info(f"collection {collection_name_with_prefix} successfully created!")
|
||||||
|
|
||||||
def _create_collection_if_not_exists(self, collection_name, dimension):
|
def _create_collection_if_not_exists(self, collection_name, dimension):
|
||||||
@@ -151,23 +183,23 @@ class QdrantClient(VectorDBBase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
points = self.client.query_points(
|
points = self.client.scroll(
|
||||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||||
query_filter=models.Filter(should=field_conditions),
|
scroll_filter=models.Filter(should=field_conditions),
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
return self._result_to_get_result(points.points)
|
return self._result_to_get_result(points[0])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error querying a collection '{collection_name}': {e}")
|
log.exception(f"Error querying a collection '{collection_name}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||||
# Get all the items in the collection.
|
# Get all the items in the collection.
|
||||||
points = self.client.query_points(
|
points = self.client.scroll(
|
||||||
collection_name=f"{self.collection_prefix}_{collection_name}",
|
collection_name=f"{self.collection_prefix}_{collection_name}",
|
||||||
limit=NO_LIMIT, # otherwise qdrant would set limit to 10!
|
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]):
|
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.
|
# Insert the items into the collection, if the collection does not exist, it will be created.
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
WARNING: This mapping relies on current Open WebUI naming conventions for
|
||||||
|
collection names. If Open WebUI changes how it generates collection names
|
||||||
|
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
|
||||||
|
formats), this mapping will break and route data to incorrect collections.
|
||||||
|
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
|
||||||
|
DATA MAPPING INSIDE THE DATABASE.
|
||||||
|
"""
|
||||||
|
# Check for user memory collections
|
||||||
|
tenant_id = collection_name
|
||||||
|
|
||||||
|
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,776 @@
|
|||||||
|
from open_webui.retrieval.vector.utils import process_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 exists using direct lookup.
|
||||||
|
This avoids pagination issues with list_indexes() and is significantly faster.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.client.get_index(
|
||||||
|
vectorBucketName=self.bucket_name, indexName=collection_name
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error checking if index '{collection_name}' exists: {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 = process_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 = process_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,340 @@
|
|||||||
|
import weaviate
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from open_webui.retrieval.vector.main import (
|
||||||
|
VectorDBBase,
|
||||||
|
VectorItem,
|
||||||
|
SearchResult,
|
||||||
|
GetResult,
|
||||||
|
)
|
||||||
|
from open_webui.retrieval.vector.utils import process_metadata
|
||||||
|
from open_webui.config import (
|
||||||
|
WEAVIATE_HTTP_HOST,
|
||||||
|
WEAVIATE_HTTP_PORT,
|
||||||
|
WEAVIATE_GRPC_PORT,
|
||||||
|
WEAVIATE_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_uuids_to_strings(obj: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Recursively convert UUID objects to strings in nested data structures.
|
||||||
|
|
||||||
|
This function handles:
|
||||||
|
- UUID objects -> string
|
||||||
|
- Dictionaries with UUID values
|
||||||
|
- Lists/Tuples with UUID values
|
||||||
|
- Nested combinations of the above
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: Any object that might contain UUIDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The same object structure with UUIDs converted to strings
|
||||||
|
"""
|
||||||
|
if isinstance(obj, uuid.UUID):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {key: _convert_uuids_to_strings(value) for key, value in obj.items()}
|
||||||
|
elif isinstance(obj, (list, tuple)):
|
||||||
|
return type(obj)(_convert_uuids_to_strings(item) for item in obj)
|
||||||
|
elif isinstance(obj, (str, int, float, bool, type(None))):
|
||||||
|
return obj
|
||||||
|
else:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class WeaviateClient(VectorDBBase):
|
||||||
|
def __init__(self):
|
||||||
|
self.url = WEAVIATE_HTTP_HOST
|
||||||
|
try:
|
||||||
|
# Build connection parameters
|
||||||
|
connection_params = {
|
||||||
|
"host": WEAVIATE_HTTP_HOST,
|
||||||
|
"port": WEAVIATE_HTTP_PORT,
|
||||||
|
"grpc_port": WEAVIATE_GRPC_PORT,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
|
||||||
|
if WEAVIATE_API_KEY:
|
||||||
|
connection_params["auth_credentials"] = (
|
||||||
|
weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client = weaviate.connect_to_local(**connection_params)
|
||||||
|
self.client.connect()
|
||||||
|
except Exception as e:
|
||||||
|
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
|
||||||
|
|
||||||
|
def _sanitize_collection_name(self, collection_name: str) -> str:
|
||||||
|
"""Sanitize collection name to be a valid Weaviate class name."""
|
||||||
|
if not isinstance(collection_name, str) or not collection_name.strip():
|
||||||
|
raise ValueError("Collection name must be a non-empty string")
|
||||||
|
|
||||||
|
# Requirements for a valid Weaviate class name:
|
||||||
|
# The collection name must begin with a capital letter.
|
||||||
|
# The name can only contain letters, numbers, and the underscore (_) character. Spaces are not allowed.
|
||||||
|
|
||||||
|
# Replace hyphens with underscores and keep only alphanumeric characters
|
||||||
|
name = re.sub(r"[^a-zA-Z0-9_]", "", collection_name.replace("-", "_"))
|
||||||
|
name = name.strip("_")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise ValueError(
|
||||||
|
"Could not sanitize collection name to be a valid Weaviate class name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure it starts with a letter and is capitalized
|
||||||
|
if not name[0].isalpha():
|
||||||
|
name = "C" + name
|
||||||
|
|
||||||
|
return name[0].upper() + name[1:]
|
||||||
|
|
||||||
|
def has_collection(self, collection_name: str) -> bool:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
return self.client.collections.exists(sane_collection_name)
|
||||||
|
|
||||||
|
def delete_collection(self, collection_name: str) -> None:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if self.client.collections.exists(sane_collection_name):
|
||||||
|
self.client.collections.delete(sane_collection_name)
|
||||||
|
|
||||||
|
def _create_collection(self, collection_name: str) -> None:
|
||||||
|
self.client.collections.create(
|
||||||
|
name=collection_name,
|
||||||
|
vector_config=weaviate.classes.config.Configure.Vectors.self_provided(),
|
||||||
|
properties=[
|
||||||
|
weaviate.classes.config.Property(
|
||||||
|
name="text", data_type=weaviate.classes.config.DataType.TEXT
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if not self.client.collections.exists(sane_collection_name):
|
||||||
|
self._create_collection(sane_collection_name)
|
||||||
|
|
||||||
|
collection = self.client.collections.get(sane_collection_name)
|
||||||
|
|
||||||
|
with collection.batch.fixed_size(batch_size=100) as batch:
|
||||||
|
for item in items:
|
||||||
|
item_uuid = str(uuid.uuid4()) if not item["id"] else str(item["id"])
|
||||||
|
|
||||||
|
properties = {"text": item["text"]}
|
||||||
|
if item["metadata"]:
|
||||||
|
clean_metadata = _convert_uuids_to_strings(
|
||||||
|
process_metadata(item["metadata"])
|
||||||
|
)
|
||||||
|
clean_metadata.pop("text", None)
|
||||||
|
properties.update(clean_metadata)
|
||||||
|
|
||||||
|
batch.add_object(
|
||||||
|
properties=properties, uuid=item_uuid, vector=item["vector"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if not self.client.collections.exists(sane_collection_name):
|
||||||
|
self._create_collection(sane_collection_name)
|
||||||
|
|
||||||
|
collection = self.client.collections.get(sane_collection_name)
|
||||||
|
|
||||||
|
with collection.batch.fixed_size(batch_size=100) as batch:
|
||||||
|
for item in items:
|
||||||
|
item_uuid = str(item["id"]) if item["id"] else None
|
||||||
|
|
||||||
|
properties = {"text": item["text"]}
|
||||||
|
if item["metadata"]:
|
||||||
|
clean_metadata = _convert_uuids_to_strings(
|
||||||
|
process_metadata(item["metadata"])
|
||||||
|
)
|
||||||
|
clean_metadata.pop("text", None)
|
||||||
|
properties.update(clean_metadata)
|
||||||
|
|
||||||
|
batch.add_object(
|
||||||
|
properties=properties, uuid=item_uuid, vector=item["vector"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
|
||||||
|
) -> Optional[SearchResult]:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if not self.client.collections.exists(sane_collection_name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
collection = self.client.collections.get(sane_collection_name)
|
||||||
|
|
||||||
|
result_ids, result_documents, result_metadatas, result_distances = (
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
for vector_embedding in vectors:
|
||||||
|
try:
|
||||||
|
response = collection.query.near_vector(
|
||||||
|
near_vector=vector_embedding,
|
||||||
|
limit=limit,
|
||||||
|
return_metadata=weaviate.classes.query.MetadataQuery(distance=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = [str(obj.uuid) for obj in response.objects]
|
||||||
|
documents = []
|
||||||
|
metadatas = []
|
||||||
|
distances = []
|
||||||
|
|
||||||
|
for obj in response.objects:
|
||||||
|
properties = dict(obj.properties) if obj.properties else {}
|
||||||
|
documents.append(properties.pop("text", ""))
|
||||||
|
metadatas.append(_convert_uuids_to_strings(properties))
|
||||||
|
|
||||||
|
# Weaviate has cosine distance, 2 (worst) -> 0 (best). Re-ordering to 0 -> 1
|
||||||
|
raw_distances = [
|
||||||
|
(
|
||||||
|
obj.metadata.distance
|
||||||
|
if obj.metadata and obj.metadata.distance
|
||||||
|
else 2.0
|
||||||
|
)
|
||||||
|
for obj in response.objects
|
||||||
|
]
|
||||||
|
distances = [(2 - dist) / 2 for dist in raw_distances]
|
||||||
|
|
||||||
|
result_ids.append(ids)
|
||||||
|
result_documents.append(documents)
|
||||||
|
result_metadatas.append(metadatas)
|
||||||
|
result_distances.append(distances)
|
||||||
|
except Exception:
|
||||||
|
result_ids.append([])
|
||||||
|
result_documents.append([])
|
||||||
|
result_metadatas.append([])
|
||||||
|
result_distances.append([])
|
||||||
|
|
||||||
|
return SearchResult(
|
||||||
|
**{
|
||||||
|
"ids": result_ids,
|
||||||
|
"documents": result_documents,
|
||||||
|
"metadatas": result_metadatas,
|
||||||
|
"distances": result_distances,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def query(
|
||||||
|
self, collection_name: str, filter: Dict, limit: Optional[int] = None
|
||||||
|
) -> Optional[GetResult]:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if not self.client.collections.exists(sane_collection_name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
collection = self.client.collections.get(sane_collection_name)
|
||||||
|
|
||||||
|
weaviate_filter = None
|
||||||
|
if filter:
|
||||||
|
for key, value in filter.items():
|
||||||
|
prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
weaviate_filter = (
|
||||||
|
prop_filter
|
||||||
|
if weaviate_filter is None
|
||||||
|
else weaviate.classes.query.Filter.all_of(
|
||||||
|
[weaviate_filter, prop_filter]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = collection.query.fetch_objects(
|
||||||
|
filters=weaviate_filter, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = [str(obj.uuid) for obj in response.objects]
|
||||||
|
documents = []
|
||||||
|
metadatas = []
|
||||||
|
|
||||||
|
for obj in response.objects:
|
||||||
|
properties = dict(obj.properties) if obj.properties else {}
|
||||||
|
documents.append(properties.pop("text", ""))
|
||||||
|
metadatas.append(_convert_uuids_to_strings(properties))
|
||||||
|
|
||||||
|
return GetResult(
|
||||||
|
**{
|
||||||
|
"ids": [ids],
|
||||||
|
"documents": [documents],
|
||||||
|
"metadatas": [metadatas],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if not self.client.collections.exists(sane_collection_name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
collection = self.client.collections.get(sane_collection_name)
|
||||||
|
ids, documents, metadatas = [], [], []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in collection.iterator():
|
||||||
|
ids.append(str(item.uuid))
|
||||||
|
properties = dict(item.properties) if item.properties else {}
|
||||||
|
documents.append(properties.pop("text", ""))
|
||||||
|
metadatas.append(_convert_uuids_to_strings(properties))
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return GetResult(
|
||||||
|
**{
|
||||||
|
"ids": [ids],
|
||||||
|
"documents": [documents],
|
||||||
|
"metadatas": [metadatas],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
ids: Optional[List[str]] = None,
|
||||||
|
filter: Optional[Dict] = None,
|
||||||
|
) -> None:
|
||||||
|
sane_collection_name = self._sanitize_collection_name(collection_name)
|
||||||
|
if not self.client.collections.exists(sane_collection_name):
|
||||||
|
return
|
||||||
|
|
||||||
|
collection = self.client.collections.get(sane_collection_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ids:
|
||||||
|
for item_id in ids:
|
||||||
|
collection.data.delete_by_id(uuid=item_id)
|
||||||
|
elif filter:
|
||||||
|
weaviate_filter = None
|
||||||
|
for key, value in filter.items():
|
||||||
|
prop_filter = weaviate.classes.query.Filter.by_property(
|
||||||
|
name=key
|
||||||
|
).equal(value)
|
||||||
|
weaviate_filter = (
|
||||||
|
prop_filter
|
||||||
|
if weaviate_filter is None
|
||||||
|
else weaviate.classes.query.Filter.all_of(
|
||||||
|
[weaviate_filter, prop_filter]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if weaviate_filter:
|
||||||
|
collection.data.delete_many(where=weaviate_filter)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
try:
|
||||||
|
for collection_name in self.client.collections.list_all().keys():
|
||||||
|
self.client.collections.delete(collection_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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,
|
||||||
|
ENABLE_MILVUS_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:
|
||||||
|
if ENABLE_MILVUS_MULTITENANCY_MODE:
|
||||||
|
from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
|
||||||
|
MilvusClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MilvusClient()
|
||||||
|
else:
|
||||||
|
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||||
|
|
||||||
|
return MilvusClient()
|
||||||
|
case VectorType.QDRANT:
|
||||||
|
if ENABLE_QDRANT_MULTITENANCY_MODE:
|
||||||
|
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
|
||||||
|
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 VectorType.WEAVIATE:
|
||||||
|
from open_webui.retrieval.vector.dbs.weaviate import WeaviateClient
|
||||||
|
|
||||||
|
return WeaviateClient()
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Unsupported vector type: {vector_type}")
|
||||||
|
|
||||||
|
|
||||||
|
VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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"
|
||||||
|
WEAVIATE = "weaviate"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
||||||
|
metadata = {
|
||||||
|
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def process_metadata(
|
||||||
|
metadata: dict[str, any],
|
||||||
|
) -> dict[str, any]:
|
||||||
|
for key, value in metadata.items():
|
||||||
|
# Remove large fields
|
||||||
|
if key in KEYS_TO_EXCLUDE:
|
||||||
|
del metadata[key]
|
||||||
|
|
||||||
|
# Convert non-serializable fields to strings
|
||||||
|
if (
|
||||||
|
isinstance(value, datetime)
|
||||||
|
or isinstance(value, list)
|
||||||
|
or isinstance(value, dict)
|
||||||
|
):
|
||||||
|
metadata[key] = str(value)
|
||||||
|
return metadata
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
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"])
|
||||||
|
|
||||||
|
"""
|
||||||
|
Azure AI Search integration for Open WebUI.
|
||||||
|
Documentation: https://learn.microsoft.com/en-us/python/api/overview/azure/search-documents-readme?view=azure-python
|
||||||
|
|
||||||
|
Required package: azure-search-documents
|
||||||
|
Install: pip install azure-search-documents
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def search_azure(
|
||||||
|
api_key: str,
|
||||||
|
endpoint: str,
|
||||||
|
index_name: str,
|
||||||
|
query: str,
|
||||||
|
count: int,
|
||||||
|
filter_list: Optional[list[str]] = None,
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
"""
|
||||||
|
Search using Azure AI Search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Azure Search API key (query key or admin key)
|
||||||
|
endpoint: Azure Search service endpoint (e.g., https://myservice.search.windows.net)
|
||||||
|
index_name: Name of the search index to query
|
||||||
|
query: Search query string
|
||||||
|
count: Number of results to return
|
||||||
|
filter_list: Optional list of domains to filter results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SearchResult objects with link, title, and snippet
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from azure.core.credentials import AzureKeyCredential
|
||||||
|
from azure.search.documents import SearchClient
|
||||||
|
except ImportError:
|
||||||
|
log.error(
|
||||||
|
"azure-search-documents package is not installed. "
|
||||||
|
"Install it with: pip install azure-search-documents"
|
||||||
|
)
|
||||||
|
raise ImportError(
|
||||||
|
"azure-search-documents is required for Azure AI Search. "
|
||||||
|
"Install it with: pip install azure-search-documents"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create search client with API key authentication
|
||||||
|
credential = AzureKeyCredential(api_key)
|
||||||
|
search_client = SearchClient(
|
||||||
|
endpoint=endpoint, index_name=index_name, credential=credential
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform the search
|
||||||
|
results = search_client.search(search_text=query, top=count)
|
||||||
|
|
||||||
|
# Convert results to list and extract fields
|
||||||
|
search_results = []
|
||||||
|
for result in results:
|
||||||
|
# Azure AI Search returns documents with custom schemas
|
||||||
|
# We need to extract common fields that might represent URL, title, and content
|
||||||
|
# Common field names to look for:
|
||||||
|
result_dict = dict(result)
|
||||||
|
|
||||||
|
# Try to find URL field (common names)
|
||||||
|
link = (
|
||||||
|
result_dict.get("url")
|
||||||
|
or result_dict.get("link")
|
||||||
|
or result_dict.get("uri")
|
||||||
|
or result_dict.get("metadata_storage_path")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to find title field (common names)
|
||||||
|
title = (
|
||||||
|
result_dict.get("title")
|
||||||
|
or result_dict.get("name")
|
||||||
|
or result_dict.get("metadata_title")
|
||||||
|
or result_dict.get("metadata_storage_name")
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to find content/snippet field (common names)
|
||||||
|
snippet = (
|
||||||
|
result_dict.get("content")
|
||||||
|
or result_dict.get("snippet")
|
||||||
|
or result_dict.get("description")
|
||||||
|
or result_dict.get("summary")
|
||||||
|
or result_dict.get("text")
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Truncate snippet if too long
|
||||||
|
if snippet and len(snippet) > 500:
|
||||||
|
snippet = snippet[:497] + "..."
|
||||||
|
|
||||||
|
if link: # Only add if we found a valid link
|
||||||
|
search_results.append(
|
||||||
|
{
|
||||||
|
"link": link,
|
||||||
|
"title": title,
|
||||||
|
"snippet": snippet,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply domain filtering if specified
|
||||||
|
if filter_list:
|
||||||
|
search_results = get_filtered_results(search_results, filter_list)
|
||||||
|
|
||||||
|
# Convert to SearchResult objects
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
link=result["link"],
|
||||||
|
title=result.get("title"),
|
||||||
|
snippet=result.get("snippet"),
|
||||||
|
)
|
||||||
|
for result in search_results
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
log.error(f"Azure AI Search error: {ex}")
|
||||||
|
raise ex
|
||||||
@@ -36,7 +36,9 @@ def search_brave(
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
SearchResult(
|
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]
|
for result in results[:count]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
from duckduckgo_search import DDGS
|
from ddgs import DDGS
|
||||||
from duckduckgo_search.exceptions import RatelimitException
|
from ddgs.exceptions import RatelimitException
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -11,7 +11,10 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|||||||
|
|
||||||
|
|
||||||
def search_duckduckgo(
|
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]:
|
) -> list[SearchResult]:
|
||||||
"""
|
"""
|
||||||
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
|
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
|
||||||
@@ -25,6 +28,9 @@ def search_duckduckgo(
|
|||||||
# Use the DDGS context manager to create a DDGS object
|
# Use the DDGS context manager to create a DDGS object
|
||||||
search_results = []
|
search_results = []
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
|
if concurrent_requests:
|
||||||
|
ddgs.threads = concurrent_requests
|
||||||
|
|
||||||
# Use the ddgs.text() method to perform the search
|
# Use the ddgs.text() method to perform the search
|
||||||
try:
|
try:
|
||||||
search_results = ddgs.text(
|
search_results = ddgs.text(
|
||||||
|
|||||||
@@ -2,27 +2,42 @@ import logging
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
def search_external(
|
def search_external(
|
||||||
|
request: Request,
|
||||||
external_url: str,
|
external_url: str,
|
||||||
external_api_key: str,
|
external_api_key: str,
|
||||||
query: str,
|
query: str,
|
||||||
count: int,
|
count: int,
|
||||||
filter_list: Optional[List[str]] = None,
|
filter_list: Optional[List[str]] = None,
|
||||||
|
user=None,
|
||||||
) -> List[SearchResult]:
|
) -> List[SearchResult]:
|
||||||
try:
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||||
|
"Authorization": f"Bearer {external_api_key}",
|
||||||
|
}
|
||||||
|
headers = include_user_info_headers(headers, user)
|
||||||
|
|
||||||
|
chat_id = getattr(request.state, "chat_id", None)
|
||||||
|
if chat_id:
|
||||||
|
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
external_url,
|
external_url,
|
||||||
headers={
|
headers=headers,
|
||||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
|
||||||
"Authorization": f"Bearer {external_api_key}",
|
|
||||||
},
|
|
||||||
json={
|
json={
|
||||||
"query": query,
|
"query": query,
|
||||||
"count": count,
|
"count": count,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
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.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
@@ -18,27 +17,20 @@ def search_firecrawl(
|
|||||||
filter_list: Optional[List[str]] = None,
|
filter_list: Optional[List[str]] = None,
|
||||||
) -> List[SearchResult]:
|
) -> List[SearchResult]:
|
||||||
try:
|
try:
|
||||||
firecrawl_search_url = urljoin(firecrawl_url, "/v1/search")
|
from firecrawl import FirecrawlApp
|
||||||
response = requests.post(
|
|
||||||
firecrawl_search_url,
|
firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url)
|
||||||
headers={
|
response = firecrawl.search(
|
||||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3
|
||||||
"Authorization": f"Bearer {firecrawl_api_key}",
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"query": query,
|
|
||||||
"limit": count,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
results = response.web
|
||||||
results = response.json().get("data", [])
|
|
||||||
if filter_list:
|
if filter_list:
|
||||||
results = get_filtered_results(results, filter_list)
|
results = get_filtered_results(results, filter_list)
|
||||||
results = [
|
results = [
|
||||||
SearchResult(
|
SearchResult(
|
||||||
link=result.get("url"),
|
link=result.url,
|
||||||
title=result.get("title"),
|
title=result.title,
|
||||||
snippet=result.get("description"),
|
snippet=result.description,
|
||||||
)
|
)
|
||||||
for result in results[:count]
|
for result in results[:count]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def search_google_pse(
|
|||||||
query: str,
|
query: str,
|
||||||
count: int,
|
count: int,
|
||||||
filter_list: Optional[list[str]] = None,
|
filter_list: Optional[list[str]] = None,
|
||||||
|
referer: Optional[str] = None,
|
||||||
) -> list[SearchResult]:
|
) -> list[SearchResult]:
|
||||||
"""Search using Google's Programmable Search Engine API and return the results as a list of SearchResult objects.
|
"""Search using Google's Programmable Search Engine API and return the results as a list of SearchResult objects.
|
||||||
Handles pagination for counts greater than 10.
|
Handles pagination for counts greater than 10.
|
||||||
@@ -30,7 +31,11 @@ def search_google_pse(
|
|||||||
list[SearchResult]: A list of SearchResult objects.
|
list[SearchResult]: A list of SearchResult objects.
|
||||||
"""
|
"""
|
||||||
url = "https://www.googleapis.com/customsearch/v1"
|
url = "https://www.googleapis.com/customsearch/v1"
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if referer:
|
||||||
|
headers["Referer"] = referer
|
||||||
|
|
||||||
all_results = []
|
all_results = []
|
||||||
start_index = 1 # Google PSE start parameter is 1-based
|
start_index = 1 # Google PSE start parameter is 1-based
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,38 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from open_webui.retrieval.web.utils import resolve_hostname
|
||||||
|
from open_webui.utils.misc import is_string_allowed
|
||||||
|
|
||||||
|
|
||||||
def get_filtered_results(results, filter_list):
|
def get_filtered_results(results, filter_list):
|
||||||
if not filter_list:
|
if not filter_list:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
filtered_results = []
|
filtered_results = []
|
||||||
|
|
||||||
for result in 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):
|
if not validators.url(url):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domain = urlparse(url).netloc
|
domain = urlparse(url).netloc
|
||||||
if any(domain.endswith(filtered_domain) for filtered_domain in filter_list):
|
if not domain:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hostnames = [domain]
|
||||||
|
|
||||||
|
try:
|
||||||
|
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
|
||||||
|
hostnames.extend(ipv4_addresses)
|
||||||
|
hostnames.extend(ipv6_addresses)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if is_string_allowed(hostnames, filter_list):
|
||||||
filtered_results.append(result)
|
filtered_results.append(result)
|
||||||
|
continue
|
||||||
|
|
||||||
return filtered_results
|
return filtered_results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
from open_webui.retrieval.web.main import SearchResult
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
|
def search_ollama_cloud(
|
||||||
|
url: str,
|
||||||
|
api_key: str,
|
||||||
|
query: str,
|
||||||
|
count: int,
|
||||||
|
filter_list: Optional[list[str]] = None,
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
"""Search using Ollama Search API and return the results as a list of SearchResult objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key (str): A Ollama Search API key
|
||||||
|
query (str): The query to search for
|
||||||
|
count (int): Number of results to return
|
||||||
|
filter_list (Optional[list[str]]): List of domains to filter results by
|
||||||
|
"""
|
||||||
|
log.info(f"Searching with Ollama for query: {query}")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
payload = {"query": query, "max_results": count}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
log.info(f"Found {len(results)} results")
|
||||||
|
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
link=result.get("url", ""),
|
||||||
|
title=result.get("title", ""),
|
||||||
|
snippet=result.get("content", ""),
|
||||||
|
)
|
||||||
|
for result in results
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error searching Ollama: {e}")
|
||||||
|
return []
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
from typing import Optional, Literal
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
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 = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
@@ -14,6 +24,8 @@ def search_perplexity(
|
|||||||
query: str,
|
query: str,
|
||||||
count: int,
|
count: int,
|
||||||
filter_list: Optional[list[str]] = None,
|
filter_list: Optional[list[str]] = None,
|
||||||
|
model: MODELS = "sonar",
|
||||||
|
search_context_usage: SEARCH_CONTEXT_USAGE_LEVELS = "medium",
|
||||||
) -> list[SearchResult]:
|
) -> list[SearchResult]:
|
||||||
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
"""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
|
api_key (str): A Perplexity API key
|
||||||
query (str): The query to search for
|
query (str): The query to search for
|
||||||
count (int): Maximum number of results to return
|
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
|
# Create payload for the API call
|
||||||
payload = {
|
payload = {
|
||||||
"model": "sonar",
|
"model": model,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@@ -43,6 +58,9 @@ def search_perplexity(
|
|||||||
],
|
],
|
||||||
"temperature": 0.2, # Lower temperature for more factual responses
|
"temperature": 0.2, # Lower temperature for more factual responses
|
||||||
"stream": False,
|
"stream": False,
|
||||||
|
"web_search_options": {
|
||||||
|
"search_context_usage": search_context_usage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional, Literal
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
|
def search_perplexity_search(
|
||||||
|
api_key: str,
|
||||||
|
query: str,
|
||||||
|
count: int,
|
||||||
|
filter_list: Optional[list[str]] = None,
|
||||||
|
api_url: str = "https://api.perplexity.ai/search",
|
||||||
|
user=None,
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key (str): A Perplexity API key
|
||||||
|
query (str): The query to search for
|
||||||
|
count (int): Maximum number of results to return
|
||||||
|
filter_list (Optional[list[str]]): List of domains to filter results
|
||||||
|
api_url (str): Custom API URL (defaults to https://api.perplexity.ai/search)
|
||||||
|
user: Optional user object for forwarding user info headers
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Handle PersistentConfig object
|
||||||
|
if hasattr(api_key, "__str__"):
|
||||||
|
api_key = str(api_key)
|
||||||
|
|
||||||
|
if hasattr(api_url, "__str__"):
|
||||||
|
api_url = str(api_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = api_url
|
||||||
|
|
||||||
|
# Create payload for the API call
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"max_results": count,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward user info headers if user is provided
|
||||||
|
if user is not None:
|
||||||
|
headers = include_user_info_headers(headers, user)
|
||||||
|
|
||||||
|
# Make the API request
|
||||||
|
response = requests.request("POST", url, json=payload, headers=headers)
|
||||||
|
# Parse the JSON response
|
||||||
|
json_response = response.json()
|
||||||
|
|
||||||
|
# Extract citations from the response
|
||||||
|
results = json_response.get("results", [])
|
||||||
|
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
link=result["url"], title=result["title"], snippet=result["snippet"]
|
||||||
|
)
|
||||||
|
for result in results
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error searching with Perplexity Search API: {e}")
|
||||||
|
return []
|
||||||
@@ -42,7 +42,9 @@ def search_searchapi(
|
|||||||
results = get_filtered_results(results, filter_list)
|
results = get_filtered_results(results, filter_list)
|
||||||
return [
|
return [
|
||||||
SearchResult(
|
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]
|
for result in results[:count]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ def search_serpapi(
|
|||||||
results = get_filtered_results(results, filter_list)
|
results = get_filtered_results(results, filter_list)
|
||||||
return [
|
return [
|
||||||
SearchResult(
|
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]
|
for result in results[:count]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import socket
|
|||||||
import ssl
|
import ssl
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
@@ -17,15 +16,17 @@ from typing import (
|
|||||||
Union,
|
Union,
|
||||||
Literal,
|
Literal,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import certifi
|
import certifi
|
||||||
import validators
|
import validators
|
||||||
from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader
|
from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader
|
||||||
from langchain_community.document_loaders.firecrawl import FireCrawlLoader
|
|
||||||
from langchain_community.document_loaders.base import BaseLoader
|
from langchain_community.document_loaders.base import BaseLoader
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
||||||
from open_webui.retrieval.loaders.external import ExternalLoader
|
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
from open_webui.config import (
|
from open_webui.config import (
|
||||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||||
@@ -38,17 +39,46 @@ from open_webui.config import (
|
|||||||
TAVILY_EXTRACT_DEPTH,
|
TAVILY_EXTRACT_DEPTH,
|
||||||
EXTERNAL_WEB_LOADER_URL,
|
EXTERNAL_WEB_LOADER_URL,
|
||||||
EXTERNAL_WEB_LOADER_API_KEY,
|
EXTERNAL_WEB_LOADER_API_KEY,
|
||||||
|
WEB_FETCH_FILTER_LIST,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
from open_webui.utils.misc import is_string_allowed
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hostname(hostname):
|
||||||
|
# Get address information
|
||||||
|
addr_info = socket.getaddrinfo(hostname, None)
|
||||||
|
|
||||||
|
# Extract IP addresses from address information
|
||||||
|
ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]
|
||||||
|
ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]
|
||||||
|
|
||||||
|
return ipv4_addresses, ipv6_addresses
|
||||||
|
|
||||||
|
|
||||||
def validate_url(url: Union[str, Sequence[str]]):
|
def validate_url(url: Union[str, Sequence[str]]):
|
||||||
if isinstance(url, str):
|
if isinstance(url, str):
|
||||||
if isinstance(validators.url(url), validators.ValidationError):
|
if isinstance(validators.url(url), validators.ValidationError):
|
||||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||||
|
|
||||||
|
parsed_url = urllib.parse.urlparse(url)
|
||||||
|
|
||||||
|
# Protocol validation - only allow http/https
|
||||||
|
if parsed_url.scheme not in ["http", "https"]:
|
||||||
|
log.warning(
|
||||||
|
f"Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}"
|
||||||
|
)
|
||||||
|
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||||
|
|
||||||
|
# Blocklist check using unified filtering logic
|
||||||
|
if WEB_FETCH_FILTER_LIST:
|
||||||
|
if not is_string_allowed(url, WEB_FETCH_FILTER_LIST):
|
||||||
|
log.warning(f"URL blocked by filter list: {url}")
|
||||||
|
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||||
|
|
||||||
if not ENABLE_RAG_LOCAL_WEB_FETCH:
|
if not ENABLE_RAG_LOCAL_WEB_FETCH:
|
||||||
# Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
|
# Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
|
||||||
parsed_url = urllib.parse.urlparse(url)
|
parsed_url = urllib.parse.urlparse(url)
|
||||||
@@ -75,22 +105,12 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]:
|
|||||||
try:
|
try:
|
||||||
if validate_url(u):
|
if validate_url(u):
|
||||||
valid_urls.append(u)
|
valid_urls.append(u)
|
||||||
except ValueError:
|
except Exception as e:
|
||||||
|
log.debug(f"Invalid URL {u}: {str(e)}")
|
||||||
continue
|
continue
|
||||||
return valid_urls
|
return valid_urls
|
||||||
|
|
||||||
|
|
||||||
def resolve_hostname(hostname):
|
|
||||||
# Get address information
|
|
||||||
addr_info = socket.getaddrinfo(hostname, None)
|
|
||||||
|
|
||||||
# Extract IP addresses from address information
|
|
||||||
ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]
|
|
||||||
ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]
|
|
||||||
|
|
||||||
return ipv4_addresses, ipv6_addresses
|
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata(soup, url):
|
def extract_metadata(soup, url):
|
||||||
metadata = {"source": url}
|
metadata = {"source": url}
|
||||||
if title := soup.find("title"):
|
if title := soup.find("title"):
|
||||||
@@ -141,13 +161,13 @@ class RateLimitMixin:
|
|||||||
|
|
||||||
|
|
||||||
class URLProcessingMixin:
|
class URLProcessingMixin:
|
||||||
def _verify_ssl_cert(self, url: str) -> bool:
|
async def _verify_ssl_cert(self, url: str) -> bool:
|
||||||
"""Verify SSL certificate for a URL."""
|
"""Verify SSL certificate for a URL."""
|
||||||
return verify_ssl_cert(url)
|
return await run_in_threadpool(verify_ssl_cert, url)
|
||||||
|
|
||||||
async def _safe_process_url(self, url: str) -> bool:
|
async def _safe_process_url(self, url: str) -> bool:
|
||||||
"""Perform safety checks before processing a URL."""
|
"""Perform safety checks before processing a URL."""
|
||||||
if self.verify_ssl and not self._verify_ssl_cert(url):
|
if self.verify_ssl and not await self._verify_ssl_cert(url):
|
||||||
raise ValueError(f"SSL certificate verification failed for {url}")
|
raise ValueError(f"SSL certificate verification failed for {url}")
|
||||||
await self._wait_for_rate_limit()
|
await self._wait_for_rate_limit()
|
||||||
return True
|
return True
|
||||||
@@ -188,13 +208,12 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||||||
(uses FIRE_CRAWL_API_KEY environment variable if not provided).
|
(uses FIRE_CRAWL_API_KEY environment variable if not provided).
|
||||||
api_url: Base URL for FireCrawl API. Defaults to official API endpoint.
|
api_url: Base URL for FireCrawl API. Defaults to official API endpoint.
|
||||||
mode: Operation mode selection:
|
mode: Operation mode selection:
|
||||||
- 'crawl': Website crawling mode (default)
|
- 'crawl': Website crawling mode
|
||||||
- 'scrape': Direct page scraping
|
- 'scrape': Direct page scraping (default)
|
||||||
- 'map': Site map generation
|
- 'map': Site map generation
|
||||||
proxy: Proxy override settings for the FireCrawl API.
|
proxy: Proxy override settings for the FireCrawl API.
|
||||||
params: The parameters to pass to the Firecrawl API.
|
params: The parameters to pass to the Firecrawl API.
|
||||||
Examples include crawlerOptions.
|
For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape
|
||||||
For more details, visit: https://github.com/mendableai/firecrawl-py
|
|
||||||
"""
|
"""
|
||||||
proxy_server = proxy.get("server") if proxy else None
|
proxy_server = proxy.get("server") if proxy else None
|
||||||
if trust_env and not proxy_server:
|
if trust_env and not proxy_server:
|
||||||
@@ -214,50 +233,88 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.api_url = api_url
|
self.api_url = api_url
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.params = params
|
self.params = params or {}
|
||||||
|
|
||||||
def lazy_load(self) -> Iterator[Document]:
|
def lazy_load(self) -> Iterator[Document]:
|
||||||
"""Load documents concurrently using FireCrawl."""
|
"""Load documents using FireCrawl batch_scrape."""
|
||||||
for url in self.web_paths:
|
log.debug(
|
||||||
try:
|
"Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s",
|
||||||
self._safe_process_url_sync(url)
|
len(self.web_paths),
|
||||||
loader = FireCrawlLoader(
|
self.mode,
|
||||||
url=url,
|
self.params,
|
||||||
api_key=self.api_key,
|
)
|
||||||
api_url=self.api_url,
|
try:
|
||||||
mode=self.mode,
|
from firecrawl import FirecrawlApp
|
||||||
params=self.params,
|
|
||||||
|
firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url)
|
||||||
|
result = firecrawl.batch_scrape(
|
||||||
|
self.web_paths,
|
||||||
|
formats=["markdown"],
|
||||||
|
skip_tls_verification=not self.verify_ssl,
|
||||||
|
ignore_invalid_urls=True,
|
||||||
|
remove_base64_images=True,
|
||||||
|
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
|
||||||
|
wait_timeout=len(self.web_paths) * 3,
|
||||||
|
**self.params,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.status != "completed":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"FireCrawl batch scrape did not complete successfully. result: {result}"
|
||||||
)
|
)
|
||||||
for document in loader.lazy_load():
|
|
||||||
if not document.metadata.get("source"):
|
for data in result.data:
|
||||||
document.metadata["source"] = document.metadata.get("sourceURL")
|
metadata = data.metadata or {}
|
||||||
yield document
|
yield Document(
|
||||||
except Exception as e:
|
page_content=data.markdown or "",
|
||||||
if self.continue_on_failure:
|
metadata={"source": metadata.url or metadata.source_url or ""},
|
||||||
log.exception(f"Error loading {url}: {e}")
|
)
|
||||||
continue
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.continue_on_failure:
|
||||||
|
log.exception(f"Error extracting content from URLs: {e}")
|
||||||
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def alazy_load(self):
|
async def alazy_load(self):
|
||||||
"""Async version of lazy_load."""
|
"""Async version of lazy_load."""
|
||||||
for url in self.web_paths:
|
log.debug(
|
||||||
try:
|
"Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s",
|
||||||
await self._safe_process_url(url)
|
len(self.web_paths),
|
||||||
loader = FireCrawlLoader(
|
self.mode,
|
||||||
url=url,
|
self.params,
|
||||||
api_key=self.api_key,
|
)
|
||||||
api_url=self.api_url,
|
try:
|
||||||
mode=self.mode,
|
from firecrawl import FirecrawlApp
|
||||||
params=self.params,
|
|
||||||
|
firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url)
|
||||||
|
result = firecrawl.batch_scrape(
|
||||||
|
self.web_paths,
|
||||||
|
formats=["markdown"],
|
||||||
|
skip_tls_verification=not self.verify_ssl,
|
||||||
|
ignore_invalid_urls=True,
|
||||||
|
remove_base64_images=True,
|
||||||
|
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
|
||||||
|
wait_timeout=len(self.web_paths) * 3,
|
||||||
|
**self.params,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.status != "completed":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"FireCrawl batch scrape did not complete successfully. result: {result}"
|
||||||
)
|
)
|
||||||
async for document in loader.alazy_load():
|
|
||||||
if not document.metadata.get("source"):
|
for data in result.data:
|
||||||
document.metadata["source"] = document.metadata.get("sourceURL")
|
metadata = data.metadata or {}
|
||||||
yield document
|
yield Document(
|
||||||
except Exception as e:
|
page_content=data.markdown or "",
|
||||||
if self.continue_on_failure:
|
metadata={"source": metadata.url or metadata.source_url or ""},
|
||||||
log.exception(f"Error loading {url}: {e}")
|
)
|
||||||
continue
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.continue_on_failure:
|
||||||
|
log.exception(f"Error extracting content from URLs: {e}")
|
||||||
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
@@ -515,7 +572,9 @@ class SafeWebBaseLoader(WebBaseLoader):
|
|||||||
kwargs["ssl"] = False
|
kwargs["ssl"] = False
|
||||||
|
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, **(self.requests_kwargs | kwargs)
|
url,
|
||||||
|
**(self.requests_kwargs | kwargs),
|
||||||
|
allow_redirects=False,
|
||||||
) as response:
|
) as response:
|
||||||
if self.raise_for_status:
|
if self.raise_for_status:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -601,6 +660,10 @@ def get_web_loader(
|
|||||||
# Check if the URLs are valid
|
# Check if the URLs are valid
|
||||||
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
|
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
|
||||||
|
|
||||||
|
if not safe_urls:
|
||||||
|
log.warning(f"All provided URLs were blocked or invalid: {urls}")
|
||||||
|
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||||
|
|
||||||
web_loader_args = {
|
web_loader_args = {
|
||||||
"web_paths": safe_urls,
|
"web_paths": safe_urls,
|
||||||
"verify_ssl": verify_ssl,
|
"verify_ssl": verify_ssl,
|
||||||
@@ -613,7 +676,7 @@ def get_web_loader(
|
|||||||
WebLoaderClass = SafeWebBaseLoader
|
WebLoaderClass = SafeWebBaseLoader
|
||||||
if WEB_LOADER_ENGINE.value == "playwright":
|
if WEB_LOADER_ENGINE.value == "playwright":
|
||||||
WebLoaderClass = SafePlaywrightURLLoader
|
WebLoaderClass = SafePlaywrightURLLoader
|
||||||
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
|
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
|
||||||
if PLAYWRIGHT_WS_URL.value:
|
if PLAYWRIGHT_WS_URL.value:
|
||||||
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
|
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
|
||||||
|
|
||||||
@@ -628,7 +691,7 @@ def get_web_loader(
|
|||||||
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
|
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
|
||||||
|
|
||||||
if WEB_LOADER_ENGINE.value == "external":
|
if WEB_LOADER_ENGINE.value == "external":
|
||||||
WebLoaderClass = ExternalLoader
|
WebLoaderClass = ExternalWebLoader
|
||||||
web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
|
web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
|
||||||
web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
|
web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
|
||||||
|
|
||||||
|
|||||||
+474
-115
@@ -3,11 +3,15 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import html
|
||||||
|
import base64
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
from pydub.silence import split_on_silence
|
from pydub.silence import split_on_silence
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fnmatch import fnmatch
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import requests
|
import requests
|
||||||
@@ -17,6 +21,7 @@ from fastapi import (
|
|||||||
Depends,
|
Depends,
|
||||||
FastAPI,
|
FastAPI,
|
||||||
File,
|
File,
|
||||||
|
Form,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
Request,
|
Request,
|
||||||
UploadFile,
|
UploadFile,
|
||||||
@@ -29,17 +34,20 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
from open_webui.config import (
|
from open_webui.config import (
|
||||||
WHISPER_MODEL_AUTO_UPDATE,
|
WHISPER_MODEL_AUTO_UPDATE,
|
||||||
WHISPER_MODEL_DIR,
|
WHISPER_MODEL_DIR,
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
WHISPER_LANGUAGE,
|
WHISPER_LANGUAGE,
|
||||||
|
ELEVENLABS_API_BASE_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
AIOHTTP_CLIENT_TIMEOUT,
|
|
||||||
ENV,
|
ENV,
|
||||||
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
AIOHTTP_CLIENT_TIMEOUT,
|
||||||
SRC_LOG_LEVELS,
|
SRC_LOG_LEVELS,
|
||||||
DEVICE_TYPE,
|
DEVICE_TYPE,
|
||||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||||
@@ -49,7 +57,7 @@ from open_webui.env import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
MAX_FILE_SIZE_MB = 25
|
MAX_FILE_SIZE_MB = 20
|
||||||
MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
||||||
AZURE_MAX_FILE_SIZE_MB = 200
|
AZURE_MAX_FILE_SIZE_MB = 200
|
||||||
AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
||||||
@@ -71,35 +79,47 @@ from pydub import AudioSegment
|
|||||||
from pydub.utils import mediainfo
|
from pydub.utils import mediainfo
|
||||||
|
|
||||||
|
|
||||||
def get_audio_convert_format(file_path):
|
def is_audio_conversion_required(file_path):
|
||||||
"""Check if the given file needs to be converted to a different format."""
|
"""
|
||||||
|
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):
|
if not os.path.isfile(file_path):
|
||||||
log.error(f"File not found: {file_path}")
|
log.error(f"File not found: {file_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
info = mediainfo(file_path)
|
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 (
|
if codec_name == "aac" and codec_type == "audio" and codec_tag_string == "mp4a":
|
||||||
info.get("codec_name") == "aac"
|
# File is AAC/mp4a audio, recommend mp3 conversion
|
||||||
and info.get("codec_type") == "audio"
|
return True
|
||||||
and info.get("codec_tag_string") == "mp4a"
|
|
||||||
):
|
# If the codec name is in the supported formats
|
||||||
return "mp4"
|
if codec_name in SUPPORTED_FORMATS:
|
||||||
elif info.get("format_name") == "ogg":
|
return False
|
||||||
return "ogg"
|
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error getting audio format: {e}")
|
log.error(f"Error getting audio format: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
def convert_audio_to_mp3(file_path):
|
||||||
def convert_audio_to_wav(file_path, output_path, conversion_type):
|
"""Convert audio file to mp3 format."""
|
||||||
"""Convert MP4/OGG audio file to WAV format."""
|
try:
|
||||||
audio = AudioSegment.from_file(file_path, format=conversion_type)
|
output_path = os.path.splitext(file_path)[0] + ".mp3"
|
||||||
audio.export(output_path, format="wav")
|
audio = AudioSegment.from_file(file_path)
|
||||||
log.info(f"Converted {file_path} to {output_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):
|
def set_faster_whisper_model(model: str, auto_update: bool = False):
|
||||||
@@ -136,6 +156,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
|
|||||||
class TTSConfigForm(BaseModel):
|
class TTSConfigForm(BaseModel):
|
||||||
OPENAI_API_BASE_URL: str
|
OPENAI_API_BASE_URL: str
|
||||||
OPENAI_API_KEY: str
|
OPENAI_API_KEY: str
|
||||||
|
OPENAI_PARAMS: Optional[dict] = None
|
||||||
API_KEY: str
|
API_KEY: str
|
||||||
ENGINE: str
|
ENGINE: str
|
||||||
MODEL: str
|
MODEL: str
|
||||||
@@ -151,6 +172,7 @@ class STTConfigForm(BaseModel):
|
|||||||
OPENAI_API_KEY: str
|
OPENAI_API_KEY: str
|
||||||
ENGINE: str
|
ENGINE: str
|
||||||
MODEL: str
|
MODEL: str
|
||||||
|
SUPPORTED_CONTENT_TYPES: list[str] = []
|
||||||
WHISPER_MODEL: str
|
WHISPER_MODEL: str
|
||||||
DEEPGRAM_API_KEY: str
|
DEEPGRAM_API_KEY: str
|
||||||
AZURE_API_KEY: str
|
AZURE_API_KEY: str
|
||||||
@@ -158,6 +180,9 @@ class STTConfigForm(BaseModel):
|
|||||||
AZURE_LOCALES: str
|
AZURE_LOCALES: str
|
||||||
AZURE_BASE_URL: str
|
AZURE_BASE_URL: str
|
||||||
AZURE_MAX_SPEAKERS: str
|
AZURE_MAX_SPEAKERS: str
|
||||||
|
MISTRAL_API_KEY: str
|
||||||
|
MISTRAL_API_BASE_URL: str
|
||||||
|
MISTRAL_USE_CHAT_COMPLETIONS: bool
|
||||||
|
|
||||||
|
|
||||||
class AudioConfigUpdateForm(BaseModel):
|
class AudioConfigUpdateForm(BaseModel):
|
||||||
@@ -171,6 +196,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
|||||||
"tts": {
|
"tts": {
|
||||||
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||||
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
||||||
|
"OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
|
||||||
"API_KEY": request.app.state.config.TTS_API_KEY,
|
"API_KEY": request.app.state.config.TTS_API_KEY,
|
||||||
"ENGINE": request.app.state.config.TTS_ENGINE,
|
"ENGINE": request.app.state.config.TTS_ENGINE,
|
||||||
"MODEL": request.app.state.config.TTS_MODEL,
|
"MODEL": request.app.state.config.TTS_MODEL,
|
||||||
@@ -185,6 +211,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
|||||||
"OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY,
|
"OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY,
|
||||||
"ENGINE": request.app.state.config.STT_ENGINE,
|
"ENGINE": request.app.state.config.STT_ENGINE,
|
||||||
"MODEL": request.app.state.config.STT_MODEL,
|
"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,
|
"WHISPER_MODEL": request.app.state.config.WHISPER_MODEL,
|
||||||
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
|
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
|
||||||
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||||
@@ -192,6 +219,9 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
|||||||
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
||||||
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
||||||
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||||
|
"MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
|
||||||
|
"MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
|
||||||
|
"MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +232,7 @@ async def update_audio_config(
|
|||||||
):
|
):
|
||||||
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
|
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
|
||||||
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
|
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
|
||||||
|
request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
|
||||||
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
|
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
|
||||||
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
|
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
|
||||||
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
|
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
|
||||||
@@ -219,6 +250,10 @@ async def update_audio_config(
|
|||||||
request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY
|
request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY
|
||||||
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
|
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_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.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
|
||||||
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
|
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_API_KEY = form_data.stt.AZURE_API_KEY
|
||||||
@@ -228,20 +263,30 @@ async def update_audio_config(
|
|||||||
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = (
|
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = (
|
||||||
form_data.stt.AZURE_MAX_SPEAKERS
|
form_data.stt.AZURE_MAX_SPEAKERS
|
||||||
)
|
)
|
||||||
|
request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY
|
||||||
|
request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = (
|
||||||
|
form_data.stt.MISTRAL_API_BASE_URL
|
||||||
|
)
|
||||||
|
request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = (
|
||||||
|
form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS
|
||||||
|
)
|
||||||
|
|
||||||
if request.app.state.config.STT_ENGINE == "":
|
if request.app.state.config.STT_ENGINE == "":
|
||||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||||
form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
|
form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
request.app.state.faster_whisper_model = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tts": {
|
"tts": {
|
||||||
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
|
||||||
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
|
||||||
"API_KEY": request.app.state.config.TTS_API_KEY,
|
|
||||||
"ENGINE": request.app.state.config.TTS_ENGINE,
|
"ENGINE": request.app.state.config.TTS_ENGINE,
|
||||||
"MODEL": request.app.state.config.TTS_MODEL,
|
"MODEL": request.app.state.config.TTS_MODEL,
|
||||||
"VOICE": request.app.state.config.TTS_VOICE,
|
"VOICE": request.app.state.config.TTS_VOICE,
|
||||||
|
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||||
|
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
||||||
|
"OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
|
||||||
|
"API_KEY": request.app.state.config.TTS_API_KEY,
|
||||||
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
|
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
|
||||||
"AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION,
|
"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_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
|
||||||
@@ -252,6 +297,7 @@ async def update_audio_config(
|
|||||||
"OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY,
|
"OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY,
|
||||||
"ENGINE": request.app.state.config.STT_ENGINE,
|
"ENGINE": request.app.state.config.STT_ENGINE,
|
||||||
"MODEL": request.app.state.config.STT_MODEL,
|
"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,
|
"WHISPER_MODEL": request.app.state.config.WHISPER_MODEL,
|
||||||
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
|
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
|
||||||
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||||
@@ -259,6 +305,9 @@ async def update_audio_config(
|
|||||||
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
||||||
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
||||||
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||||
|
"MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
|
||||||
|
"MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
|
||||||
|
"MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +350,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
log.exception(e)
|
log.exception(e)
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||||
|
|
||||||
|
r = None
|
||||||
if request.app.state.config.TTS_ENGINE == "openai":
|
if request.app.state.config.TTS_ENGINE == "openai":
|
||||||
payload["model"] = request.app.state.config.TTS_MODEL
|
payload["model"] = request.app.state.config.TTS_MODEL
|
||||||
|
|
||||||
@@ -309,31 +359,32 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
timeout=timeout, trust_env=True
|
timeout=timeout, trust_env=True
|
||||||
) as session:
|
) as session:
|
||||||
async with session.post(
|
payload = {
|
||||||
|
**payload,
|
||||||
|
**(request.app.state.config.TTS_OPENAI_PARAMS or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
||||||
|
}
|
||||||
|
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||||
|
headers = include_user_info_headers(headers, user)
|
||||||
|
|
||||||
|
r = await session.post(
|
||||||
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers=headers,
|
||||||
"Content-Type": "application/json",
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
)
|
||||||
**(
|
|
||||||
{
|
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
|
||||||
}
|
|
||||||
if ENABLE_FORWARD_USER_INFO_HEADERS
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
},
|
|
||||||
) as r:
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, "wb") as f:
|
r.raise_for_status()
|
||||||
await f.write(await r.read())
|
|
||||||
|
|
||||||
async with aiofiles.open(file_body_path, "w") as f:
|
async with aiofiles.open(file_path, "wb") as f:
|
||||||
await f.write(json.dumps(payload))
|
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)
|
return FileResponse(file_path)
|
||||||
|
|
||||||
@@ -341,18 +392,22 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
log.exception(e)
|
log.exception(e)
|
||||||
detail = None
|
detail = None
|
||||||
|
|
||||||
try:
|
status_code = 500
|
||||||
if r.status != 200:
|
detail = f"Open WebUI: Server Connection Error"
|
||||||
res = await r.json()
|
|
||||||
|
|
||||||
|
if r is not None:
|
||||||
|
status_code = r.status
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await r.json()
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
detail = f"External: {res['error'].get('message', '')}"
|
detail = f"External: {res['error']}"
|
||||||
except Exception:
|
except Exception:
|
||||||
detail = f"External: {e}"
|
detail = f"External: {e}"
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=getattr(r, "status", 500) if r else 500,
|
status_code=status_code,
|
||||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
detail=detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
||||||
@@ -370,7 +425,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
timeout=timeout, trust_env=True
|
timeout=timeout, trust_env=True
|
||||||
) as session:
|
) as session:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
|
f"{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}",
|
||||||
json={
|
json={
|
||||||
"text": payload["input"],
|
"text": payload["input"],
|
||||||
"model_id": request.app.state.config.TTS_MODEL,
|
"model_id": request.app.state.config.TTS_MODEL,
|
||||||
@@ -381,6 +436,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"xi-api-key": request.app.state.config.TTS_API_KEY,
|
"xi-api-key": request.app.state.config.TTS_API_KEY,
|
||||||
},
|
},
|
||||||
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as r:
|
) as r:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
@@ -424,7 +480,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">
|
data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">
|
||||||
<voice name="{language}">{payload["input"]}</voice>
|
<voice name="{language}">{html.escape(payload["input"])}</voice>
|
||||||
</speak>"""
|
</speak>"""
|
||||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
@@ -439,6 +495,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
"X-Microsoft-OutputFormat": output_format,
|
"X-Microsoft-OutputFormat": output_format,
|
||||||
},
|
},
|
||||||
data=data,
|
data=data,
|
||||||
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as r:
|
) as r:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
@@ -507,12 +564,18 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
return FileResponse(file_path)
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
|
||||||
def transcribe(request: Request, file_path):
|
def transcription_handler(request, file_path, metadata, user=None):
|
||||||
log.info(f"transcribe: {file_path}")
|
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
file_dir = os.path.dirname(file_path)
|
file_dir = os.path.dirname(file_path)
|
||||||
id = filename.split(".")[0]
|
id = filename.split(".")[0]
|
||||||
|
|
||||||
|
metadata = metadata or {}
|
||||||
|
|
||||||
|
languages = [
|
||||||
|
metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
|
||||||
|
None, # Always fallback to None in case transcription fails
|
||||||
|
]
|
||||||
|
|
||||||
if request.app.state.config.STT_ENGINE == "":
|
if request.app.state.config.STT_ENGINE == "":
|
||||||
if request.app.state.faster_whisper_model is None:
|
if request.app.state.faster_whisper_model is None:
|
||||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||||
@@ -524,7 +587,7 @@ def transcribe(request: Request, file_path):
|
|||||||
file_path,
|
file_path,
|
||||||
beam_size=5,
|
beam_size=5,
|
||||||
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
|
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
|
||||||
language=WHISPER_LANGUAGE,
|
language=languages[0],
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"Detected language '%s' with probability %f"
|
"Detected language '%s' with probability %f"
|
||||||
@@ -542,29 +605,32 @@ def transcribe(request: Request, file_path):
|
|||||||
log.debug(data)
|
log.debug(data)
|
||||||
return data
|
return data
|
||||||
elif request.app.state.config.STT_ENGINE == "openai":
|
elif request.app.state.config.STT_ENGINE == "openai":
|
||||||
convert_format = get_audio_convert_format(file_path)
|
|
||||||
|
|
||||||
if convert_format:
|
|
||||||
ext = convert_format.split(".")[-1]
|
|
||||||
|
|
||||||
os.rename(file_path, file_path.replace(".{ext}", f".{convert_format}"))
|
|
||||||
# Convert unsupported audio file to WAV format
|
|
||||||
convert_audio_to_wav(
|
|
||||||
file_path.replace(".{ext}", f".{convert_format}"),
|
|
||||||
file_path,
|
|
||||||
convert_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
r = None
|
r = None
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
for language in languages:
|
||||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
payload = {
|
||||||
headers={
|
"model": request.app.state.config.STT_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if language:
|
||||||
|
payload["language"] = language
|
||||||
|
|
||||||
|
headers = {
|
||||||
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
|
||||||
},
|
}
|
||||||
files={"file": (filename, open(file_path, "rb"))},
|
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||||
data={"model": request.app.state.config.STT_MODEL},
|
headers = include_user_info_headers(headers, user)
|
||||||
)
|
|
||||||
|
r = requests.post(
|
||||||
|
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (filename, open(file_path, "rb"))},
|
||||||
|
data=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
# Successful transcription
|
||||||
|
break
|
||||||
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -606,18 +672,26 @@ def transcribe(request: Request, file_path):
|
|||||||
"Content-Type": mime,
|
"Content-Type": mime,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add model if specified
|
for language in languages:
|
||||||
params = {}
|
params = {}
|
||||||
if request.app.state.config.STT_MODEL:
|
if request.app.state.config.STT_MODEL:
|
||||||
params["model"] = 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()
|
r.raise_for_status()
|
||||||
response_data = r.json()
|
response_data = r.json()
|
||||||
|
|
||||||
@@ -770,42 +844,328 @@ def transcribe(request: Request, file_path):
|
|||||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
detail=detail if detail else "Open WebUI: Server Connection Error",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif request.app.state.config.STT_ENGINE == "mistral":
|
||||||
|
# Check file exists
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=400, detail="Audio file not found")
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB}MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY
|
||||||
|
api_base_url = (
|
||||||
|
request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL
|
||||||
|
or "https://api.mistral.ai/v1"
|
||||||
|
)
|
||||||
|
use_chat_completions = (
|
||||||
|
request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Mistral API key is required for Mistral STT",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
# Use voxtral-mini-latest as the default model for transcription
|
||||||
|
model = request.app.state.config.STT_MODEL or "voxtral-mini-latest"
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f"Mistral STT - model: {model}, "
|
||||||
|
f"method: {'chat_completions' if use_chat_completions else 'transcriptions'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_chat_completions:
|
||||||
|
# Use chat completions API with audio input
|
||||||
|
# This method requires mp3 or wav format
|
||||||
|
audio_file_to_use = file_path
|
||||||
|
|
||||||
|
if is_audio_conversion_required(file_path):
|
||||||
|
log.debug("Converting audio to mp3 for chat completions API")
|
||||||
|
converted_path = convert_audio_to_mp3(file_path)
|
||||||
|
if converted_path:
|
||||||
|
audio_file_to_use = converted_path
|
||||||
|
else:
|
||||||
|
log.error("Audio conversion failed")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Audio conversion failed. Chat completions API requires mp3 or wav format.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read and encode audio file as base64
|
||||||
|
with open(audio_file_to_use, "rb") as audio_file:
|
||||||
|
audio_base64 = base64.b64encode(audio_file.read()).decode("utf-8")
|
||||||
|
|
||||||
|
# Prepare chat completions request
|
||||||
|
url = f"{api_base_url}/chat/completions"
|
||||||
|
|
||||||
|
# Add language instruction if specified
|
||||||
|
language = metadata.get("language", None) if metadata else None
|
||||||
|
if language:
|
||||||
|
text_instruction = f"Transcribe this audio exactly as spoken in {language}. Do not translate it."
|
||||||
|
else:
|
||||||
|
text_instruction = "Transcribe this audio exactly as spoken in its original language. Do not translate it to another language."
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": audio_base64,
|
||||||
|
},
|
||||||
|
{"type": "text", "text": text_instruction},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post(
|
||||||
|
url=url,
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
response = r.json()
|
||||||
|
|
||||||
|
# Extract transcript from chat completion response
|
||||||
|
transcript = (
|
||||||
|
response.get("choices", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("content", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
if not transcript:
|
||||||
|
raise ValueError("Empty transcript in response")
|
||||||
|
|
||||||
|
data = {"text": transcript}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Use dedicated transcriptions API
|
||||||
|
url = f"{api_base_url}/audio/transcriptions"
|
||||||
|
|
||||||
|
# Determine the MIME type
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
if not mime_type:
|
||||||
|
mime_type = "audio/webm"
|
||||||
|
|
||||||
|
# Use context manager to ensure file is properly closed
|
||||||
|
with open(file_path, "rb") as audio_file:
|
||||||
|
files = {"file": (filename, audio_file, mime_type)}
|
||||||
|
data_form = {"model": model}
|
||||||
|
|
||||||
|
# Add language if specified in metadata
|
||||||
|
language = metadata.get("language", None) if metadata else None
|
||||||
|
if language:
|
||||||
|
data_form["language"] = language
|
||||||
|
|
||||||
|
r = requests.post(
|
||||||
|
url=url,
|
||||||
|
files=files,
|
||||||
|
data=data_form,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
response = r.json()
|
||||||
|
|
||||||
|
# Extract transcript from response
|
||||||
|
transcript = response.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 ValueError as e:
|
||||||
|
log.exception("Error parsing Mistral response")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to parse Mistral 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', '')}"
|
||||||
|
else:
|
||||||
|
detail = f"External: {r.text}"
|
||||||
|
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, user=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, user
|
||||||
|
)
|
||||||
|
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):
|
def compress_audio(file_path):
|
||||||
if os.path.getsize(file_path) > MAX_FILE_SIZE:
|
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)
|
file_dir = os.path.dirname(file_path)
|
||||||
|
|
||||||
audio = AudioSegment.from_file(file_path)
|
audio = AudioSegment.from_file(file_path)
|
||||||
audio = audio.set_frame_rate(16000).set_channels(1) # Compress audio
|
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 (
|
compressed_path = os.path.join(file_dir, f"{id}_compressed.mp3")
|
||||||
os.path.getsize(compressed_path) > MAX_FILE_SIZE
|
audio.export(compressed_path, format="mp3", bitrate="32k")
|
||||||
): # Still larger than MAX_FILE_SIZE after compression
|
# log.debug(f"Compressed audio to {compressed_path}") # Uncomment if log is defined
|
||||||
raise Exception(ERROR_MESSAGES.FILE_TOO_LARGE(size=f"{MAX_FILE_SIZE_MB}MB"))
|
|
||||||
return compressed_path
|
return compressed_path
|
||||||
else:
|
else:
|
||||||
return file_path
|
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")
|
@router.post("/transcriptions")
|
||||||
def transcription(
|
def transcription(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
language: Optional[str] = Form(None),
|
||||||
user=Depends(get_verified_user),
|
user=Depends(get_verified_user),
|
||||||
):
|
):
|
||||||
log.info(f"file.content_type: {file.content_type}")
|
log.info(f"file.content_type: {file.content_type}")
|
||||||
|
|
||||||
supported_filetypes = (
|
stt_supported_content_types = getattr(
|
||||||
"audio/mpeg",
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
"audio/wav",
|
|
||||||
"audio/ogg",
|
|
||||||
"audio/x-m4a",
|
|
||||||
"audio/webm",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
|
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
|
||||||
@@ -826,19 +1186,18 @@ def transcription(
|
|||||||
f.write(contents)
|
f.write(contents)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
metadata = None
|
||||||
file_path = compress_audio(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
|
|
||||||
raise HTTPException(
|
if language:
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
metadata = {"language": language}
|
||||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
|
||||||
)
|
result = transcribe(request, file_path, metadata, user)
|
||||||
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
|
|
||||||
@@ -878,7 +1237,7 @@ def get_available_models(request: Request) -> list[dict]:
|
|||||||
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
"https://api.elevenlabs.io/v1/models",
|
f"{ELEVENLABS_API_BASE_URL}/v1/models",
|
||||||
headers={
|
headers={
|
||||||
"xi-api-key": request.app.state.config.TTS_API_KEY,
|
"xi-api-key": request.app.state.config.TTS_API_KEY,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -982,7 +1341,7 @@ def get_elevenlabs_voices(api_key: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
# TODO: Add retries
|
# TODO: Add retries
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
"https://api.elevenlabs.io/v1/voices",
|
f"{ELEVENLABS_API_BASE_URL}/v1/voices",
|
||||||
headers={
|
headers={
|
||||||
"xi-api-key": api_key,
|
"xi-api-key": api_key,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
+375
-101
@@ -4,6 +4,8 @@ import time
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
|
||||||
from open_webui.models.auths import (
|
from open_webui.models.auths import (
|
||||||
AddUserForm,
|
AddUserForm,
|
||||||
@@ -15,29 +17,44 @@ from open_webui.models.auths import (
|
|||||||
SigninResponse,
|
SigninResponse,
|
||||||
SignupForm,
|
SignupForm,
|
||||||
UpdatePasswordForm,
|
UpdatePasswordForm,
|
||||||
UpdateProfileForm,
|
|
||||||
UserResponse,
|
|
||||||
)
|
)
|
||||||
from open_webui.models.users import Users
|
from open_webui.models.users import (
|
||||||
|
UserProfileImageResponse,
|
||||||
|
Users,
|
||||||
|
UpdateProfileForm,
|
||||||
|
UserStatus,
|
||||||
|
)
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
from open_webui.models.oauth_sessions import OAuthSessions
|
||||||
|
|
||||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
WEBUI_AUTH,
|
WEBUI_AUTH,
|
||||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
||||||
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
||||||
|
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
|
||||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||||
WEBUI_AUTH_COOKIE_SECURE,
|
WEBUI_AUTH_COOKIE_SECURE,
|
||||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||||
|
ENABLE_INITIAL_ADMIN_SIGNUP,
|
||||||
SRC_LOG_LEVELS,
|
SRC_LOG_LEVELS,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
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 open_webui.config import (
|
||||||
|
OPENID_PROVIDER_URL,
|
||||||
|
ENABLE_OAUTH_SIGNUP,
|
||||||
|
ENABLE_LDAP,
|
||||||
|
ENABLE_PASSWORD_AUTH,
|
||||||
|
)
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from open_webui.utils.misc import parse_duration, validate_email_format
|
from open_webui.utils.misc import parse_duration, validate_email_format
|
||||||
from open_webui.utils.auth import (
|
from open_webui.utils.auth import (
|
||||||
|
validate_password,
|
||||||
|
verify_password,
|
||||||
decode_token,
|
decode_token,
|
||||||
|
invalidate_token,
|
||||||
create_api_key,
|
create_api_key,
|
||||||
create_token,
|
create_token,
|
||||||
get_admin_user,
|
get_admin_user,
|
||||||
@@ -47,32 +64,46 @@ from open_webui.utils.auth import (
|
|||||||
get_http_authorization_cred,
|
get_http_authorization_cred,
|
||||||
)
|
)
|
||||||
from open_webui.utils.webhook import post_webhook
|
from open_webui.utils.webhook import post_webhook
|
||||||
from open_webui.utils.access_control import get_permissions
|
from open_webui.utils.access_control import get_permissions, has_permission
|
||||||
|
from open_webui.utils.groups import apply_default_group_assignment
|
||||||
|
|
||||||
|
from open_webui.utils.redis import get_redis_client
|
||||||
|
from open_webui.utils.rate_limit import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
from typing import Optional, List
|
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 import Server, Connection, NONE, Tls
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
from ldap3.utils.conv import escape_filter_chars
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
|
|
||||||
|
signin_rate_limiter = RateLimiter(
|
||||||
|
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
|
||||||
|
)
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# GetSessionUser
|
# GetSessionUser
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
class SessionUserResponse(Token, UserResponse):
|
class SessionUserResponse(Token, UserProfileImageResponse):
|
||||||
expires_at: Optional[int] = None
|
expires_at: Optional[int] = None
|
||||||
permissions: Optional[dict] = None
|
permissions: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=SessionUserResponse)
|
class SessionUserInfoResponse(SessionUserResponse, UserStatus):
|
||||||
|
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(
|
async def get_session_user(
|
||||||
request: Request, response: Response, user=Depends(get_current_user)
|
request: Request, response: Response, user=Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@@ -120,6 +151,12 @@ async def get_session_user(
|
|||||||
"name": user.name,
|
"name": user.name,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"profile_image_url": user.profile_image_url,
|
"profile_image_url": user.profile_image_url,
|
||||||
|
"bio": user.bio,
|
||||||
|
"gender": user.gender,
|
||||||
|
"date_of_birth": user.date_of_birth,
|
||||||
|
"status_emoji": user.status_emoji,
|
||||||
|
"status_message": user.status_message,
|
||||||
|
"status_expires_at": user.status_expires_at,
|
||||||
"permissions": user_permissions,
|
"permissions": user_permissions,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +166,14 @@ async def get_session_user(
|
|||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
@router.post("/update/profile", response_model=UserResponse)
|
@router.post("/update/profile", response_model=UserProfileImageResponse)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
form_data: UpdateProfileForm, session_user=Depends(get_verified_user)
|
form_data: UpdateProfileForm, session_user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
if session_user:
|
if session_user:
|
||||||
user = Users.update_user_by_id(
|
user = Users.update_user_by_id(
|
||||||
session_user.id,
|
session_user.id,
|
||||||
{"profile_image_url": form_data.profile_image_url, "name": form_data.name},
|
form_data.model_dump(),
|
||||||
)
|
)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
@@ -158,13 +195,19 @@ async def update_password(
|
|||||||
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
||||||
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
|
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
|
||||||
if session_user:
|
if session_user:
|
||||||
user = Auths.authenticate_user(session_user.email, form_data.password)
|
user = Auths.authenticate_user(
|
||||||
|
session_user.email, lambda pw: verify_password(form_data.password, pw)
|
||||||
|
)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
|
try:
|
||||||
|
validate_password(form_data.password)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, detail=str(e))
|
||||||
hashed = get_password_hash(form_data.new_password)
|
hashed = get_password_hash(form_data.new_password)
|
||||||
return Auths.update_user_password_by_id(user.id, hashed)
|
return Auths.update_user_password_by_id(user.id, hashed)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_PASSWORD)
|
raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||||
|
|
||||||
@@ -174,7 +217,17 @@ async def update_password(
|
|||||||
############################
|
############################
|
||||||
@router.post("/ldap", response_model=SessionUserResponse)
|
@router.post("/ldap", response_model=SessionUserResponse)
|
||||||
async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||||
ENABLE_LDAP = request.app.state.config.ENABLE_LDAP
|
# Security checks FIRST - before loading any config
|
||||||
|
if not request.app.state.config.ENABLE_LDAP:
|
||||||
|
raise HTTPException(400, detail="LDAP authentication is not enabled")
|
||||||
|
|
||||||
|
if not ENABLE_PASSWORD_AUTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOW load LDAP config variables
|
||||||
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
|
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
|
||||||
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
|
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
|
||||||
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
|
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
|
||||||
@@ -186,18 +239,18 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
|
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
|
||||||
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
|
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
|
||||||
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
|
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 = (
|
LDAP_CIPHERS = (
|
||||||
request.app.state.config.LDAP_CIPHERS
|
request.app.state.config.LDAP_CIPHERS
|
||||||
if request.app.state.config.LDAP_CIPHERS
|
if request.app.state.config.LDAP_CIPHERS
|
||||||
else "ALL"
|
else "ALL"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not ENABLE_LDAP:
|
|
||||||
raise HTTPException(400, detail="LDAP authentication is not enabled")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tls = Tls(
|
tls = Tls(
|
||||||
validate=CERT_REQUIRED,
|
validate=LDAP_VALIDATE_CERT,
|
||||||
version=PROTOCOL_TLS,
|
version=PROTOCOL_TLS,
|
||||||
ca_certs_file=LDAP_CA_CERT_FILE,
|
ca_certs_file=LDAP_CA_CERT_FILE,
|
||||||
ciphers=LDAP_CIPHERS,
|
ciphers=LDAP_CIPHERS,
|
||||||
@@ -224,14 +277,30 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
if not connection_app.bind():
|
if not connection_app.bind():
|
||||||
raise HTTPException(400, detail="Application account bind failed")
|
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_success = connection_app.search(
|
||||||
search_base=LDAP_SEARCH_BASE,
|
search_base=LDAP_SEARCH_BASE,
|
||||||
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
||||||
attributes=[
|
attributes=search_attributes,
|
||||||
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
|
|
||||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
|
|
||||||
"cn",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not search_success or not connection_app.entries:
|
if not search_success or not connection_app.entries:
|
||||||
@@ -254,6 +323,69 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
cn = str(entry["cn"])
|
cn = str(entry["cn"])
|
||||||
user_dn = entry.entry_dn
|
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():
|
if username == form_data.user.lower():
|
||||||
connection_user = Connection(
|
connection_user = Connection(
|
||||||
server,
|
server,
|
||||||
@@ -268,11 +400,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
user = Users.get_user_by_email(email)
|
user = Users.get_user_by_email(email)
|
||||||
if not user:
|
if not user:
|
||||||
try:
|
try:
|
||||||
user_count = Users.get_num_users()
|
|
||||||
|
|
||||||
role = (
|
role = (
|
||||||
"admin"
|
"admin"
|
||||||
if user_count == 0
|
if not Users.has_users()
|
||||||
else request.app.state.config.DEFAULT_USER_ROLE
|
else request.app.state.config.DEFAULT_USER_ROLE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,6 +418,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
500, detail=ERROR_MESSAGES.CREATE_USER_ERROR
|
500, detail=ERROR_MESSAGES.CREATE_USER_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
|
apply_default_group_assignment(
|
||||||
|
request.app.state.config.DEFAULT_GROUP_ID,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -296,7 +431,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
500, detail="Internal error occurred during LDAP user creation."
|
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:
|
if user:
|
||||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||||
@@ -329,6 +464,21 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
user.id, request.app.state.config.USER_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 {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
@@ -356,33 +506,53 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
|||||||
|
|
||||||
@router.post("/signin", response_model=SessionUserResponse)
|
@router.post("/signin", response_model=SessionUserResponse)
|
||||||
async def signin(request: Request, response: Response, form_data: SigninForm):
|
async def signin(request: Request, response: Response, form_data: SigninForm):
|
||||||
|
if not ENABLE_PASSWORD_AUTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
||||||
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
|
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
|
||||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
|
||||||
|
|
||||||
trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
|
email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
|
||||||
trusted_name = trusted_email
|
name = email
|
||||||
|
|
||||||
if WEBUI_AUTH_TRUSTED_NAME_HEADER:
|
if WEBUI_AUTH_TRUSTED_NAME_HEADER:
|
||||||
trusted_name = request.headers.get(
|
name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
|
||||||
WEBUI_AUTH_TRUSTED_NAME_HEADER, trusted_email
|
try:
|
||||||
)
|
name = urllib.parse.unquote(name, encoding="utf-8")
|
||||||
if not Users.get_user_by_email(trusted_email.lower()):
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not Users.get_user_by_email(email.lower()):
|
||||||
await signup(
|
await signup(
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
SignupForm(
|
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
|
||||||
email=trusted_email, password=str(uuid.uuid4()), name=trusted_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:
|
elif WEBUI_AUTH == False:
|
||||||
admin_email = "admin@localhost"
|
admin_email = "admin@localhost"
|
||||||
admin_password = "admin"
|
admin_password = "admin"
|
||||||
|
|
||||||
if Users.get_user_by_email(admin_email.lower()):
|
if Users.get_user_by_email(admin_email.lower()):
|
||||||
user = Auths.authenticate_user(admin_email.lower(), admin_password)
|
user = Auths.authenticate_user(
|
||||||
|
admin_email.lower(), lambda pw: verify_password(admin_password, pw)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if Users.get_num_users() != 0:
|
if Users.has_users():
|
||||||
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
|
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
|
||||||
|
|
||||||
await signup(
|
await signup(
|
||||||
@@ -391,9 +561,28 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
|
|||||||
SignupForm(email=admin_email, password=admin_password, name="User"),
|
SignupForm(email=admin_email, password=admin_password, name="User"),
|
||||||
)
|
)
|
||||||
|
|
||||||
user = Auths.authenticate_user(admin_email.lower(), admin_password)
|
user = Auths.authenticate_user(
|
||||||
|
admin_email.lower(), lambda pw: verify_password(admin_password, pw)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
user = Auths.authenticate_user(form_data.email.lower(), form_data.password)
|
if signin_rate_limiter.is_limited(form_data.email.lower()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
|
||||||
|
)
|
||||||
|
|
||||||
|
password_bytes = form_data.password.encode("utf-8")
|
||||||
|
if len(password_bytes) > 72:
|
||||||
|
# TODO: Implement other hashing algorithms that support longer passwords
|
||||||
|
log.info("Password too long, truncating to 72 bytes for bcrypt")
|
||||||
|
password_bytes = password_bytes[:72]
|
||||||
|
|
||||||
|
# decode safely — ignore incomplete UTF-8 sequences
|
||||||
|
form_data.password = password_bytes.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
user = Auths.authenticate_user(
|
||||||
|
form_data.email.lower(), lambda pw: verify_password(form_data.password, pw)
|
||||||
|
)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
|
|
||||||
@@ -449,22 +638,23 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
|
|||||||
|
|
||||||
@router.post("/signup", response_model=SessionUserResponse)
|
@router.post("/signup", response_model=SessionUserResponse)
|
||||||
async def signup(request: Request, response: Response, form_data: SignupForm):
|
async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||||
|
has_users = Users.has_users()
|
||||||
|
|
||||||
if WEBUI_AUTH:
|
if WEBUI_AUTH:
|
||||||
if (
|
if (
|
||||||
not request.app.state.config.ENABLE_SIGNUP
|
not request.app.state.config.ENABLE_SIGNUP
|
||||||
or not request.app.state.config.ENABLE_LOGIN_FORM
|
or not request.app.state.config.ENABLE_LOGIN_FORM
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
|
||||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
raise HTTPException(
|
||||||
)
|
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if Users.get_num_users() != 0:
|
if has_users:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
|
||||||
)
|
)
|
||||||
|
|
||||||
user_count = Users.get_num_users()
|
|
||||||
if not validate_email_format(form_data.email.lower()):
|
if not validate_email_format(form_data.email.lower()):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
|
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
|
||||||
@@ -474,22 +664,14 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
|||||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
role = (
|
try:
|
||||||
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
|
validate_password(form_data.password)
|
||||||
)
|
except Exception as e:
|
||||||
|
raise HTTPException(400, detail=str(e))
|
||||||
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)
|
hashed = get_password_hash(form_data.password)
|
||||||
|
|
||||||
|
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
|
||||||
user = Auths.insert_new_auth(
|
user = Auths.insert_new_auth(
|
||||||
form_data.email.lower(),
|
form_data.email.lower(),
|
||||||
hashed,
|
hashed,
|
||||||
@@ -526,7 +708,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.app.state.config.WEBHOOK_URL:
|
if request.app.state.config.WEBHOOK_URL:
|
||||||
post_webhook(
|
await post_webhook(
|
||||||
request.app.state.WEBUI_NAME,
|
request.app.state.WEBUI_NAME,
|
||||||
request.app.state.config.WEBHOOK_URL,
|
request.app.state.config.WEBHOOK_URL,
|
||||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||||
@@ -541,6 +723,15 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
|||||||
user.id, request.app.state.config.USER_PERMISSIONS
|
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
|
||||||
|
|
||||||
|
apply_default_group_assignment(
|
||||||
|
request.app.state.config.DEFAULT_GROUP_ID,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
@@ -561,42 +752,81 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
|||||||
|
|
||||||
@router.get("/signout")
|
@router.get("/signout")
|
||||||
async def signout(request: Request, response: Response):
|
async def signout(request: Request, response: Response):
|
||||||
response.delete_cookie("token")
|
|
||||||
|
|
||||||
if ENABLE_OAUTH_SIGNUP.value:
|
# get auth token from headers or cookies
|
||||||
oauth_id_token = request.cookies.get("oauth_id_token")
|
token = None
|
||||||
if oauth_id_token:
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header:
|
||||||
|
auth_cred = get_http_authorization_cred(auth_header)
|
||||||
|
token = auth_cred.credentials
|
||||||
|
else:
|
||||||
|
token = request.cookies.get("token")
|
||||||
|
|
||||||
|
if token:
|
||||||
|
await invalidate_token(request, token)
|
||||||
|
|
||||||
|
response.delete_cookie("token")
|
||||||
|
response.delete_cookie("oui-session")
|
||||||
|
response.delete_cookie("oauth_id_token")
|
||||||
|
|
||||||
|
oauth_session_id = request.cookies.get("oauth_session_id")
|
||||||
|
if oauth_session_id:
|
||||||
|
response.delete_cookie("oauth_session_id")
|
||||||
|
|
||||||
|
session = OAuthSessions.get_session_by_id(oauth_session_id)
|
||||||
|
oauth_server_metadata_url = (
|
||||||
|
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
|
||||||
|
if session
|
||||||
|
else None
|
||||||
|
) or OPENID_PROVIDER_URL.value
|
||||||
|
|
||||||
|
if session and oauth_server_metadata_url:
|
||||||
|
oauth_id_token = session.token.get("id_token")
|
||||||
try:
|
try:
|
||||||
async with ClientSession() as session:
|
async with ClientSession(trust_env=True) as session:
|
||||||
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
async with session.get(oauth_server_metadata_url) as r:
|
||||||
if resp.status == 200:
|
if r.status == 200:
|
||||||
openid_data = await resp.json()
|
openid_data = await r.json()
|
||||||
logout_url = openid_data.get("end_session_endpoint")
|
logout_url = openid_data.get("end_session_endpoint")
|
||||||
|
|
||||||
if logout_url:
|
if logout_url:
|
||||||
response.delete_cookie("oauth_id_token")
|
return JSONResponse(
|
||||||
return RedirectResponse(
|
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,
|
headers=response.headers,
|
||||||
url=f"{logout_url}?id_token_hint={oauth_id_token}",
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise Exception("Failed to fetch OpenID configuration")
|
||||||
status_code=resp.status,
|
|
||||||
detail="Failed to fetch OpenID configuration",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"OpenID signout error: {str(e)}")
|
log.error(f"OpenID signout error: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="Failed to sign out from the OpenID provider.",
|
detail="Failed to sign out from the OpenID provider.",
|
||||||
|
headers=response.headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
|
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
|
||||||
return RedirectResponse(
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={
|
||||||
|
"status": True,
|
||||||
|
"redirect_url": WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||||
|
},
|
||||||
headers=response.headers,
|
headers=response.headers,
|
||||||
url=WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": True}
|
return JSONResponse(
|
||||||
|
status_code=200, content={"status": True}, headers=response.headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
@@ -605,7 +835,9 @@ async def signout(request: Request, response: Response):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/add", response_model=SigninResponse)
|
@router.post("/add", response_model=SigninResponse)
|
||||||
async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
|
async def add_user(
|
||||||
|
request: Request, form_data: AddUserForm, user=Depends(get_admin_user)
|
||||||
|
):
|
||||||
if not validate_email_format(form_data.email.lower()):
|
if not validate_email_format(form_data.email.lower()):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
|
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
|
||||||
@@ -615,6 +847,11 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
|
|||||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
validate_password(form_data.password)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, detail=str(e))
|
||||||
|
|
||||||
hashed = get_password_hash(form_data.password)
|
hashed = get_password_hash(form_data.password)
|
||||||
user = Auths.insert_new_auth(
|
user = Auths.insert_new_auth(
|
||||||
form_data.email.lower(),
|
form_data.email.lower(),
|
||||||
@@ -625,6 +862,11 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
|
apply_default_group_assignment(
|
||||||
|
request.app.state.config.DEFAULT_GROUP_ID,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
token = create_token(data={"id": user.id})
|
token = create_token(data={"id": user.id})
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
@@ -686,16 +928,21 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
|||||||
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
|
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
|
||||||
"WEBUI_URL": request.app.state.config.WEBUI_URL,
|
"WEBUI_URL": request.app.state.config.WEBUI_URL,
|
||||||
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
|
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
|
||||||
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
|
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
|
||||||
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
|
"ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||||
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
|
"API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
|
||||||
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
|
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
|
||||||
|
"DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID,
|
||||||
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
|
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
|
||||||
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||||
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
|
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
|
||||||
|
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
|
||||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||||
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
|
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
|
||||||
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -703,16 +950,21 @@ class AdminConfig(BaseModel):
|
|||||||
SHOW_ADMIN_DETAILS: bool
|
SHOW_ADMIN_DETAILS: bool
|
||||||
WEBUI_URL: str
|
WEBUI_URL: str
|
||||||
ENABLE_SIGNUP: bool
|
ENABLE_SIGNUP: bool
|
||||||
ENABLE_API_KEY: bool
|
ENABLE_API_KEYS: bool
|
||||||
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
|
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool
|
||||||
API_KEY_ALLOWED_ENDPOINTS: str
|
API_KEYS_ALLOWED_ENDPOINTS: str
|
||||||
DEFAULT_USER_ROLE: str
|
DEFAULT_USER_ROLE: str
|
||||||
|
DEFAULT_GROUP_ID: str
|
||||||
JWT_EXPIRES_IN: str
|
JWT_EXPIRES_IN: str
|
||||||
ENABLE_COMMUNITY_SHARING: bool
|
ENABLE_COMMUNITY_SHARING: bool
|
||||||
ENABLE_MESSAGE_RATING: bool
|
ENABLE_MESSAGE_RATING: bool
|
||||||
|
ENABLE_FOLDERS: bool
|
||||||
ENABLE_CHANNELS: bool
|
ENABLE_CHANNELS: bool
|
||||||
ENABLE_NOTES: bool
|
ENABLE_NOTES: bool
|
||||||
ENABLE_USER_WEBHOOKS: 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")
|
@router.post("/admin/config")
|
||||||
@@ -723,20 +975,23 @@ async def update_admin_config(
|
|||||||
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
|
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
|
||||||
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
|
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
|
||||||
|
|
||||||
request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY
|
request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS
|
||||||
request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
|
request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = (
|
||||||
form_data.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
|
form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
|
||||||
)
|
)
|
||||||
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS = (
|
request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = (
|
||||||
form_data.API_KEY_ALLOWED_ENDPOINTS
|
form_data.API_KEYS_ALLOWED_ENDPOINTS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
|
||||||
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
|
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
|
||||||
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
|
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
|
||||||
|
|
||||||
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
|
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
|
||||||
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
|
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
|
||||||
|
|
||||||
|
request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID
|
||||||
|
|
||||||
pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$"
|
pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$"
|
||||||
|
|
||||||
# Check if the input string matches the pattern
|
# Check if the input string matches the pattern
|
||||||
@@ -750,20 +1005,34 @@ async def update_admin_config(
|
|||||||
|
|
||||||
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
|
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 {
|
return {
|
||||||
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
|
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
|
||||||
"WEBUI_URL": request.app.state.config.WEBUI_URL,
|
"WEBUI_URL": request.app.state.config.WEBUI_URL,
|
||||||
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
|
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
|
||||||
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
|
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
|
||||||
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
|
"ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||||
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
|
"API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
|
||||||
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
|
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
|
||||||
|
"DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID,
|
||||||
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
|
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
|
||||||
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||||
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
|
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
|
||||||
|
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
|
||||||
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
|
||||||
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
|
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
|
||||||
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -779,6 +1048,7 @@ class LdapServerConfig(BaseModel):
|
|||||||
search_filters: str = ""
|
search_filters: str = ""
|
||||||
use_tls: bool = True
|
use_tls: bool = True
|
||||||
certificate_path: Optional[str] = None
|
certificate_path: Optional[str] = None
|
||||||
|
validate_cert: bool = True
|
||||||
ciphers: Optional[str] = "ALL"
|
ciphers: Optional[str] = "ALL"
|
||||||
|
|
||||||
|
|
||||||
@@ -796,6 +1066,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
|
|||||||
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
|
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
|
||||||
"use_tls": request.app.state.config.LDAP_USE_TLS,
|
"use_tls": request.app.state.config.LDAP_USE_TLS,
|
||||||
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
|
"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,
|
"ciphers": request.app.state.config.LDAP_CIPHERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +1102,7 @@ async def update_ldap_server(
|
|||||||
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
|
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_USE_TLS = form_data.use_tls
|
||||||
request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path
|
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
|
request.app.state.config.LDAP_CIPHERS = form_data.ciphers
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -845,6 +1117,7 @@ async def update_ldap_server(
|
|||||||
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
|
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
|
||||||
"use_tls": request.app.state.config.LDAP_USE_TLS,
|
"use_tls": request.app.state.config.LDAP_USE_TLS,
|
||||||
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
|
"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,
|
"ciphers": request.app.state.config.LDAP_CIPHERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,9 +1147,11 @@ async def update_ldap_config(
|
|||||||
# create api key
|
# create api key
|
||||||
@router.post("/api_key", response_model=ApiKey)
|
@router.post("/api_key", response_model=ApiKey)
|
||||||
async def generate_api_key(request: Request, user=Depends(get_current_user)):
|
async def generate_api_key(request: Request, user=Depends(get_current_user)):
|
||||||
if not request.app.state.config.ENABLE_API_KEY:
|
if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
|
||||||
|
user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
|
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -894,8 +1169,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
|
|||||||
# delete api key
|
# delete api key
|
||||||
@router.delete("/api_key", response_model=bool)
|
@router.delete("/api_key", response_model=bool)
|
||||||
async def delete_api_key(user=Depends(get_current_user)):
|
async def delete_api_key(user=Depends(get_current_user)):
|
||||||
success = Users.update_user_api_key_by_id(user.id, None)
|
return Users.delete_user_api_key_by_id(user.id)
|
||||||
return success
|
|
||||||
|
|
||||||
|
|
||||||
# get api key
|
# get api key
|
||||||
|
|||||||
+1014
-141
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user