Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7fe44328 | ||
|
|
15b5f97f89 | ||
|
|
ca0983f76b | ||
|
|
ef04a704ce | ||
|
|
5fda814669 | ||
|
|
c26e8110af | ||
|
|
9e85055b8b | ||
|
|
6d17de6c67 | ||
|
|
f4e99c80f6 | ||
|
|
09dc28df1e | ||
|
|
c748c3ede7 | ||
|
|
33308022f0 | ||
|
|
f96e8f04fc | ||
|
|
38ae91ae23 | ||
|
|
88401e91c7 | ||
|
|
8c5cfa530d | ||
|
|
d215e46315 | ||
|
|
e10e7d056e | ||
|
|
7a7d902238 | ||
|
|
24179cde2f | ||
|
|
3ae4c618e1 | ||
|
|
4a0d893995 | ||
|
|
b780d5c556 | ||
|
|
911eecac85 | ||
|
|
319d3e8856 | ||
|
|
e1b3e7252c | ||
|
|
f20cc6d7e6 | ||
|
|
f1a1e64d2e | ||
|
|
58e923fe00 | ||
|
|
f2aca781c8 | ||
|
|
ce51c481b8 | ||
|
|
9a2595f070 | ||
|
|
393c0071dc | ||
|
|
7e224e4a53 | ||
|
|
883f1dda0f | ||
|
|
12bad452fa | ||
|
|
5de60dc922 | ||
|
|
a30b106ea3 | ||
|
|
d33ad462aa | ||
|
|
3b61562c82 | ||
|
|
e5d88be4f3 | ||
|
|
b36f8d9314 | ||
|
|
626d236d13 | ||
|
|
79ecbfc757 | ||
|
|
a9b8677cc0 | ||
|
|
0f3f68b0c4 | ||
|
|
abc9b63093 | ||
|
|
64fa26bd28 | ||
|
|
2487c84f1f | ||
|
|
163211a367 | ||
|
|
f027a01ab2 | ||
|
|
370a677a38 | ||
|
|
d1d1efe212 | ||
|
|
b7549d2f6c | ||
|
|
589c4e64c1 | ||
|
|
20de5a87da | ||
|
|
ca6b18ab5c | ||
|
|
97a3b1528d | ||
|
|
d01b1d4880 | ||
|
|
df6e38039f | ||
|
|
b4c3f54f96 | ||
|
|
73776d54b8 | ||
|
|
7bda6bf767 | ||
|
|
ddcec9842f | ||
|
|
4d5b7b3014 | ||
|
|
9886ebb97f | ||
|
|
0b05b2fc7e | ||
|
|
49e7eade15 | ||
|
|
7a7a25766c | ||
|
|
9fc1658085 | ||
|
|
5297dceb2a | ||
|
|
59afbd6f92 | ||
|
|
f7af3f010e | ||
|
|
bb40724d45 | ||
|
|
0e64b31adb | ||
|
|
850a864b02 | ||
|
|
9468d92553 | ||
|
|
c3dc5d5984 | ||
|
|
5291b3dca2 | ||
|
|
87d0c112fa | ||
|
|
2a11175f22 | ||
|
|
3238d94a0e | ||
|
|
8919d8a82a | ||
|
|
ea4ef28da5 | ||
|
|
2ffd8d9277 | ||
|
|
e8499ccdd1 | ||
|
|
0dcbd05e24 | ||
|
|
c653e4ec54 | ||
|
|
8cf32ae2a7 | ||
|
|
c6af296b60 | ||
|
|
531ac70ce5 | ||
|
|
423d8b1817 | ||
|
|
633505460a | ||
|
|
c8bf390680 | ||
|
|
da46c1bbd2 | ||
|
|
05ae44b98d | ||
|
|
a40808579f | ||
|
|
4d024c91d6 | ||
|
|
ccb71a7322 | ||
|
|
efe5416f83 | ||
|
|
a4281f6a7f | ||
|
|
2372b70031 | ||
|
|
d02e826c9d | ||
|
|
dddac2b0ca | ||
|
|
0da57149ae | ||
|
|
0bebb260bf | ||
|
|
390d7663b0 | ||
|
|
ce3a615442 | ||
|
|
4e0cb88583 | ||
|
|
9b30e8f689 | ||
|
|
97331bf11d | ||
|
|
96c07f44a8 | ||
|
|
60ada21c15 | ||
|
|
ab20745ee5 | ||
|
|
f376d4f378 | ||
|
|
89fddcc741 | ||
|
|
773787c74c | ||
|
|
5c1c9a4dcb | ||
|
|
9b925a115a | ||
|
|
e5035ea31e | ||
|
|
c8cbdc8f7f | ||
|
|
64c37ab968 | ||
|
|
f7c5965a70 | ||
|
|
46aa54b7dc | ||
|
|
27944cf7ca | ||
|
|
1973115678 | ||
|
|
a38ad8fc42 | ||
|
|
c2207887b3 | ||
|
|
3fabc085cc | ||
|
|
2f584c9f88 | ||
|
|
ba18d6250a | ||
|
|
4331029926 | ||
|
|
3e56261c5e | ||
|
|
30f72672fa | ||
|
|
4aedfdc547 | ||
|
|
e3a8257690 | ||
|
|
a73cdf4288 | ||
|
|
c259c87806 | ||
|
|
0044902c08 | ||
|
|
cd31b8301b | ||
|
|
8fd5c06e5b | ||
|
|
97afe3bc58 | ||
|
|
3567054325 | ||
|
|
f236192fe1 | ||
|
|
9b1fd86aa7 | ||
|
|
55169e69c0 | ||
|
|
e3e4e1d9d3 | ||
|
|
c2f5cb542e | ||
|
|
1034b74abd | ||
|
|
0a44d80252 | ||
|
|
48a0abb40f | ||
|
|
68c77295bd | ||
|
|
3c7f9aa6a4 | ||
|
|
f7406ff576 | ||
|
|
65904b867e | ||
|
|
e2d09ac361 | ||
|
|
3ae44d11a5 | ||
|
|
aa8c2959ca | ||
|
|
b147616080 | ||
|
|
9747b07ca5 | ||
|
|
0f78451c2b | ||
|
|
42763cbbd8 | ||
|
|
d193c143a5 | ||
|
|
26460917c4 | ||
|
|
fd97ae9bd7 | ||
|
|
c3fa0f30fe | ||
|
|
4852227158 | ||
|
|
9be85b6d3c | ||
|
|
59b98ab730 | ||
|
|
690686f3c7 | ||
|
|
691a04f0dd | ||
|
|
4fa6123c04 | ||
|
|
494cf8b3ef | ||
|
|
73bb600034 | ||
|
|
9cf4d34832 | ||
|
|
284b97bd84 | ||
|
|
7e79f8d1c6 | ||
|
|
258454276e | ||
|
|
938d1b0743 | ||
|
|
b1737040a7 | ||
|
|
26286625f4 | ||
|
|
a214ec40ea | ||
|
|
2c37daef86 | ||
|
|
8e79b3d0bc | ||
|
|
6c0f886cdf | ||
|
|
225036863f | ||
|
|
cac5dd12e9 | ||
|
|
f751c0b46c | ||
|
|
9ed8f50d40 | ||
|
|
d3f2cf7474 | ||
|
|
62750b8980 | ||
|
|
e62649f940 | ||
|
|
0e60c757ce | ||
|
|
68a1e87b66 | ||
|
|
e8a36f033b | ||
|
|
2cf2565e80 | ||
|
|
5669d1062c | ||
|
|
3ace75820e | ||
|
|
020cb0d4bf | ||
|
|
8b75d34a8a | ||
|
|
6320a9aaa9 | ||
|
|
6cd35b185d | ||
|
|
a1ea854b38 | ||
|
|
405dc26cc6 | ||
|
|
fe681abd33 | ||
|
|
43c68468f7 | ||
|
|
ecf3fa2feb | ||
|
|
4aacaeb9b8 | ||
|
|
afc56b9746 | ||
|
|
0a61666197 | ||
|
|
a9e0462e57 | ||
|
|
a2b9986a75 | ||
|
|
654172d757 | ||
|
|
527d48efa9 | ||
|
|
cda08aaed4 | ||
|
|
cfd30581d5 | ||
|
|
3c0313f41b | ||
|
|
d938eb0e76 | ||
|
|
60f2f8c1c4 | ||
|
|
b0c5f7b668 | ||
|
|
767343dc5b | ||
|
|
c22bb4f853 | ||
|
|
117c091b95 | ||
|
|
6719558150 | ||
|
|
6ffce4bccd | ||
|
|
ea9c58ea80 | ||
|
|
b2c2f1bd49 | ||
|
|
679e56c494 | ||
|
|
3da4323ef3 | ||
|
|
75e5a485d2 | ||
|
|
7bb3a827bb | ||
|
|
96f106319e | ||
|
|
a4ad34841b | ||
|
|
599cd2eeeb | ||
|
|
ee5fd1246c | ||
|
|
1441d0d735 | ||
|
|
e5dbfc420d | ||
|
|
643c661a6f | ||
|
|
ab5dfbda54 | ||
|
|
94302de49b | ||
|
|
45d7486485 | ||
|
|
ee27fd8de1 | ||
|
|
89f154630f | ||
|
|
a6ed0ef9f4 | ||
|
|
aac98120c8 | ||
|
|
44e36e5b0d | ||
|
|
f9ab66f51a | ||
|
|
ce8ed5b5ec | ||
|
|
baef422a28 | ||
|
|
68e257849d | ||
|
|
e686554392 | ||
|
|
96a9696383 | ||
|
|
fa84ff5e12 | ||
|
|
66359d5815 | ||
|
|
c111fa0837 | ||
|
|
6e182940e2 | ||
|
|
bc90463ea6 | ||
|
|
93ed4ae2cd | ||
|
|
a10ac774ab | ||
|
|
26a5d8f75d | ||
|
|
5749f78262 | ||
|
|
1eaae9d934 | ||
|
|
567b0776cd | ||
|
|
72f330133a | ||
|
|
665f95eda3 | ||
|
|
8e2b0b6fd2 | ||
|
|
ce50d9bac4 | ||
|
|
934bebd8cd | ||
|
|
171940869b | ||
|
|
33020d826f | ||
|
|
2c12278444 | ||
|
|
57a2024c58 | ||
|
|
d67fe02263 | ||
|
|
57ec2aa088 | ||
|
|
fa859de460 | ||
|
|
36766f157d | ||
|
|
683438b418 | ||
|
|
4a55167759 | ||
|
|
c5c4aef7b1 | ||
|
|
533c7b27eb | ||
|
|
82af218790 | ||
|
|
b272ca5e88 | ||
|
|
10ba2accf7 | ||
|
|
4c8d4e6dbd | ||
|
|
6359628bc3 | ||
|
|
25fd342261 | ||
|
|
f199c486a2 | ||
|
|
1f205a8441 | ||
|
|
b1d5b3b28e | ||
|
|
32810b4152 | ||
|
|
b7e9992d78 | ||
|
|
6c76983999 | ||
|
|
8bf46dcc5d | ||
|
|
5510fa178e | ||
|
|
5ad593e465 | ||
|
|
dff0141160 | ||
|
|
44da9c6523 | ||
|
|
6ab7d54982 | ||
|
|
0c79a566ac | ||
|
|
34773e795b | ||
|
|
66daa15722 | ||
|
|
db80dd2692 | ||
|
|
0dc74a8a2e | ||
|
|
90a057f400 | ||
|
|
78f856e204 | ||
|
|
d2c695eb11 | ||
|
|
46cf40ec82 | ||
|
|
655420fd25 | ||
|
|
52c73390f8 | ||
|
|
c46ef3b63b | ||
|
|
443908b14c | ||
|
|
3bec320bb9 | ||
|
|
fa6f238777 | ||
|
|
86e6b2b68b | ||
|
|
14e51e0977 | ||
|
|
4c6f100b5f | ||
|
|
5a0488bb18 | ||
|
|
9af40624c5 | ||
|
|
0df561c33c | ||
|
|
c7f996d593 | ||
|
|
907dba4517 | ||
|
|
9e5d6069fe | ||
|
|
14f6747dfc | ||
|
|
68b2872ed6 | ||
|
|
1a4bdd2b30 | ||
|
|
886c12c566 | ||
|
|
a3600e8b21 | ||
|
|
5d48e48e15 | ||
|
|
474427c67e | ||
|
|
00b3583dc2 | ||
|
|
4d9a7cc6c0 | ||
|
|
509bd2bebb | ||
|
|
8c70453b2e | ||
|
|
8eebc2aea6 | ||
|
|
a9a0ce6bea | ||
|
|
ecbdef732b | ||
|
|
4615e8f92b | ||
|
|
91faa9fd5a | ||
|
|
85e92fe3b0 | ||
|
|
38bf0b6eec | ||
|
|
6ae3ddd66b | ||
|
|
98cb2d3411 | ||
|
|
be75bc506a | ||
|
|
7a42efec53 | ||
|
|
e9926694c3 | ||
|
|
5cfb7a08cb | ||
|
|
9d642f6354 | ||
|
|
716f2986b9 | ||
|
|
409f565f09 | ||
|
|
26e95f2a92 | ||
|
|
711a2cd738 | ||
|
|
1c1f72f05c | ||
|
|
1d343aeae4 | ||
|
|
e26f6acc3b | ||
|
|
1555252c4a | ||
|
|
de0cbb9073 | ||
|
|
5a075a2c83 | ||
|
|
7da37b4f66 | ||
|
|
6f80cb6b65 | ||
|
|
9617df04ae | ||
|
|
01d5f42755 | ||
|
|
84d76cccde | ||
|
|
af584b46f4 | ||
|
|
0fb4cceec1 | ||
|
|
1dc353433a | ||
|
|
33e8a09880 | ||
|
|
1cb751d184 | ||
|
|
9e596f8616 | ||
|
|
24044b42ea | ||
|
|
84263fc6a6 | ||
|
|
2faab409d3 | ||
|
|
0b5aa6dd60 | ||
|
|
d0c2bfdbff | ||
|
|
242625782f | ||
|
|
826e9ab317 | ||
|
|
182d5e8591 | ||
|
|
3fc866117d | ||
|
|
b464b48f53 | ||
|
|
2b26355002 | ||
|
|
d81a36310c | ||
|
|
d56bb2c383 | ||
|
|
2dd09223f2 | ||
|
|
0c369d195b | ||
|
|
ab99d3b112 | ||
|
|
3f133fad56 | ||
|
|
41d1ccd39c | ||
|
|
7839d043ff | ||
|
|
9b9e6ce2ab | ||
|
|
81510e9d8f | ||
|
|
c0ff925c2a | ||
|
|
2da661fed1 | ||
|
|
59b128bbda | ||
|
|
f2a360cb87 | ||
|
|
8deef788c1 | ||
|
|
f9b0534e0c | ||
|
|
c4de5ea50c | ||
|
|
8646aebaab | ||
|
|
6adde203cd | ||
|
|
5990c51ab5 | ||
|
|
3c986adeda | ||
|
|
eff772562b | ||
|
|
7bb11f9ef0 | ||
|
|
592a6213b9 | ||
|
|
db02d802a7 | ||
|
|
7a7a0c423b | ||
|
|
74c4af6e11 | ||
|
|
9496e8f7b5 | ||
|
|
636ba171a4 | ||
|
|
f4d99135e4 | ||
|
|
a7b4b6e51a | ||
|
|
83dce80e30 | ||
|
|
869108a3e1 | ||
|
|
bb6188abf0 | ||
|
|
401c1949a0 | ||
|
|
10838b3654 | ||
|
|
3a57233dd4 | ||
|
|
daccf0713e | ||
|
|
1138929f4d | ||
|
|
9a9a824fe9 | ||
|
|
b2a1f71d92 | ||
|
|
ffbd6ec7f2 | ||
|
|
15934d208e | ||
|
|
b377e5ff4c | ||
|
|
924af3d908 | ||
|
|
9e9616b670 | ||
|
|
00837d8f99 | ||
|
|
cd296fcf0d | ||
|
|
48bdb3f266 | ||
|
|
ef9cd0e0ad | ||
|
|
8e7ebdbf63 | ||
|
|
9451b13dc6 | ||
|
|
9223efaff0 | ||
|
|
7aee9c4783 | ||
|
|
c1147578c0 | ||
|
|
9b06fdc8fe | ||
|
|
a9a979fb3d | ||
|
|
4b4743b497 | ||
|
|
e7207c8f55 | ||
|
|
ae56bca7c5 | ||
|
|
700349064d | ||
|
|
c417fdd94d | ||
|
|
b6cef30bfc | ||
|
|
aa084f804d | ||
|
|
e67891a374 | ||
|
|
b73d30b6df | ||
|
|
48f1b2d547 | ||
|
|
b8fb9e815b | ||
|
|
74974334a5 | ||
|
|
084f0ef6a5 | ||
|
|
0e1edc4c01 | ||
|
|
0654df7bdb | ||
|
|
35d385e9cc | ||
|
|
ab400e3eae | ||
|
|
961136413f | ||
|
|
c8622adcb0 | ||
|
|
2789f6a24d | ||
|
|
60e916d6c0 | ||
|
|
502813c6c8 | ||
|
|
732d9b484d | ||
|
|
72698a0465 | ||
|
|
cc046695e6 | ||
|
|
17490381f5 | ||
|
|
87ebbbe2f4 | ||
|
|
1148d1c927 | ||
|
|
927a765641 | ||
|
|
1cfbb823ec | ||
|
|
c87031e9a6 | ||
|
|
5921a19519 | ||
|
|
2543252bb0 | ||
|
|
619f260466 | ||
|
|
119fc21257 | ||
|
|
40c45ffe1f | ||
|
|
4cf901f519 | ||
|
|
de9cff6b4e | ||
|
|
713a65ee31 | ||
|
|
cd5a38a694 | ||
|
|
8ef0f7743b | ||
|
|
8d568cc8c5 | ||
|
|
f5455d48c4 | ||
|
|
ab7b97e21f | ||
|
|
b1d30673b6 | ||
|
|
bb821ab654 | ||
|
|
460ab07f39 | ||
|
|
1d08376860 | ||
|
|
d3ab9f4b96 | ||
|
|
9c5773d0af | ||
|
|
00af37bb4e | ||
|
|
a3270648d8 | ||
|
|
646835d767 | ||
|
|
e27fb3e291 | ||
|
|
3f577c0c3f | ||
|
|
ca514cd3ed | ||
|
|
6b5643c786 | ||
|
|
5c1d52231a | ||
|
|
1f059fe730 | ||
|
|
b55a46ae99 | ||
|
|
2bb13d5dbc | ||
|
|
614cb56420 | ||
|
|
e754940c03 | ||
|
|
dc2c2f2295 | ||
|
|
c324359580 | ||
|
|
e55bf2c2ac | ||
|
|
bd07ef87ab | ||
|
|
1c82506bfd | ||
|
|
a2f8e41fbc | ||
|
|
8712446cc1 | ||
|
|
bf42535d12 | ||
|
|
cfecca8eda | ||
|
|
ccd3295a5d | ||
|
|
f0829ba6e6 | ||
|
|
e4a5b06ca6 | ||
|
|
d3ee3fd23e | ||
|
|
89565c58c6 | ||
|
|
f981843852 | ||
|
|
c144122f60 | ||
|
|
b67796465e | ||
|
|
a1036e544d | ||
|
|
4eadf84e1f | ||
|
|
f7f8a263b9 | ||
|
|
6c7f966f2a | ||
|
|
89ad1c68d1 | ||
|
|
b619a157bc | ||
|
|
fdae5644e3 | ||
|
|
bf2b296239 | ||
|
|
dfc5dad631 | ||
|
|
431632d530 | ||
|
|
4f918e7aa4 | ||
|
|
8b3a4c1e2f | ||
|
|
71ca25c8ac | ||
|
|
6c203fc7df | ||
|
|
0bd295b10b | ||
|
|
252a983091 | ||
|
|
5a556b00c3 | ||
|
|
9195d7aeb0 | ||
|
|
f4670b3add | ||
|
|
048692c068 | ||
|
|
b91e8b73ab | ||
|
|
201c38a08a | ||
|
|
46f867cda6 | ||
|
|
6d087202ad | ||
|
|
07cbc52c9a | ||
|
|
2c0ebbcced | ||
|
|
a2e9263af0 | ||
|
|
6351077958 | ||
|
|
2acee80829 | ||
|
|
450421b189 | ||
|
|
c2c389f722 | ||
|
|
b997d00947 | ||
|
|
fe84afd09a | ||
|
|
08bf4670ec | ||
|
|
18a33a079b | ||
|
|
d3a682759f | ||
|
|
61e25dc2dc | ||
|
|
697e94e935 | ||
|
|
935808f5ea | ||
|
|
464846d4a3 | ||
|
|
95ce70c4fd | ||
|
|
214dd50ce9 | ||
|
|
4750a7cab0 | ||
|
|
3b6601631b | ||
|
|
d5ccd95293 | ||
|
|
b3371033be | ||
|
|
aaea9a5956 | ||
|
|
c02451178c | ||
|
|
fe3047d53c | ||
|
|
5649a668fa | ||
|
|
2453b75ff0 | ||
|
|
5d1459df16 | ||
|
|
88dbc14abc | ||
|
|
ca176c5c03 | ||
|
|
87e8d39a90 | ||
|
|
9a9b5ef699 | ||
|
|
3994d88c90 | ||
|
|
cc602abcb2 | ||
|
|
267cc14d2d | ||
|
|
26af1f92e2 | ||
|
|
475dd91ed7 | ||
|
|
9405628e46 | ||
|
|
93db0a4e57 | ||
|
|
b1d0f00d8c | ||
|
|
6dd0f99b90 | ||
|
|
eb4b085b86 | ||
|
|
c79fb7db74 | ||
|
|
145c7516f2 | ||
|
|
2041ab483e | ||
|
|
d4de26bd05 | ||
|
|
7b6f8b49e0 | ||
|
|
37b15604de | ||
|
|
a9609585af | ||
|
|
ac0ae2ae20 | ||
|
|
3b0d25ad2b | ||
|
|
adf463059d | ||
|
|
5f20102b33 | ||
|
|
d08f9ed556 | ||
|
|
25db8225f8 | ||
|
|
9c2f5148d9 | ||
|
|
d29271184e | ||
|
|
f5c07e317e | ||
|
|
39a2b9789c | ||
|
|
51dd488417 | ||
|
|
6c14ba1fb4 | ||
|
|
44faf00fac | ||
|
|
4a17a7c20a | ||
|
|
459cfde1f8 | ||
|
|
85bbed3ec5 | ||
|
|
9c61e95ecb | ||
|
|
df106099a1 | ||
|
|
4be99174be | ||
|
|
c0ec04935b | ||
|
|
2e9e46a9f1 | ||
|
|
5995771583 | ||
|
|
758325a203 | ||
|
|
9824f0e333 | ||
|
|
117b1bfa65 | ||
|
|
e9706cb1e5 | ||
|
|
769110f290 | ||
|
|
d053600096 | ||
|
|
42b6cf32bc | ||
|
|
8f37a1b721 | ||
|
|
a7271532f8 | ||
|
|
5088583721 | ||
|
|
0a78323406 | ||
|
|
423983f5a7 | ||
|
|
4ab917c74b | ||
|
|
7f0ca8c4b7 | ||
|
|
5c5f87a63c | ||
|
|
def8a0027d | ||
|
|
d95f533214 | ||
|
|
de97f797ce | ||
|
|
f0a04427e2 | ||
|
|
088f62f1e1 | ||
|
|
cfa8b3a396 | ||
|
|
a8bb000616 | ||
|
|
837f7022d5 | ||
|
|
67fd2ec759 | ||
|
|
baf0f51827 | ||
|
|
f7d5750210 | ||
|
|
446cc0ac60 | ||
|
|
01e88c6ac2 | ||
|
|
45e3237756 | ||
|
|
bd7f1869ce | ||
|
|
f1bf4f20c5 | ||
|
|
a3458f492c | ||
|
|
96ec66a89c | ||
|
|
a1155dfeb7 | ||
|
|
fa924946b0 | ||
|
|
b35aeb8f46 | ||
|
|
60c93b4ccc | ||
|
|
ef43e81f9a | ||
|
|
f826d3ed75 | ||
|
|
b3904b6ecb | ||
|
|
c96549eaa7 | ||
|
|
7746e9f4b8 | ||
|
|
4fd790f7dd | ||
|
|
ae203d8952 | ||
|
|
48ccb1e170 | ||
|
|
5077676d33 | ||
|
|
0dd2cfe1f2 | ||
|
|
53c1ca64b7 | ||
|
|
870e29e373 | ||
|
|
7e4848e10b | ||
|
|
6993b0b40b | ||
|
|
360289db21 | ||
|
|
0eeda79048 | ||
|
|
8eddff83cb | ||
|
|
28b2fcab0c | ||
|
|
0b0962a2ae | ||
|
|
90c0c5da00 | ||
|
|
fe653a1336 | ||
|
|
4c2e5c93e9 | ||
|
|
d4bd938a77 | ||
|
|
37085ed42b | ||
|
|
2e7c7d635d | ||
|
|
b0d8372d31 | ||
|
|
afaa404fe4 | ||
|
|
8890555dab | ||
|
|
b5bd8704fe | ||
|
|
823b9a6dd9 | ||
|
|
d65116282c | ||
|
|
e61724d2b1 | ||
|
|
cd170735c5 | ||
|
|
3d5aaa9ead | ||
|
|
149bb9dae2 | ||
|
|
ed8bb028a0 | ||
|
|
ffbd774d32 | ||
|
|
05a3aae59c | ||
|
|
f509f5542d | ||
|
|
0eb33e8e12 | ||
|
|
59d6eb2bad | ||
|
|
7bd43f00b1 | ||
|
|
cad4c61a25 | ||
|
|
dce52f33c0 | ||
|
|
0413c973e7 | ||
|
|
bc681f8258 | ||
|
|
195801f74a | ||
|
|
07d28ad781 | ||
|
|
8f7f5db1fd | ||
|
|
06038cd73d | ||
|
|
e11c6dca75 | ||
|
|
a1bde74e97 | ||
|
|
1c62be4406 | ||
|
|
67290e1c2a | ||
|
|
64dbc71643 | ||
|
|
90209a30a8 | ||
|
|
18e6cfb1fd | ||
|
|
8e661a4e73 | ||
|
|
379f888c9d | ||
|
|
4c4b9d19a1 | ||
|
|
7364b67455 | ||
|
|
3418f53d07 | ||
|
|
3b3e12b43a | ||
|
|
4d9a51ba33 | ||
|
|
4b4241273d | ||
|
|
db95e96688 | ||
|
|
99c820d607 | ||
|
|
282c541427 | ||
|
|
b364cf43d3 | ||
|
|
b9676cf36f | ||
|
|
258caaeced | ||
|
|
6e99b10163 | ||
|
|
a2a9a9bcf4 | ||
|
|
0addc1ea46 | ||
|
|
6812d3b9d1 | ||
|
|
ceae3d48e6 | ||
|
|
3ed1df2e53 | ||
|
|
68219d84a9 | ||
|
|
6068e23590 | ||
|
|
d7467a86e2 | ||
|
|
d098c57d4d | ||
|
|
693636d971 | ||
|
|
a6ef82c5ed | ||
|
|
79cfe29bb2 | ||
|
|
d1d42128e5 | ||
|
|
2bccf8350d | ||
|
|
c15201620d | ||
|
|
f31ca75892 | ||
|
|
a7993f6f4e | ||
|
|
ae47101dc6 | ||
|
|
cf6a1300ca | ||
|
|
a934dc997e | ||
|
|
ed2db0d04b | ||
|
|
4ecacda28c | ||
|
|
94a8439105 | ||
|
|
7b0b16ebbd | ||
|
|
49d54c5821 | ||
|
|
0eafc09965 | ||
|
|
6a75620fcb | ||
|
|
205c711120 | ||
|
|
3af96c9d4e | ||
|
|
6e0badde67 | ||
|
|
b29e7fd0be | ||
|
|
02df867843 | ||
|
|
00c2b6ca40 | ||
|
|
65d4b22c7c | ||
|
|
a4fe823893 | ||
|
|
103ff0c5e4 | ||
|
|
4363df175d | ||
|
|
307b37d5e2 | ||
|
|
9b24cddef6 | ||
|
|
1ea555a5ac | ||
|
|
c24b1207a0 | ||
|
|
44e41806f2 | ||
|
|
2b1a29d44b | ||
|
|
f5fbbaf060 | ||
|
|
ba158d378f | ||
|
|
b02397e460 | ||
|
|
bcd50ed8f1 | ||
|
|
8cea0cf746 | ||
|
|
ce945a9334 | ||
|
|
3c8f1cf8e5 | ||
|
|
4d4ed743ae | ||
|
|
aa9c0389c3 | ||
|
|
492c8bac09 | ||
|
|
0617ede0eb | ||
|
|
7e6b8a9a71 | ||
|
|
dcdbafa5ae | ||
|
|
cdd75ade50 | ||
|
|
508fde70ff | ||
|
|
2a8a2f1ba3 | ||
|
|
d90d036d23 | ||
|
|
2f68ac33b3 | ||
|
|
01284b92db | ||
|
|
1779090bdb | ||
|
|
d27e019af3 | ||
|
|
5c2df97f04 | ||
|
|
6fe737bf8f | ||
|
|
7b126b23d5 | ||
|
|
22f1b764a7 | ||
|
|
39f778c275 | ||
|
|
54b7ec56d6 | ||
|
|
133618aaf0 | ||
|
|
b560775713 | ||
|
|
964333ed65 | ||
|
|
85b1e21765 | ||
|
|
c86fcb75b7 | ||
|
|
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 | ||
|
|
4bc054a347 | ||
|
|
d9f2b6b14e | ||
|
|
5889e8238a | ||
|
|
3345514efc | ||
|
|
fd735590da | ||
|
|
cd554ebce5 | ||
|
|
4a6d333a03 | ||
|
|
41faec758b | ||
|
|
6469822af1 | ||
|
|
519b7af6d8 | ||
|
|
1d9c1f741d | ||
|
|
c9fda793e2 | ||
|
|
39bcee3f7b | ||
|
|
f4d54c518e | ||
|
|
54d606eabf | ||
|
|
a8a8fd537f | ||
|
|
46e0992a83 | ||
|
|
39e4551a45 | ||
|
|
e0afd7f496 | ||
|
|
12ebdbae81 | ||
|
|
25e241ae41 | ||
|
|
b56dbb26be | ||
|
|
3e2fd074bb | ||
|
|
bb09245792 | ||
|
|
6dd969129d | ||
|
|
4f982e244f |
No files matched your search
@@ -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
|
||||||
|
|
||||||
@@ -126,6 +134,7 @@ body:
|
|||||||
description: |
|
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.
|
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 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: |
|
||||||
Example (include every detail):
|
Example (include every detail):
|
||||||
@@ -163,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,28 @@
|
|||||||
|
<!--
|
||||||
|
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||||
|
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||||
|
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||||
|
-->
|
||||||
|
|
||||||
# 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. **PRs targeting `main` will be immediately closed.**
|
||||||
- [ ] **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:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
|
||||||
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
|
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
|
||||||
- [ ] **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**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them 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:
|
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
|
||||||
|
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
|
||||||
|
- [ ] **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 +84,13 @@
|
|||||||
|
|
||||||
### 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.
|
<!--
|
||||||
|
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||||
|
Keep the "Contributor License Agreement" confirmation text intact.
|
||||||
|
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||||
|
-->
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -57,7 +57,8 @@ jobs:
|
|||||||
git lfs install
|
git lfs install
|
||||||
git lfs track "*.ttf"
|
git lfs track "*.ttf"
|
||||||
git lfs track "*.jpg"
|
git lfs track "*.jpg"
|
||||||
rm demo.gif
|
rm demo.png
|
||||||
|
rm banner.png
|
||||||
git add .
|
git add .
|
||||||
git commit -m "GitHub deploy: ${{ github.sha }}"
|
git commit -m "GitHub deploy: ${{ github.sha }}"
|
||||||
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main
|
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main
|
||||||
@@ -43,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
|
||||||
@@ -141,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
|
||||||
@@ -243,8 +246,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
|
||||||
@@ -347,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
|
||||||
@@ -419,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]
|
||||||
@@ -433,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
|
||||||
@@ -487,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
|
||||||
@@ -543,7 +651,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-cuda126-*
|
pattern: digests-cuda126-*
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
@@ -599,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
|
||||||
@@ -640,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,10 +24,10 @@ 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'
|
||||||
|
|
||||||
@@ -51,10 +51,10 @@ 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'
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +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
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install Git
|
- name: Install Git
|
||||||
run: sudo apt-get update && sudo apt-get install -y git
|
run: sudo apt-get update && sudo apt-get install -y git
|
||||||
- uses: actions/setup-node@v4
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ pnpm-lock.yaml
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
kubernetes/
|
|
||||||
|
|
||||||
# Copy of .gitignore
|
# Copy of .gitignore
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
+1375
File diff suppressed because it is too large.
Load diff
+53
-29
@@ -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
|
||||||
@@ -11,6 +13,7 @@ ARG USE_CUDA_VER=cu128
|
|||||||
# IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them.
|
# IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them.
|
||||||
ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
ARG USE_RERANKING_MODEL=""
|
ARG USE_RERANKING_MODEL=""
|
||||||
|
ARG USE_AUXILIARY_EMBEDDING_MODEL=TaylorAI/bge-micro-v2
|
||||||
|
|
||||||
# Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken
|
# Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken
|
||||||
ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base"
|
ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base"
|
||||||
@@ -24,6 +27,9 @@ 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
|
# to store git revision in build
|
||||||
@@ -37,26 +43,34 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
######## WebUI backend ########
|
######## WebUI backend ########
|
||||||
FROM python:3.11-slim-bookworm AS base
|
FROM python:3.11.14-slim-bookworm AS base
|
||||||
|
|
||||||
# Use args
|
# Use args
|
||||||
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 USE_AUXILIARY_EMBEDDING_MODEL
|
||||||
ARG UID
|
ARG UID
|
||||||
ARG GID
|
ARG GID
|
||||||
|
|
||||||
|
# Python settings
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
## Basis ##
|
## Basis ##
|
||||||
ENV ENV=prod \
|
ENV ENV=prod \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
# 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} \
|
||||||
|
USE_AUXILIARY_EMBEDDING_MODEL_DOCKER=${USE_AUXILIARY_EMBEDDING_MODEL}
|
||||||
|
|
||||||
## Basis URL Config ##
|
## Basis URL Config ##
|
||||||
ENV OLLAMA_BASE_URL="/ollama" \
|
ENV OLLAMA_BASE_URL="/ollama" \
|
||||||
@@ -77,6 +91,7 @@ ENV WHISPER_MODEL="base" \
|
|||||||
## RAG Embedding model settings ##
|
## RAG Embedding model settings ##
|
||||||
ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \
|
ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \
|
||||||
RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \
|
RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \
|
||||||
|
AUXILIARY_EMBEDDING_MODEL="$USE_AUXILIARY_EMBEDDING_MODEL_DOCKER" \
|
||||||
SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
|
SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
|
||||||
|
|
||||||
## Tiktoken model settings ##
|
## Tiktoken model settings ##
|
||||||
@@ -108,29 +123,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 zstd \
|
||||||
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
|
||||||
@@ -138,21 +137,35 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
|||||||
RUN pip3 install --no-cache-dir uv && \
|
RUN pip3 install --no-cache-dir uv && \
|
||||||
if [ "$USE_CUDA" = "true" ]; then \
|
if [ "$USE_CUDA" = "true" ]; then \
|
||||||
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
||||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
|
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
|
||||||
|
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
|
||||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||||
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 sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), 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'])"; \
|
||||||
|
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||||
else \
|
else \
|
||||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
pip3 install 'torch<=2.9.1' 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 sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), 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'])"; \
|
||||||
|
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||||
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
|
||||||
@@ -170,6 +183,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
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
### Installing Both Ollama and Open WebUI Using Kustomize
|
|
||||||
|
|
||||||
For cpu-only pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f ./kubernetes/manifest/base
|
|
||||||
```
|
|
||||||
|
|
||||||
For gpu-enabled pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -k ./kubernetes/manifest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Installing Both Ollama and Open WebUI Using Helm
|
|
||||||
|
|
||||||
Package Helm file first
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm package ./kubernetes/helm/
|
|
||||||
```
|
|
||||||
|
|
||||||
For cpu-only pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm install ollama-webui ./ollama-webui-*.tgz
|
|
||||||
```
|
|
||||||
|
|
||||||
For gpu-enabled pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm install ollama-webui ./ollama-webui-*.tgz --set ollama.resources.limits.nvidia.com/gpu="1"
|
|
||||||
```
|
|
||||||
|
|
||||||
Check the `kubernetes/helm/values.yaml` file to know which parameters are available for customization
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
|
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -10,14 +10,16 @@
|
|||||||
[](https://discord.gg/5rJgQTnV4s)
|
[](https://discord.gg/5rJgQTnV4s)
|
||||||
[](https://github.com/sponsors/tjbck)
|
[](https://github.com/sponsors/tjbck)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
|
**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/)
|
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!**
|
||||||
|
|
||||||
@@ -37,24 +39,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.
|
||||||
@@ -63,33 +79,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>
|
|
||||||
<a href="https://n8n.io/">n8n</a> • Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="https://tailscale.com/blog/self-host-a-local-ai-stack/?utm_source=OpenWebUI&utm_medium=paid-ad-placement&utm_campaign=OpenWebUI-Docs" target="_blank">
|
|
||||||
<img src="https://docs.openwebui.com/sponsors/logos/tailscale.png" alt="Tailscale" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="https://tailscale.com/blog/self-host-a-local-ai-stack/?utm_source=OpenWebUI&utm_medium=paid-ad-placement&utm_campaign=OpenWebUI-Docs">Tailscale</a> • Connect self-hosted AI to any device with Tailscale
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</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!
|
||||||
@@ -201,14 +190,6 @@ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=
|
|||||||
|
|
||||||
### Keeping Your Docker Installation Up-to-Date
|
### Keeping Your Docker Installation Up-to-Date
|
||||||
|
|
||||||
In case you want to update your local Docker installation to the latest version, you can do it with [Watchtower](https://containrrr.dev/watchtower/):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once open-webui
|
|
||||||
```
|
|
||||||
|
|
||||||
In the last part of the command, replace `open-webui` with your container name if it is different.
|
|
||||||
|
|
||||||
Check our Updating Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/updating).
|
Check our Updating Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/updating).
|
||||||
|
|
||||||
### Using the Dev Branch 🌙
|
### Using the Dev Branch 🌙
|
||||||
@@ -236,7 +217,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.
|
||||||
|
|||||||
+1027
-154
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:
|
||||||
|
|||||||
+376
-34
@@ -7,6 +7,9 @@ import sys
|
|||||||
import shutil
|
import shutil
|
||||||
from uuid import uuid4
|
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
|
||||||
@@ -16,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
|
||||||
@@ -79,32 +85,7 @@ if "cuda_error" in locals():
|
|||||||
log.exception(cuda_error)
|
log.exception(cuda_error)
|
||||||
del cuda_error
|
del cuda_error
|
||||||
|
|
||||||
log_sources = [
|
SRC_LOG_LEVELS = {} # Legacy variable, do not remove
|
||||||
"AUDIO",
|
|
||||||
"COMFYUI",
|
|
||||||
"CONFIG",
|
|
||||||
"DB",
|
|
||||||
"IMAGES",
|
|
||||||
"MAIN",
|
|
||||||
"MODELS",
|
|
||||||
"OLLAMA",
|
|
||||||
"OPENAI",
|
|
||||||
"RAG",
|
|
||||||
"WEBHOOK",
|
|
||||||
"SOCKET",
|
|
||||||
"OAUTH",
|
|
||||||
]
|
|
||||||
|
|
||||||
SRC_LOG_LEVELS = {}
|
|
||||||
|
|
||||||
for source in log_sources:
|
|
||||||
log_env_var = source + "_LOG_LEVEL"
|
|
||||||
SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper()
|
|
||||||
if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping():
|
|
||||||
SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL
|
|
||||||
log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}")
|
|
||||||
|
|
||||||
log.setLevel(SRC_LOG_LEVELS["CONFIG"])
|
|
||||||
|
|
||||||
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
|
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
|
||||||
if WEBUI_NAME != "Open WebUI":
|
if WEBUI_NAME != "Open WebUI":
|
||||||
@@ -131,8 +112,13 @@ else:
|
|||||||
PACKAGE_DATA = {"version": "0.0.0"}
|
PACKAGE_DATA = {"version": "0.0.0"}
|
||||||
|
|
||||||
VERSION = PACKAGE_DATA["version"]
|
VERSION = PACKAGE_DATA["version"]
|
||||||
|
|
||||||
|
|
||||||
|
DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
|
||||||
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
|
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
|
||||||
|
|
||||||
|
ENABLE_DB_MIGRATIONS = os.environ.get("ENABLE_DB_MIGRATIONS", "True").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
# Function to parse each section
|
# Function to parse each section
|
||||||
def parse_section(section):
|
def parse_section(section):
|
||||||
@@ -208,6 +194,35 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Header names for user info forwarding (customizable via environment variables)
|
||||||
|
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get(
|
||||||
|
"FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name"
|
||||||
|
)
|
||||||
|
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get(
|
||||||
|
"FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id"
|
||||||
|
)
|
||||||
|
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get(
|
||||||
|
"FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email"
|
||||||
|
)
|
||||||
|
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get(
|
||||||
|
"FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Header name for chat ID forwarding (customizable via environment variable)
|
||||||
|
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID = os.environ.get(
|
||||||
|
"FORWARD_SESSION_INFO_HEADER_MESSAGE_ID", "X-OpenWebUI-Message-Id"
|
||||||
|
)
|
||||||
|
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get(
|
||||||
|
"FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Experimental feature, may be removed in future
|
||||||
|
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
||||||
|
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true"
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# WEBUI_BUILD_HASH
|
# WEBUI_BUILD_HASH
|
||||||
####################################
|
####################################
|
||||||
@@ -287,6 +302,9 @@ DB_VARS = {
|
|||||||
|
|
||||||
if all(DB_VARS.values()):
|
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']}"
|
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:
|
||||||
@@ -332,6 +350,31 @@ 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
|
||||||
|
|
||||||
|
# When enabled, get_db_context reuses existing sessions; set to False to always create new sessions
|
||||||
|
DATABASE_ENABLE_SESSION_SHARING = (
|
||||||
|
os.environ.get("DATABASE_ENABLE_SESSION_SHARING", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable public visibility of active user count (when disabled, only admins can see it)
|
||||||
|
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = (
|
||||||
|
os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -340,12 +383,19 @@ 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"
|
||||||
|
|
||||||
|
RAG_SYSTEM_CONTEXT = os.environ.get("RAG_SYSTEM_CONTEXT", "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_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")
|
||||||
|
|
||||||
@@ -358,6 +408,25 @@ try:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
|
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
|
||||||
|
|
||||||
|
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "")
|
||||||
|
try:
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
|
||||||
|
except ValueError:
|
||||||
|
REDIS_SOCKET_CONNECT_TIMEOUT = None
|
||||||
|
|
||||||
|
REDIS_RECONNECT_DELAY = os.environ.get("REDIS_RECONNECT_DELAY", "")
|
||||||
|
|
||||||
|
if REDIS_RECONNECT_DELAY == "":
|
||||||
|
REDIS_RECONNECT_DELAY = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
REDIS_RECONNECT_DELAY = float(REDIS_RECONNECT_DELAY)
|
||||||
|
if REDIS_RECONNECT_DELAY < 0:
|
||||||
|
REDIS_RECONNECT_DELAY = None
|
||||||
|
except Exception:
|
||||||
|
REDIS_RECONNECT_DELAY = None
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# UVICORN WORKERS
|
# UVICORN WORKERS
|
||||||
####################################
|
####################################
|
||||||
@@ -377,6 +446,24 @@ 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# Admin Account Runtime Creation
|
||||||
|
####################################
|
||||||
|
|
||||||
|
# Optional env vars for creating an admin account on startup
|
||||||
|
# Useful for headless/automated deployments
|
||||||
|
WEBUI_ADMIN_EMAIL = os.environ.get("WEBUI_ADMIN_EMAIL", "")
|
||||||
|
WEBUI_ADMIN_PASSWORD = os.environ.get("WEBUI_ADMIN_PASSWORD", "")
|
||||||
|
WEBUI_ADMIN_NAME = os.environ.get("WEBUI_ADMIN_NAME", "Admin")
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
@@ -386,6 +473,27 @@ WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ENABLE_PASSWORD_VALIDATION = (
|
||||||
|
os.environ.get("ENABLE_PASSWORD_VALIDATION", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
|
||||||
|
"PASSWORD_VALIDATION_REGEX_PATTERN",
|
||||||
|
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
PASSWORD_VALIDATION_REGEX_PATTERN = rf"{PASSWORD_VALIDATION_REGEX_PATTERN}"
|
||||||
|
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}")
|
||||||
|
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(
|
||||||
|
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -430,10 +538,80 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
|
|||||||
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token Exchange Configuration
|
||||||
|
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
|
||||||
|
ENABLE_OAUTH_TOKEN_EXCHANGE = (
|
||||||
|
os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# SCIM Configuration
|
||||||
|
####################################
|
||||||
|
|
||||||
|
ENABLE_SCIM = (
|
||||||
|
os.environ.get("ENABLE_SCIM", os.environ.get("SCIM_ENABLED", "False")).lower()
|
||||||
|
== "true"
|
||||||
|
)
|
||||||
|
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
|
||||||
|
SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "")
|
||||||
|
|
||||||
|
if ENABLE_SCIM and not SCIM_AUTH_PROVIDER:
|
||||||
|
log.warning(
|
||||||
|
"SCIM is enabled but SCIM_AUTH_PROVIDER is not set. "
|
||||||
|
"Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') "
|
||||||
|
"to enable externalId storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# 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
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
|
ENABLE_CUSTOM_MODEL_FALLBACK = (
|
||||||
|
os.environ.get("ENABLE_CUSTOM_MODEL_FALLBACK", "False").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
|
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
|
||||||
if MODELS_CACHE_TTL == "":
|
if MODELS_CACHE_TTL == "":
|
||||||
MODELS_CACHE_TTL = None
|
MODELS_CACHE_TTL = None
|
||||||
@@ -444,6 +622,58 @@ else:
|
|||||||
MODELS_CACHE_TTL = 1
|
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
|
# WEBSOCKET SUPPORT
|
||||||
####################################
|
####################################
|
||||||
@@ -455,7 +685,28 @@ ENABLE_WEBSOCKET_SUPPORT = (
|
|||||||
|
|
||||||
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 == "":
|
||||||
|
if REDIS_SOCKET_CONNECT_TIMEOUT:
|
||||||
|
WEBSOCKET_REDIS_OPTIONS = {
|
||||||
|
"socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
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_CLUSTER = (
|
||||||
|
os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
|
websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
|
||||||
|
|
||||||
@@ -465,8 +716,31 @@ except ValueError:
|
|||||||
WEBSOCKET_REDIS_LOCK_TIMEOUT = 60
|
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_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
|
||||||
|
|
||||||
|
|
||||||
|
REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true"
|
||||||
|
|
||||||
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
|
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
|
||||||
|
|
||||||
@@ -517,6 +791,17 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RAG_EMBEDDING_TIMEOUT = os.environ.get("RAG_EMBEDDING_TIMEOUT", "")
|
||||||
|
|
||||||
|
if RAG_EMBEDDING_TIMEOUT == "":
|
||||||
|
RAG_EMBEDDING_TIMEOUT = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
RAG_EMBEDDING_TIMEOUT = int(RAG_EMBEDDING_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
RAG_EMBEDDING_TIMEOUT = None
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# SENTENCE TRANSFORMERS
|
# SENTENCE TRANSFORMERS
|
||||||
####################################
|
####################################
|
||||||
@@ -561,6 +846,16 @@ else:
|
|||||||
except Exception:
|
except Exception:
|
||||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
|
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
|
||||||
|
|
||||||
|
# Whether to apply sigmoid normalization to CrossEncoder reranking scores.
|
||||||
|
# When enabled (default), scores are normalized to 0-1 range for proper
|
||||||
|
# relevance threshold behavior with MS MARCO models.
|
||||||
|
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
|
||||||
|
os.environ.get(
|
||||||
|
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION", "True"
|
||||||
|
).lower()
|
||||||
|
== "true"
|
||||||
|
)
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# OFFLINE_MODE
|
# OFFLINE_MODE
|
||||||
####################################
|
####################################
|
||||||
@@ -577,8 +872,15 @@ if OFFLINE_MODE:
|
|||||||
####################################
|
####################################
|
||||||
# AUDIT LOGGING
|
# AUDIT LOGGING
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
|
|
||||||
|
ENABLE_AUDIT_STDOUT = os.getenv("ENABLE_AUDIT_STDOUT", "False").lower() == "true"
|
||||||
|
ENABLE_AUDIT_LOGS_FILE = os.getenv("ENABLE_AUDIT_LOGS_FILE", "True").lower() == "true"
|
||||||
|
|
||||||
# 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")
|
||||||
|
|
||||||
@@ -609,13 +911,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_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 = (
|
OTEL_EXPORTER_OTLP_INSECURE = (
|
||||||
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
|
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", ""
|
||||||
@@ -626,11 +949,30 @@ OTEL_TRACES_SAMPLER = os.environ.get(
|
|||||||
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
|
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
|
||||||
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
|
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 = os.environ.get(
|
||||||
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
|
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
|
||||||
).lower() # grpc or http
|
).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,6 +19,7 @@ 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,
|
||||||
@@ -34,9 +35,8 @@ from open_webui.utils.plugin import (
|
|||||||
get_function_module_from_cache,
|
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.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||||
|
|
||||||
from open_webui.utils.misc import (
|
from open_webui.utils.misc import (
|
||||||
add_or_update_system_message,
|
add_or_update_system_message,
|
||||||
@@ -47,21 +47,31 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
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):
|
||||||
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
|
function_module, _, _ = get_function_module_from_cache(request, 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
|
||||||
|
|
||||||
|
|
||||||
@@ -70,65 +80,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
|
||||||
|
|
||||||
@@ -219,6 +239,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__,
|
||||||
@@ -230,9 +260,10 @@ async def generate_function_chat_completion(
|
|||||||
"__files__": files,
|
"__files__": files,
|
||||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||||
"__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,
|
||||||
@@ -253,9 +284,7 @@ async def generate_function_chat_completion(
|
|||||||
if params:
|
if params:
|
||||||
system = params.pop("system", None)
|
system = params.pop("system", None)
|
||||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||||
form_data = apply_model_system_prompt_to_body(
|
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||||
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
|
||||||
@@ -8,22 +9,23 @@ from open_webui.env import (
|
|||||||
OPEN_WEBUI_DIR,
|
OPEN_WEBUI_DIR,
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
DATABASE_SCHEMA,
|
DATABASE_SCHEMA,
|
||||||
SRC_LOG_LEVELS,
|
|
||||||
DATABASE_POOL_MAX_OVERFLOW,
|
DATABASE_POOL_MAX_OVERFLOW,
|
||||||
DATABASE_POOL_RECYCLE,
|
DATABASE_POOL_RECYCLE,
|
||||||
DATABASE_POOL_SIZE,
|
DATABASE_POOL_SIZE,
|
||||||
DATABASE_POOL_TIMEOUT,
|
DATABASE_POOL_TIMEOUT,
|
||||||
|
DATABASE_ENABLE_SQLITE_WAL,
|
||||||
|
DATABASE_ENABLE_SESSION_SHARING,
|
||||||
|
ENABLE_DB_MIGRATIONS,
|
||||||
)
|
)
|
||||||
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, Session
|
||||||
from sqlalchemy.pool import QueuePool, NullPool
|
from sqlalchemy.pool import QueuePool, NullPool
|
||||||
from sqlalchemy.sql.type_api import _T
|
from sqlalchemy.sql.type_api import _T
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["DB"])
|
|
||||||
|
|
||||||
|
|
||||||
class JSONField(types.TypeDecorator):
|
class JSONField(types.TypeDecorator):
|
||||||
@@ -75,14 +77,53 @@ def handle_peewee_migration(DATABASE_URL):
|
|||||||
assert db.is_closed(), "Database connection is still open."
|
assert db.is_closed(), "Database connection is still open."
|
||||||
|
|
||||||
|
|
||||||
handle_peewee_migration(DATABASE_URL)
|
if ENABLE_DB_MIGRATIONS:
|
||||||
|
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://", "")
|
||||||
|
|
||||||
|
# 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 isinstance(DATABASE_POOL_SIZE, int):
|
if isinstance(DATABASE_POOL_SIZE, int):
|
||||||
if DATABASE_POOL_SIZE > 0:
|
if DATABASE_POOL_SIZE > 0:
|
||||||
@@ -108,7 +149,7 @@ SessionLocal = sessionmaker(
|
|||||||
)
|
)
|
||||||
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
|
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
|
||||||
Base = declarative_base(metadata=metadata_obj)
|
Base = declarative_base(metadata=metadata_obj)
|
||||||
Session = scoped_session(SessionLocal)
|
ScopedSession = scoped_session(SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
@@ -120,3 +161,12 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
get_db = contextmanager(get_session)
|
get_db = contextmanager(get_session)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db_context(db: Optional[Session] = None):
|
||||||
|
if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING:
|
||||||
|
yield db
|
||||||
|
else:
|
||||||
|
with get_db() as session:
|
||||||
|
yield session
|
||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
|||||||
import peewee as pw
|
import peewee as pw
|
||||||
from peewee_migrate import Migrator
|
from peewee_migrate import Migrator
|
||||||
|
|
||||||
|
|
||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
import playhouse.postgres_ext as pw_pext
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
from peewee import *
|
from peewee import *
|
||||||
from peewee import InterfaceError as PeeWeeInterfaceError
|
from peewee import InterfaceError as PeeWeeInterfaceError
|
||||||
from peewee import PostgresqlDatabase
|
from peewee import PostgresqlDatabase
|
||||||
@@ -9,7 +9,6 @@ from playhouse.db_url import connect, parse
|
|||||||
from playhouse.shortcuts import ReconnectMixin
|
from playhouse.shortcuts import ReconnectMixin
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["DB"])
|
|
||||||
|
|
||||||
db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None}
|
db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None}
|
||||||
db_state = ContextVar("db_state", default=db_state_default.copy())
|
db_state = ContextVar("db_state", default=db_state_default.copy())
|
||||||
@@ -43,24 +42,45 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
|
|||||||
|
|
||||||
|
|
||||||
def register_connection(db_url):
|
def register_connection(db_url):
|
||||||
db = connect(db_url, unquote_user=True, 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://", "")
|
||||||
|
|
||||||
|
# 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_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:
|
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
|
||||||
+829
-167
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")
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"""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,247 @@
|
|||||||
|
"""Add prompt history table
|
||||||
|
|
||||||
|
Revision ID: 374d2f66af06
|
||||||
|
Revises: c440947495f3
|
||||||
|
Create Date: 2026-01-23 17:15:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "374d2f66af06"
|
||||||
|
down_revision: Union[str, None] = "c440947495f3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Step 1: Read existing data from OLD table (schema likely command as PK)
|
||||||
|
# We use batch_alter previously, but we want to move to new table.
|
||||||
|
# We need to assume the OLD structure.
|
||||||
|
|
||||||
|
old_prompt_table = sa.table(
|
||||||
|
"prompt",
|
||||||
|
sa.column("command", sa.Text()),
|
||||||
|
sa.column("user_id", sa.Text()),
|
||||||
|
sa.column("title", sa.Text()),
|
||||||
|
sa.column("content", sa.Text()),
|
||||||
|
sa.column("timestamp", sa.BigInteger()),
|
||||||
|
sa.column("access_control", sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if table exists/read data
|
||||||
|
try:
|
||||||
|
existing_prompts = conn.execute(
|
||||||
|
sa.select(
|
||||||
|
old_prompt_table.c.command,
|
||||||
|
old_prompt_table.c.user_id,
|
||||||
|
old_prompt_table.c.title,
|
||||||
|
old_prompt_table.c.content,
|
||||||
|
old_prompt_table.c.timestamp,
|
||||||
|
old_prompt_table.c.access_control,
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
# Fallback if table doesn't exist (new install)
|
||||||
|
existing_prompts = []
|
||||||
|
|
||||||
|
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
|
||||||
|
op.create_table(
|
||||||
|
"prompt_new",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("command", sa.String(), unique=True, index=True),
|
||||||
|
sa.Column("user_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("data", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("meta", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("version_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("tags", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Create prompt_history table
|
||||||
|
op.create_table(
|
||||||
|
"prompt_history",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("prompt_id", sa.Text(), nullable=False, index=True),
|
||||||
|
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("commit_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Migrate data
|
||||||
|
prompt_new_table = sa.table(
|
||||||
|
"prompt_new",
|
||||||
|
sa.column("id", sa.Text()),
|
||||||
|
sa.column("command", sa.String()),
|
||||||
|
sa.column("user_id", sa.String()),
|
||||||
|
sa.column("name", sa.Text()),
|
||||||
|
sa.column("content", sa.Text()),
|
||||||
|
sa.column("data", sa.JSON()),
|
||||||
|
sa.column("meta", sa.JSON()),
|
||||||
|
sa.column("access_control", sa.JSON()),
|
||||||
|
sa.column("is_active", sa.Boolean()),
|
||||||
|
sa.column("version_id", sa.Text()),
|
||||||
|
sa.column("tags", sa.JSON()),
|
||||||
|
sa.column("created_at", sa.BigInteger()),
|
||||||
|
sa.column("updated_at", sa.BigInteger()),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt_history_table = sa.table(
|
||||||
|
"prompt_history",
|
||||||
|
sa.column("id", sa.Text()),
|
||||||
|
sa.column("prompt_id", sa.Text()),
|
||||||
|
sa.column("parent_id", sa.Text()),
|
||||||
|
sa.column("snapshot", sa.JSON()),
|
||||||
|
sa.column("user_id", sa.Text()),
|
||||||
|
sa.column("commit_message", sa.Text()),
|
||||||
|
sa.column("created_at", sa.BigInteger()),
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in existing_prompts:
|
||||||
|
command = row[0]
|
||||||
|
user_id = row[1]
|
||||||
|
title = row[2]
|
||||||
|
content = row[3]
|
||||||
|
timestamp = row[4]
|
||||||
|
access_control = row[5]
|
||||||
|
|
||||||
|
new_uuid = str(uuid.uuid4())
|
||||||
|
history_uuid = str(uuid.uuid4())
|
||||||
|
clean_command = command[1:] if command and command.startswith("/") else command
|
||||||
|
|
||||||
|
# Insert into prompt_new
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(prompt_new_table).values(
|
||||||
|
id=new_uuid,
|
||||||
|
command=clean_command,
|
||||||
|
user_id=user_id,
|
||||||
|
name=title,
|
||||||
|
content=content,
|
||||||
|
data={},
|
||||||
|
meta={},
|
||||||
|
access_control=access_control,
|
||||||
|
is_active=True,
|
||||||
|
version_id=history_uuid,
|
||||||
|
tags=[],
|
||||||
|
created_at=timestamp,
|
||||||
|
updated_at=timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial history entry
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(prompt_history_table).values(
|
||||||
|
id=history_uuid,
|
||||||
|
prompt_id=new_uuid,
|
||||||
|
parent_id=None,
|
||||||
|
snapshot={
|
||||||
|
"name": title,
|
||||||
|
"content": content,
|
||||||
|
"command": clean_command,
|
||||||
|
"data": {},
|
||||||
|
"meta": {},
|
||||||
|
"access_control": access_control,
|
||||||
|
},
|
||||||
|
user_id=user_id,
|
||||||
|
commit_message=None,
|
||||||
|
created_at=timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Replace old table with new one
|
||||||
|
op.drop_table("prompt")
|
||||||
|
op.rename_table("prompt_new", "prompt")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Step 1: Read new data
|
||||||
|
prompt_table = sa.table(
|
||||||
|
"prompt",
|
||||||
|
sa.column("command", sa.String()),
|
||||||
|
sa.column("name", sa.Text()),
|
||||||
|
sa.column("created_at", sa.BigInteger()),
|
||||||
|
sa.column("user_id", sa.Text()),
|
||||||
|
sa.column("content", sa.Text()),
|
||||||
|
sa.column("access_control", sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_data = conn.execute(
|
||||||
|
sa.select(
|
||||||
|
prompt_table.c.command,
|
||||||
|
prompt_table.c.name,
|
||||||
|
prompt_table.c.created_at,
|
||||||
|
prompt_table.c.user_id,
|
||||||
|
prompt_table.c.content,
|
||||||
|
prompt_table.c.access_control,
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
current_data = []
|
||||||
|
|
||||||
|
# Step 2: Drop history and table
|
||||||
|
op.drop_table("prompt_history")
|
||||||
|
op.drop_table("prompt")
|
||||||
|
|
||||||
|
# Step 3: Recreate old table (command as PK?)
|
||||||
|
# Assuming old schema:
|
||||||
|
op.create_table(
|
||||||
|
"prompt",
|
||||||
|
sa.Column("command", sa.String(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String()),
|
||||||
|
sa.Column("title", sa.Text()),
|
||||||
|
sa.Column("content", sa.Text()),
|
||||||
|
sa.Column("timestamp", sa.BigInteger()),
|
||||||
|
sa.Column("access_control", sa.JSON()),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Restore data
|
||||||
|
old_prompt_table = sa.table(
|
||||||
|
"prompt",
|
||||||
|
sa.column("command", sa.String()),
|
||||||
|
sa.column("user_id", sa.String()),
|
||||||
|
sa.column("title", sa.Text()),
|
||||||
|
sa.column("content", sa.Text()),
|
||||||
|
sa.column("timestamp", sa.BigInteger()),
|
||||||
|
sa.column("access_control", sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in current_data:
|
||||||
|
command = row[0]
|
||||||
|
name = row[1]
|
||||||
|
created_at = row[2]
|
||||||
|
user_id = row[3]
|
||||||
|
content = row[4]
|
||||||
|
access_control = row[5]
|
||||||
|
|
||||||
|
# Restore leading /
|
||||||
|
old_command = (
|
||||||
|
"/" + command if command and not command.startswith("/") else command
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(old_prompt_table).values(
|
||||||
|
command=old_command,
|
||||||
|
user_id=user_id,
|
||||||
|
title=name,
|
||||||
|
content=content,
|
||||||
|
timestamp=created_at,
|
||||||
|
access_control=access_control,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""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,79 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Add channel file table
|
||||||
|
|
||||||
|
Revision ID: 6283dc0e4d8d
|
||||||
|
Revises: 3e0e00844bb0
|
||||||
|
Create Date: 2025-12-10 15:11:39.424601
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 = "6283dc0e4d8d"
|
||||||
|
down_revision: Union[str, None] = "3e0e00844bb0"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"channel_file",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"channel_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("channel.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_channel_file_channel_id", "channel_id"),
|
||||||
|
sa.Index("ix_channel_file_file_id", "file_id"),
|
||||||
|
sa.Index("ix_channel_file_user_id", "user_id"),
|
||||||
|
# unique constraints
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"channel_id", "file_id", name="uq_channel_file_channel_file"
|
||||||
|
), # prevent duplicate entries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("channel_file")
|
||||||
@@ -11,7 +11,6 @@ import sqlalchemy as sa
|
|||||||
from sqlalchemy.sql import table, column, select
|
from sqlalchemy.sql import table, column, select
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
revision = "6a39f3d8e55c"
|
revision = "6a39f3d8e55c"
|
||||||
down_revision = "c0fbf31ca0db"
|
down_revision = "c0fbf31ca0db"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
"""Update channel file and knowledge table
|
||||||
|
|
||||||
|
Revision ID: 81cc2ce44d79
|
||||||
|
Revises: 6283dc0e4d8d
|
||||||
|
Create Date: 2025-12-10 16:07:58.001282
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 = "81cc2ce44d79"
|
||||||
|
down_revision: Union[str, None] = "6283dc0e4d8d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add message_id column to channel_file table
|
||||||
|
with op.batch_alter_table("channel_file", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"message_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey(
|
||||||
|
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add data column to knowledge table
|
||||||
|
with op.batch_alter_table("knowledge", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove message_id column from channel_file table
|
||||||
|
with op.batch_alter_table("channel_file", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("message_id")
|
||||||
|
|
||||||
|
# Remove data column from knowledge table
|
||||||
|
with op.batch_alter_table("knowledge", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("data")
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Add chat_message table
|
||||||
|
|
||||||
|
Revision ID: 8452d01d26d7
|
||||||
|
Revises: 374d2f66af06
|
||||||
|
Create Date: 2026-02-01 04:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
revision: str = "8452d01d26d7"
|
||||||
|
down_revision: Union[str, None] = "374d2f66af06"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Step 1: Create table
|
||||||
|
op.create_table(
|
||||||
|
"chat_message",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("chat_id", sa.Text(), nullable=False, index=True),
|
||||||
|
sa.Column("user_id", sa.Text(), index=True),
|
||||||
|
sa.Column("role", sa.Text(), nullable=False),
|
||||||
|
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("content", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("output", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("model_id", sa.Text(), nullable=True, index=True),
|
||||||
|
sa.Column("files", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("sources", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("embeds", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("done", sa.Boolean(), default=True),
|
||||||
|
sa.Column("status_history", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("error", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("usage", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), index=True),
|
||||||
|
sa.Column("updated_at", sa.BigInteger()),
|
||||||
|
sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"chat_message_model_created_idx", "chat_message", ["model_id", "created_at"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"chat_message_user_created_idx", "chat_message", ["user_id", "created_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Backfill from existing chats
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
chat_table = sa.table(
|
||||||
|
"chat",
|
||||||
|
sa.column("id", sa.Text()),
|
||||||
|
sa.column("user_id", sa.Text()),
|
||||||
|
sa.column("chat", sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_message_table = sa.table(
|
||||||
|
"chat_message",
|
||||||
|
sa.column("id", sa.Text()),
|
||||||
|
sa.column("chat_id", sa.Text()),
|
||||||
|
sa.column("user_id", sa.Text()),
|
||||||
|
sa.column("role", sa.Text()),
|
||||||
|
sa.column("parent_id", sa.Text()),
|
||||||
|
sa.column("content", sa.JSON()),
|
||||||
|
sa.column("output", sa.JSON()),
|
||||||
|
sa.column("model_id", sa.Text()),
|
||||||
|
sa.column("files", sa.JSON()),
|
||||||
|
sa.column("sources", sa.JSON()),
|
||||||
|
sa.column("embeds", sa.JSON()),
|
||||||
|
sa.column("done", sa.Boolean()),
|
||||||
|
sa.column("status_history", sa.JSON()),
|
||||||
|
sa.column("error", sa.JSON()),
|
||||||
|
sa.column("usage", sa.JSON()),
|
||||||
|
sa.column("created_at", sa.BigInteger()),
|
||||||
|
sa.column("updated_at", sa.BigInteger()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
|
||||||
|
chats = conn.execute(
|
||||||
|
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
|
||||||
|
~chat_table.c.user_id.like("shared-%")
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
messages_inserted = 0
|
||||||
|
messages_failed = 0
|
||||||
|
|
||||||
|
for chat_row in chats:
|
||||||
|
chat_id = chat_row[0]
|
||||||
|
user_id = chat_row[1]
|
||||||
|
chat_data = chat_row[2]
|
||||||
|
|
||||||
|
if not chat_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle both string and dict chat data
|
||||||
|
if isinstance(chat_data, str):
|
||||||
|
try:
|
||||||
|
chat_data = json.loads(chat_data)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
history = chat_data.get("history", {})
|
||||||
|
messages = history.get("messages", {})
|
||||||
|
|
||||||
|
for message_id, message in messages.items():
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
role = message.get("role")
|
||||||
|
if not role:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = message.get("timestamp", now)
|
||||||
|
|
||||||
|
# Normalize timestamp: convert ms to seconds, validate range
|
||||||
|
if timestamp > 10_000_000_000:
|
||||||
|
timestamp = timestamp // 1000
|
||||||
|
# Must be after 2020 and not too far in the future
|
||||||
|
if timestamp < 1577836800 or timestamp > now + 86400:
|
||||||
|
timestamp = now
|
||||||
|
|
||||||
|
# Use savepoint to allow individual insert failures without aborting transaction
|
||||||
|
savepoint = conn.begin_nested()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(chat_message_table).values(
|
||||||
|
id=f"{chat_id}-{message_id}",
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=role,
|
||||||
|
parent_id=message.get("parentId"),
|
||||||
|
content=message.get("content"),
|
||||||
|
output=message.get("output"),
|
||||||
|
model_id=message.get("model"),
|
||||||
|
files=message.get("files"),
|
||||||
|
sources=message.get("sources"),
|
||||||
|
embeds=message.get("embeds"),
|
||||||
|
done=message.get("done", True),
|
||||||
|
status_history=message.get("statusHistory"),
|
||||||
|
error=message.get("error"),
|
||||||
|
created_at=timestamp,
|
||||||
|
updated_at=timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
savepoint.commit()
|
||||||
|
messages_inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
savepoint.rollback()
|
||||||
|
messages_failed += 1
|
||||||
|
log.warning(f"Failed to insert message {message_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("chat_message_user_created_idx", table_name="chat_message")
|
||||||
|
op.drop_index("chat_message_model_created_idx", table_name="chat_message")
|
||||||
|
op.drop_index("chat_message_chat_parent_idx", table_name="chat_message")
|
||||||
|
op.drop_table("chat_message")
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add skill table
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: f1e2d3c4b5a6
|
||||||
|
Create Date: 2026-02-11 09:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from open_webui.migrations.util import get_existing_tables
|
||||||
|
|
||||||
|
revision: str = "a1b2c3d4e5f6"
|
||||||
|
down_revision: Union[str, None] = "f1e2d3c4b5a6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
existing_tables = set(get_existing_tables())
|
||||||
|
|
||||||
|
if "skill" not in existing_tables:
|
||||||
|
op.create_table(
|
||||||
|
"skill",
|
||||||
|
sa.Column("id", sa.String(), nullable=False, primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("meta", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("idx_skill_user_id", "skill", ["user_id"])
|
||||||
|
op.create_index("idx_skill_updated_at", "skill", ["updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_skill_updated_at", table_name="skill")
|
||||||
|
op.drop_index("idx_skill_user_id", table_name="skill")
|
||||||
|
op.drop_table("skill")
|
||||||
+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,249 @@
|
|||||||
|
"""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,26 @@
|
|||||||
|
"""add scim column to user table
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-02-13 14:19:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b2c3d4e5f6a7"
|
||||||
|
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||||
|
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("scim", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user", "scim")
|
||||||
@@ -12,7 +12,6 @@ import json
|
|||||||
from sqlalchemy.sql import table, column
|
from sqlalchemy.sql import table, column
|
||||||
from sqlalchemy import String, Text, JSON, and_
|
from sqlalchemy import String, Text, JSON, and_
|
||||||
|
|
||||||
|
|
||||||
revision = "c29facfe716b"
|
revision = "c29facfe716b"
|
||||||
down_revision = "c69f45358db4"
|
down_revision = "c69f45358db4"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Add chat_file table
|
||||||
|
|
||||||
|
Revision ID: c440947495f3
|
||||||
|
Revises: 81cc2ce44d79
|
||||||
|
Create Date: 2025-12-21 20:27:41.694897
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c440947495f3"
|
||||||
|
down_revision: Union[str, None] = "81cc2ce44d79"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"chat_file",
|
||||||
|
sa.Column("id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"chat_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("chat.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"file_id",
|
||||||
|
sa.Text(),
|
||||||
|
sa.ForeignKey("file.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("message_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||||
|
# indexes
|
||||||
|
sa.Index("ix_chat_file_chat_id", "chat_id"),
|
||||||
|
sa.Index("ix_chat_file_file_id", "file_id"),
|
||||||
|
sa.Index("ix_chat_file_message_id", "message_id"),
|
||||||
|
sa.Index("ix_chat_file_user_id", "user_id"),
|
||||||
|
# unique constraints
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"chat_id", "file_id", name="uq_chat_file_chat_file"
|
||||||
|
), # prevent duplicate entries
|
||||||
|
)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("chat_file")
|
||||||
|
pass
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""Add access_grant table
|
||||||
|
|
||||||
|
Revision ID: f1e2d3c4b5a6
|
||||||
|
Revises: 8452d01d26d7
|
||||||
|
Create Date: 2026-02-05 10:00:00.000000
|
||||||
|
|
||||||
|
Migrates from JSON access_control columns to normalized access_grant table.
|
||||||
|
Access control semantics:
|
||||||
|
- NULL: Public access (all users can read) -> insert user:* for read
|
||||||
|
- {}: Private/owner-only (no grants) -> insert nothing
|
||||||
|
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from open_webui.migrations.util import get_existing_tables
|
||||||
|
|
||||||
|
revision: str = "f1e2d3c4b5a6"
|
||||||
|
down_revision: Union[str, None] = "8452d01d26d7"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
existing_tables = set(get_existing_tables())
|
||||||
|
|
||||||
|
# Create access_grant table
|
||||||
|
if "access_grant" not in existing_tables:
|
||||||
|
op.create_table(
|
||||||
|
"access_grant",
|
||||||
|
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||||
|
sa.Column("resource_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("resource_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("principal_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("principal_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("permission", sa.Text(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"principal_type",
|
||||||
|
"principal_id",
|
||||||
|
"permission",
|
||||||
|
name="uq_access_grant_grant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_access_grant_resource",
|
||||||
|
"access_grant",
|
||||||
|
["resource_type", "resource_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_access_grant_principal",
|
||||||
|
"access_grant",
|
||||||
|
["principal_type", "principal_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill existing access_control JSON data
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Tables with access_control JSON columns: (table_name, resource_type)
|
||||||
|
resource_tables = [
|
||||||
|
("knowledge", "knowledge"),
|
||||||
|
("prompt", "prompt"),
|
||||||
|
("tool", "tool"),
|
||||||
|
("model", "model"),
|
||||||
|
("note", "note"),
|
||||||
|
("channel", "channel"),
|
||||||
|
("file", "file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
inserted = set()
|
||||||
|
|
||||||
|
for table_name, resource_type in resource_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Query all rows
|
||||||
|
try:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(f'SELECT id, access_control FROM "{table_name}"')
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
resource_id = row[0]
|
||||||
|
access_control_json = row[1]
|
||||||
|
|
||||||
|
# Handle NULL or JSON "null" = public access (user:* for read)
|
||||||
|
# Could be Python None (SQL NULL) or string "null" (JSON null)
|
||||||
|
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
|
||||||
|
is_null = (
|
||||||
|
access_control_json is None
|
||||||
|
or access_control_json == "null"
|
||||||
|
or (
|
||||||
|
isinstance(access_control_json, str)
|
||||||
|
and access_control_json.strip().lower() == "null"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if is_null:
|
||||||
|
# Files: NULL = private (no entry needed, owner has implicit access)
|
||||||
|
# Other resources: NULL = public (insert user:* for read)
|
||||||
|
if resource_type == "file":
|
||||||
|
continue # Private - no entry needed
|
||||||
|
|
||||||
|
key = (resource_type, resource_id, "user", "*", "read")
|
||||||
|
if key not in inserted:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||||
|
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"principal_type": "user",
|
||||||
|
"principal_id": "*",
|
||||||
|
"permission": "read",
|
||||||
|
"created_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inserted.add(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle JSON parsing
|
||||||
|
if isinstance(access_control_json, str):
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
access_control_json = json.loads(access_control_json)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle {} = private/owner-only - NO entries needed
|
||||||
|
# Owner access is implicit, no grants to store
|
||||||
|
if not access_control_json or not isinstance(access_control_json, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if it's effectively empty (no read/write keys with content)
|
||||||
|
read_data = access_control_json.get("read", {})
|
||||||
|
write_data = access_control_json.get("write", {})
|
||||||
|
|
||||||
|
has_read_grants = read_data.get("group_ids", []) or read_data.get(
|
||||||
|
"user_ids", []
|
||||||
|
)
|
||||||
|
has_write_grants = write_data.get("group_ids", []) or write_data.get(
|
||||||
|
"user_ids", []
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_read_grants and not has_write_grants:
|
||||||
|
# Empty permissions = private, no grants needed
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract permissions and insert into access_grant table
|
||||||
|
for permission in ["read", "write"]:
|
||||||
|
perm_data = access_control_json.get(permission, {})
|
||||||
|
if not perm_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for group_id in perm_data.get("group_ids", []):
|
||||||
|
key = (resource_type, resource_id, "group", group_id, permission)
|
||||||
|
if key in inserted:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||||
|
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"principal_type": "group",
|
||||||
|
"principal_id": group_id,
|
||||||
|
"permission": permission,
|
||||||
|
"created_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inserted.add(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for user_id in perm_data.get("user_ids", []):
|
||||||
|
key = (resource_type, resource_id, "user", user_id, permission)
|
||||||
|
if key in inserted:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||||
|
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"principal_type": "user",
|
||||||
|
"principal_id": user_id,
|
||||||
|
"permission": permission,
|
||||||
|
"created_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inserted.add(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Drop access_control columns from resource tables
|
||||||
|
for table_name, _ in resource_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with op.batch_alter_table(table_name) as batch:
|
||||||
|
batch.drop_column("access_control")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Resource tables mapping: (table_name, resource_type)
|
||||||
|
resource_tables = [
|
||||||
|
("knowledge", "knowledge"),
|
||||||
|
("prompt", "prompt"),
|
||||||
|
("tool", "tool"),
|
||||||
|
("model", "model"),
|
||||||
|
("note", "note"),
|
||||||
|
("channel", "channel"),
|
||||||
|
("file", "file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 1: Re-add access_control columns to resource tables
|
||||||
|
for table_name, _ in resource_tables:
|
||||||
|
try:
|
||||||
|
with op.batch_alter_table(table_name) as batch:
|
||||||
|
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 2: Query access_grant table and reconstruct JSON for each resource
|
||||||
|
for table_name, resource_type in resource_tables:
|
||||||
|
try:
|
||||||
|
# Get all grants for this resource type
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT resource_id, principal_type, principal_id, permission
|
||||||
|
FROM access_grant
|
||||||
|
WHERE resource_type = :resource_type
|
||||||
|
"""),
|
||||||
|
{"resource_type": resource_type},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Group by resource_id and reconstruct JSON structure
|
||||||
|
resource_grants = {}
|
||||||
|
for row in rows:
|
||||||
|
resource_id = row[0]
|
||||||
|
principal_type = row[1]
|
||||||
|
principal_id = row[2]
|
||||||
|
permission = row[3]
|
||||||
|
|
||||||
|
if resource_id not in resource_grants:
|
||||||
|
resource_grants[resource_id] = {
|
||||||
|
"is_public": False,
|
||||||
|
"read": {"group_ids": [], "user_ids": []},
|
||||||
|
"write": {"group_ids": [], "user_ids": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle public access (user:* for read)
|
||||||
|
if (
|
||||||
|
principal_type == "user"
|
||||||
|
and principal_id == "*"
|
||||||
|
and permission == "read"
|
||||||
|
):
|
||||||
|
resource_grants[resource_id]["is_public"] = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add to appropriate list
|
||||||
|
if permission in ["read", "write"]:
|
||||||
|
if principal_type == "group":
|
||||||
|
if (
|
||||||
|
principal_id
|
||||||
|
not in resource_grants[resource_id][permission]["group_ids"]
|
||||||
|
):
|
||||||
|
resource_grants[resource_id][permission]["group_ids"].append(
|
||||||
|
principal_id
|
||||||
|
)
|
||||||
|
elif principal_type == "user":
|
||||||
|
if (
|
||||||
|
principal_id
|
||||||
|
not in resource_grants[resource_id][permission]["user_ids"]
|
||||||
|
):
|
||||||
|
resource_grants[resource_id][permission]["user_ids"].append(
|
||||||
|
principal_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Update each resource with reconstructed JSON
|
||||||
|
for resource_id, grants in resource_grants.items():
|
||||||
|
if grants["is_public"]:
|
||||||
|
# Public = NULL
|
||||||
|
access_control_value = None
|
||||||
|
elif (
|
||||||
|
not grants["read"]["group_ids"]
|
||||||
|
and not grants["read"]["user_ids"]
|
||||||
|
and not grants["write"]["group_ids"]
|
||||||
|
and not grants["write"]["user_ids"]
|
||||||
|
):
|
||||||
|
# No grants = should not happen (would mean no entries), default to {}
|
||||||
|
access_control_value = json.dumps({})
|
||||||
|
else:
|
||||||
|
# Custom permissions
|
||||||
|
access_control_value = json.dumps(
|
||||||
|
{
|
||||||
|
"read": grants["read"],
|
||||||
|
"write": grants["write"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'
|
||||||
|
),
|
||||||
|
{"access_control": access_control_value, "id": resource_id},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 4: Set all resources WITHOUT entries to private
|
||||||
|
# For files: NULL means private (owner-only), so leave as NULL
|
||||||
|
# For other resources: {} means private, so update to {}
|
||||||
|
if resource_type != "file":
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(f"""
|
||||||
|
UPDATE "{table_name}"
|
||||||
|
SET access_control = :private_value
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
|
||||||
|
)
|
||||||
|
AND access_control IS NULL
|
||||||
|
"""),
|
||||||
|
{"private_value": json.dumps({}), "resource_type": resource_type},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# For files, NULL stays NULL - no action needed
|
||||||
|
|
||||||
|
# Step 5: Drop the access_grant table
|
||||||
|
op.drop_index("idx_access_grant_principal", table_name="access_grant")
|
||||||
|
op.drop_index("idx_access_grant_resource", table_name="access_grant")
|
||||||
|
op.drop_table("access_grant")
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, get_db_context
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# AccessGrant DB Schema
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class AccessGrant(Base):
|
||||||
|
__tablename__ = "access_grant"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
resource_type = Column(
|
||||||
|
Text, nullable=False
|
||||||
|
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||||
|
resource_id = Column(Text, nullable=False)
|
||||||
|
principal_type = Column(Text, nullable=False) # "user" or "group"
|
||||||
|
principal_id = Column(
|
||||||
|
Text, nullable=False
|
||||||
|
) # user_id, group_id, or "*" (wildcard for public)
|
||||||
|
permission = Column(Text, nullable=False) # "read" or "write"
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"principal_type",
|
||||||
|
"principal_id",
|
||||||
|
"permission",
|
||||||
|
name="uq_access_grant_grant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessGrantModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
principal_type: str
|
||||||
|
principal_id: str
|
||||||
|
permission: str
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class AccessGrantResponse(BaseModel):
|
||||||
|
"""Slim grant model for API responses — resource context is implicit from the parent."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
principal_type: str
|
||||||
|
principal_id: str
|
||||||
|
permission: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
|
||||||
|
return cls(
|
||||||
|
id=grant.id,
|
||||||
|
principal_type=grant.principal_type,
|
||||||
|
principal_id=grant.principal_id,
|
||||||
|
permission=grant.permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Conversion utilities
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
def access_control_to_grants(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
access_control: Optional[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Convert an old-style access_control JSON dict to a flat list of grant dicts.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- None → public read (user:* read) — except files which are private
|
||||||
|
- {} → private/owner-only (no grants)
|
||||||
|
- {read: {group_ids, user_ids}, write: {group_ids, user_ids}} → specific grants
|
||||||
|
|
||||||
|
Returns a list of dicts with keys: resource_type, resource_id, principal_type, principal_id, permission
|
||||||
|
"""
|
||||||
|
grants = []
|
||||||
|
|
||||||
|
if access_control is None:
|
||||||
|
# NULL → public read (user:* for read)
|
||||||
|
# Exception: files with NULL are private (owner-only), no grants needed
|
||||||
|
if resource_type != "file":
|
||||||
|
grants.append(
|
||||||
|
{
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"principal_type": "user",
|
||||||
|
"principal_id": "*",
|
||||||
|
"permission": "read",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return grants
|
||||||
|
|
||||||
|
# {} → private/owner-only, no grants
|
||||||
|
if not access_control:
|
||||||
|
return grants
|
||||||
|
|
||||||
|
# Parse structured permissions
|
||||||
|
for permission in ["read", "write"]:
|
||||||
|
perm_data = access_control.get(permission, {})
|
||||||
|
if not perm_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for group_id in perm_data.get("group_ids", []):
|
||||||
|
grants.append(
|
||||||
|
{
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"principal_type": "group",
|
||||||
|
"principal_id": group_id,
|
||||||
|
"permission": permission,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for user_id in perm_data.get("user_ids", []):
|
||||||
|
grants.append(
|
||||||
|
{
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"principal_type": "user",
|
||||||
|
"principal_id": user_id,
|
||||||
|
"permission": permission,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return grants
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Normalize direct access_grants payloads from API forms.
|
||||||
|
|
||||||
|
Keeps only valid grants and removes duplicates by
|
||||||
|
(principal_type, principal_id, permission).
|
||||||
|
"""
|
||||||
|
if not access_grants:
|
||||||
|
return []
|
||||||
|
|
||||||
|
deduped = {}
|
||||||
|
for grant in access_grants:
|
||||||
|
if isinstance(grant, BaseModel):
|
||||||
|
grant = grant.model_dump()
|
||||||
|
if not isinstance(grant, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
principal_type = grant.get("principal_type")
|
||||||
|
principal_id = grant.get("principal_id")
|
||||||
|
permission = grant.get("permission")
|
||||||
|
|
||||||
|
if principal_type not in ("user", "group"):
|
||||||
|
continue
|
||||||
|
if permission not in ("read", "write"):
|
||||||
|
continue
|
||||||
|
if not isinstance(principal_id, str) or not principal_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (principal_type, principal_id, permission)
|
||||||
|
deduped[key] = {
|
||||||
|
"id": (
|
||||||
|
grant.get("id")
|
||||||
|
if isinstance(grant.get("id"), str) and grant.get("id")
|
||||||
|
else str(uuid.uuid4())
|
||||||
|
),
|
||||||
|
"principal_type": principal_type,
|
||||||
|
"principal_id": principal_id,
|
||||||
|
"permission": permission,
|
||||||
|
}
|
||||||
|
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
|
def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True when a direct grant list includes wildcard public-read.
|
||||||
|
"""
|
||||||
|
for grant in normalize_access_grants(access_grants):
|
||||||
|
if (
|
||||||
|
grant["principal_type"] == "user"
|
||||||
|
and grant["principal_id"] == "*"
|
||||||
|
and grant["permission"] == "read"
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
|
||||||
|
back to the old-style access_control JSON dict for backward compatibility.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- [] (empty) → {} (private/owner-only)
|
||||||
|
- Contains user:*:read → None (public), but write grants are preserved
|
||||||
|
- Otherwise → {read: {group_ids, user_ids}, write: {group_ids, user_ids}}
|
||||||
|
|
||||||
|
Note: "public" (user:*:read) still allows additional write permissions
|
||||||
|
to coexist. When the wildcard read is present the function returns None
|
||||||
|
for the legacy dict, so callers that need write info should inspect the
|
||||||
|
grants list directly.
|
||||||
|
"""
|
||||||
|
if not grants:
|
||||||
|
return {} # No grants = private/owner-only
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"read": {"group_ids": [], "user_ids": []},
|
||||||
|
"write": {"group_ids": [], "user_ids": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
is_public = False
|
||||||
|
for grant in grants:
|
||||||
|
if (
|
||||||
|
grant.principal_type == "user"
|
||||||
|
and grant.principal_id == "*"
|
||||||
|
and grant.permission == "read"
|
||||||
|
):
|
||||||
|
is_public = True
|
||||||
|
continue # Don't add wildcard to user_ids list
|
||||||
|
|
||||||
|
if grant.permission not in ("read", "write"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if grant.principal_type == "group":
|
||||||
|
if grant.principal_id not in result[grant.permission]["group_ids"]:
|
||||||
|
result[grant.permission]["group_ids"].append(grant.principal_id)
|
||||||
|
elif grant.principal_type == "user":
|
||||||
|
if grant.principal_id not in result[grant.permission]["user_ids"]:
|
||||||
|
result[grant.permission]["user_ids"].append(grant.principal_id)
|
||||||
|
|
||||||
|
if is_public:
|
||||||
|
return None # Public read access
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Table Operations
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class AccessGrantsTable:
|
||||||
|
def grant_access(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
principal_type: str,
|
||||||
|
principal_id: str,
|
||||||
|
permission: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[AccessGrantModel]:
|
||||||
|
"""Add a single access grant. Idempotent (ignores duplicates)."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# Check for existing grant
|
||||||
|
existing = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
principal_type=principal_type,
|
||||||
|
principal_id=principal_id,
|
||||||
|
permission=permission,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return AccessGrantModel.model_validate(existing)
|
||||||
|
|
||||||
|
grant = AccessGrant(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
principal_type=principal_type,
|
||||||
|
principal_id=principal_id,
|
||||||
|
permission=permission,
|
||||||
|
created_at=int(time.time()),
|
||||||
|
)
|
||||||
|
db.add(grant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(grant)
|
||||||
|
return AccessGrantModel.model_validate(grant)
|
||||||
|
|
||||||
|
def revoke_access(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
principal_type: str,
|
||||||
|
principal_id: str,
|
||||||
|
permission: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove a single access grant."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
deleted = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
principal_type=principal_type,
|
||||||
|
principal_id=principal_id,
|
||||||
|
permission=permission,
|
||||||
|
)
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return deleted > 0
|
||||||
|
|
||||||
|
def revoke_all_access(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Remove all access grants for a resource."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
deleted = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def set_access_control(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
access_control: Optional[dict],
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
"""
|
||||||
|
Replace all grants for a resource from an access_control JSON dict.
|
||||||
|
This is the primary bridge for backward compat with the frontend.
|
||||||
|
"""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# Delete all existing grants for this resource
|
||||||
|
db.query(AccessGrant).filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# Convert JSON to grant dicts
|
||||||
|
grant_dicts = access_control_to_grants(
|
||||||
|
resource_type, resource_id, access_control
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert new grants
|
||||||
|
results = []
|
||||||
|
for grant_dict in grant_dicts:
|
||||||
|
grant = AccessGrant(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
**grant_dict,
|
||||||
|
created_at=int(time.time()),
|
||||||
|
)
|
||||||
|
db.add(grant)
|
||||||
|
results.append(grant)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return [AccessGrantModel.model_validate(g) for g in results]
|
||||||
|
|
||||||
|
def set_access_grants(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
access_grants: Optional[list],
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
"""
|
||||||
|
Replace all grants for a resource from a direct access_grants list.
|
||||||
|
"""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
db.query(AccessGrant).filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
normalized_grants = normalize_access_grants(access_grants)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for grant_dict in normalized_grants:
|
||||||
|
grant = AccessGrant(
|
||||||
|
id=grant_dict["id"],
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
principal_type=grant_dict["principal_type"],
|
||||||
|
principal_id=grant_dict["principal_id"],
|
||||||
|
permission=grant_dict["permission"],
|
||||||
|
created_at=int(time.time()),
|
||||||
|
)
|
||||||
|
db.add(grant)
|
||||||
|
results.append(grant)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return [AccessGrantModel.model_validate(g) for g in results]
|
||||||
|
|
||||||
|
def get_access_control(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Reconstruct the old-style access_control JSON dict from grants.
|
||||||
|
For backward compat with the frontend.
|
||||||
|
"""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
grants = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
|
||||||
|
return grants_to_access_control(grant_models)
|
||||||
|
|
||||||
|
def get_grants_by_resource(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
"""Get all grants for a specific resource."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
grants = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [AccessGrantModel.model_validate(g) for g in grants]
|
||||||
|
|
||||||
|
def has_access(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
permission: str = "read",
|
||||||
|
user_group_ids: Optional[set[str]] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has the specified permission on a resource.
|
||||||
|
|
||||||
|
Access is granted if any of the following is true:
|
||||||
|
- There's a grant for user:* (public) with the requested permission
|
||||||
|
- There's a grant for the specific user with the requested permission
|
||||||
|
- There's a grant for any of the user's groups with the requested permission
|
||||||
|
"""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# Build conditions for matching grants
|
||||||
|
conditions = [
|
||||||
|
# Public access
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == "*",
|
||||||
|
),
|
||||||
|
# Direct user access
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == user_id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Group access
|
||||||
|
if user_group_ids is None:
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
|
||||||
|
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
user_group_ids = {group.id for group in user_groups}
|
||||||
|
|
||||||
|
if user_group_ids:
|
||||||
|
conditions.append(
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "group",
|
||||||
|
AccessGrant.principal_id.in_(user_group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
exists = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter(
|
||||||
|
AccessGrant.resource_type == resource_type,
|
||||||
|
AccessGrant.resource_id == resource_id,
|
||||||
|
AccessGrant.permission == permission,
|
||||||
|
or_(*conditions),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return exists is not None
|
||||||
|
|
||||||
|
def get_accessible_resource_ids(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_ids: list[str],
|
||||||
|
permission: str = "read",
|
||||||
|
user_group_ids: Optional[set[str]] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> set[str]:
|
||||||
|
"""
|
||||||
|
Batch check: return the subset of resource_ids that the user can access.
|
||||||
|
|
||||||
|
This replaces calling has_access() in a loop (N+1) with a single query.
|
||||||
|
"""
|
||||||
|
if not resource_ids:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
conditions = [
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == "*",
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == user_id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if user_group_ids is None:
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
|
||||||
|
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
user_group_ids = {group.id for group in user_groups}
|
||||||
|
|
||||||
|
if user_group_ids:
|
||||||
|
conditions.append(
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "group",
|
||||||
|
AccessGrant.principal_id.in_(user_group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(AccessGrant.resource_id)
|
||||||
|
.filter(
|
||||||
|
AccessGrant.resource_type == resource_type,
|
||||||
|
AccessGrant.resource_id.in_(resource_ids),
|
||||||
|
AccessGrant.permission == permission,
|
||||||
|
or_(*conditions),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {row[0] for row in rows}
|
||||||
|
|
||||||
|
def get_users_with_access(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
permission: str = "read",
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
Get all users who have the specified permission on a resource.
|
||||||
|
Returns a list of UserModel instances.
|
||||||
|
"""
|
||||||
|
from open_webui.models.users import Users, UserModel
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
grants = (
|
||||||
|
db.query(AccessGrant)
|
||||||
|
.filter_by(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
permission=permission,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for public access
|
||||||
|
for grant in grants:
|
||||||
|
if grant.principal_type == "user" and grant.principal_id == "*":
|
||||||
|
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
|
||||||
|
return result.get("users", [])
|
||||||
|
|
||||||
|
user_ids_with_access = set()
|
||||||
|
|
||||||
|
for grant in grants:
|
||||||
|
if grant.principal_type == "user":
|
||||||
|
user_ids_with_access.add(grant.principal_id)
|
||||||
|
elif grant.principal_type == "group":
|
||||||
|
group_user_ids = Groups.get_group_user_ids_by_id(
|
||||||
|
grant.principal_id, db=db
|
||||||
|
)
|
||||||
|
if group_user_ids:
|
||||||
|
user_ids_with_access.update(group_user_ids)
|
||||||
|
|
||||||
|
if not user_ids_with_access:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
|
||||||
|
|
||||||
|
def has_permission_filter(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
DocumentModel,
|
||||||
|
filter: dict,
|
||||||
|
resource_type: str,
|
||||||
|
permission: str = "read",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
|
||||||
|
|
||||||
|
This replaces the old JSON-column-based filtering with a proper relational JOIN.
|
||||||
|
"""
|
||||||
|
group_ids = filter.get("group_ids", [])
|
||||||
|
user_id = filter.get("user_id")
|
||||||
|
|
||||||
|
if permission == "read_only":
|
||||||
|
return self._has_read_only_permission_filter(
|
||||||
|
db, query, DocumentModel, filter, resource_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build principal conditions
|
||||||
|
principal_conditions = []
|
||||||
|
|
||||||
|
if group_ids or user_id:
|
||||||
|
# Public access: user:* read
|
||||||
|
principal_conditions.append(
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == "*",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
# Owner always has access
|
||||||
|
principal_conditions.append(DocumentModel.user_id == user_id)
|
||||||
|
|
||||||
|
# Direct user grant
|
||||||
|
principal_conditions.append(
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_ids:
|
||||||
|
# Group grants
|
||||||
|
principal_conditions.append(
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "group",
|
||||||
|
AccessGrant.principal_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not principal_conditions:
|
||||||
|
return query
|
||||||
|
|
||||||
|
# LEFT JOIN access_grant and filter
|
||||||
|
# We use a subquery approach to avoid duplicates from multiple matching grants
|
||||||
|
from sqlalchemy import exists as sa_exists, select
|
||||||
|
|
||||||
|
grant_exists = (
|
||||||
|
select(AccessGrant.id)
|
||||||
|
.where(
|
||||||
|
AccessGrant.resource_type == resource_type,
|
||||||
|
AccessGrant.resource_id == DocumentModel.id,
|
||||||
|
AccessGrant.permission == permission,
|
||||||
|
or_(
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == "*",
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == user_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if user_id
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "group",
|
||||||
|
AccessGrant.principal_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if group_ids
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.correlate(DocumentModel)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Owner OR has a matching grant
|
||||||
|
owner_or_grant = [grant_exists]
|
||||||
|
if user_id:
|
||||||
|
owner_or_grant.append(DocumentModel.user_id == user_id)
|
||||||
|
|
||||||
|
query = query.filter(or_(*owner_or_grant))
|
||||||
|
return query
|
||||||
|
|
||||||
|
def _has_read_only_permission_filter(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
DocumentModel,
|
||||||
|
filter: dict,
|
||||||
|
resource_type: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Filter for items where user has read BUT NOT write access.
|
||||||
|
Public items are NOT considered read_only.
|
||||||
|
"""
|
||||||
|
group_ids = filter.get("group_ids", [])
|
||||||
|
user_id = filter.get("user_id")
|
||||||
|
|
||||||
|
from sqlalchemy import exists as sa_exists, select
|
||||||
|
|
||||||
|
# Has read grant (not public)
|
||||||
|
read_grant_exists = (
|
||||||
|
select(AccessGrant.id)
|
||||||
|
.where(
|
||||||
|
AccessGrant.resource_type == resource_type,
|
||||||
|
AccessGrant.resource_id == DocumentModel.id,
|
||||||
|
AccessGrant.permission == "read",
|
||||||
|
or_(
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == user_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if user_id
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "group",
|
||||||
|
AccessGrant.principal_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if group_ids
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.correlate(DocumentModel)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Does NOT have write grant
|
||||||
|
write_grant_exists = (
|
||||||
|
select(AccessGrant.id)
|
||||||
|
.where(
|
||||||
|
AccessGrant.resource_type == resource_type,
|
||||||
|
AccessGrant.resource_id == DocumentModel.id,
|
||||||
|
AccessGrant.permission == "write",
|
||||||
|
or_(
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == user_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if user_id
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
and_(
|
||||||
|
AccessGrant.principal_type == "group",
|
||||||
|
AccessGrant.principal_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if group_ids
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.correlate(DocumentModel)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Is NOT public
|
||||||
|
public_grant_exists = (
|
||||||
|
select(AccessGrant.id)
|
||||||
|
.where(
|
||||||
|
AccessGrant.resource_type == resource_type,
|
||||||
|
AccessGrant.resource_id == DocumentModel.id,
|
||||||
|
AccessGrant.permission == "read",
|
||||||
|
AccessGrant.principal_type == "user",
|
||||||
|
AccessGrant.principal_id == "*",
|
||||||
|
)
|
||||||
|
.correlate(DocumentModel)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
|
||||||
|
|
||||||
|
# Not owner
|
||||||
|
if user_id:
|
||||||
|
conditions.append(DocumentModel.user_id != user_id)
|
||||||
|
|
||||||
|
query = query.filter(and_(*conditions))
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
AccessGrants = AccessGrantsTable()
|
||||||
@@ -2,15 +2,14 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.models.users import UserModel, Users
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||||
from pydantic import BaseModel
|
from open_webui.utils.validate import validate_profile_image_url
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
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"])
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# DB MODEL
|
# DB MODEL
|
||||||
@@ -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
|
||||||
@@ -89,6 +75,13 @@ class SignupForm(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
profile_image_url: Optional[str] = "/user.png"
|
profile_image_url: Optional[str] = "/user.png"
|
||||||
|
|
||||||
|
@field_validator("profile_image_url")
|
||||||
|
@classmethod
|
||||||
|
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is not None:
|
||||||
|
return validate_profile_image_url(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class AddUserForm(SignupForm):
|
class AddUserForm(SignupForm):
|
||||||
role: Optional[str] = "pending"
|
role: Optional[str] = "pending"
|
||||||
@@ -102,9 +95,10 @@ 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,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
log.info("insert_new_auth")
|
log.info("insert_new_auth")
|
||||||
|
|
||||||
id = str(uuid.uuid4())
|
id = str(uuid.uuid4())
|
||||||
@@ -116,7 +110,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=db
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -127,18 +121,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, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
log.info(f"authenticate_user: {email}")
|
log.info(f"authenticate_user: {email}")
|
||||||
|
|
||||||
user = Users.get_user_by_email(email)
|
user = Users.get_user_by_email(email, db=db)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
auth = db.query(Auth).filter_by(id=user.id, 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):
|
||||||
return user
|
return user
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@@ -147,32 +143,45 @@ class AuthsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def authenticate_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
|
def authenticate_user_by_api_key(
|
||||||
|
self, api_key: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
log.info(f"authenticate_user_by_api_key: {api_key}")
|
log.info(f"authenticate_user_by_api_key: {api_key}")
|
||||||
# if no api_key, return None
|
# if no api_key, return None
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = Users.get_user_by_api_key(api_key)
|
user = Users.get_user_by_api_key(api_key, db=db)
|
||||||
return user if user else None
|
return user if user else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
|
def authenticate_user_by_email(
|
||||||
|
self, email: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
log.info(f"authenticate_user_by_email: {email}")
|
log.info(f"authenticate_user_by_email: {email}")
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
# Single JOIN query instead of two separate queries
|
||||||
if auth:
|
result = (
|
||||||
user = Users.get_user_by_id(auth.id)
|
db.query(Auth, User)
|
||||||
return user
|
.join(User, Auth.id == User.id)
|
||||||
|
.filter(Auth.email == email, Auth.active == True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
_, user = result
|
||||||
|
return UserModel.model_validate(user)
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_password_by_id(self, id: str, new_password: str) -> bool:
|
def update_user_password_by_id(
|
||||||
|
self, id: str, new_password: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
result = (
|
result = (
|
||||||
db.query(Auth).filter_by(id=id).update({"password": new_password})
|
db.query(Auth).filter_by(id=id).update({"password": new_password})
|
||||||
)
|
)
|
||||||
@@ -181,20 +190,22 @@ class AuthsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_email_by_id(self, id: str, email: str) -> bool:
|
def update_email_by_id(
|
||||||
|
self, id: str, email: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
result = db.query(Auth).filter_by(id=id).update({"email": email})
|
result = db.query(Auth).filter_by(id=id).update({"email": email})
|
||||||
db.commit()
|
db.commit()
|
||||||
return True if result == 1 else False
|
return True if result == 1 else False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_auth_by_id(self, id: str) -> bool:
|
def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
# Delete User
|
# Delete User
|
||||||
result = Users.delete_user_by_id(id)
|
result = Users.delete_user_by_id(id, db=db)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
db.query(Auth).filter_by(id=id).delete()
|
db.query(Auth).filter_by(id=id).delete()
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,644 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, get_db_context
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
Text,
|
||||||
|
JSON,
|
||||||
|
Index,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Helpers
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_timestamp(timestamp: int) -> float:
|
||||||
|
"""Normalize and validate timestamp. Returns current time if invalid."""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Convert milliseconds to seconds if needed
|
||||||
|
if timestamp > 10_000_000_000:
|
||||||
|
timestamp = timestamp / 1000
|
||||||
|
|
||||||
|
# Validate: must be after 2020 and not in the future (with 1 day tolerance)
|
||||||
|
min_valid = 1577836800 # 2020-01-01 00:00:00 UTC
|
||||||
|
max_valid = now + 86400 # 1 day in the future (clock skew tolerance)
|
||||||
|
|
||||||
|
if timestamp < min_valid or timestamp > max_valid:
|
||||||
|
return now
|
||||||
|
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# ChatMessage DB Schema
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(Base):
|
||||||
|
__tablename__ = "chat_message"
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
chat_id = Column(
|
||||||
|
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
user_id = Column(Text, index=True)
|
||||||
|
|
||||||
|
# Structure
|
||||||
|
role = Column(Text, nullable=False) # user, assistant, system
|
||||||
|
parent_id = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
content = Column(JSON, nullable=True) # Can be str or list of blocks
|
||||||
|
output = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Model (for assistant messages)
|
||||||
|
model_id = Column(Text, nullable=True, index=True)
|
||||||
|
|
||||||
|
# Attachments
|
||||||
|
files = Column(JSON, nullable=True)
|
||||||
|
sources = Column(JSON, nullable=True)
|
||||||
|
embeds = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
done = Column(Boolean, default=True)
|
||||||
|
status_history = Column(JSON, nullable=True)
|
||||||
|
error = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Usage (tokens, timing, etc.)
|
||||||
|
usage = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(BigInteger, index=True)
|
||||||
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("chat_message_chat_parent_idx", "chat_id", "parent_id"),
|
||||||
|
Index("chat_message_model_created_idx", "model_id", "created_at"),
|
||||||
|
Index("chat_message_user_created_idx", "user_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Pydantic Models
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
chat_id: str
|
||||||
|
user_id: str
|
||||||
|
role: str
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
content: Optional[Any] = None # str or list of blocks
|
||||||
|
output: Optional[list] = None
|
||||||
|
model_id: Optional[str] = None
|
||||||
|
files: Optional[list] = None
|
||||||
|
sources: Optional[list] = None
|
||||||
|
embeds: Optional[list] = None
|
||||||
|
done: bool = True
|
||||||
|
status_history: Optional[list] = None
|
||||||
|
error: Optional[dict | str] = None
|
||||||
|
usage: Optional[dict] = None
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Table Operations
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageTable:
|
||||||
|
def upsert_message(
|
||||||
|
self,
|
||||||
|
message_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
user_id: str,
|
||||||
|
data: dict,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[ChatMessageModel]:
|
||||||
|
"""Insert or update a chat message."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
now = int(time.time())
|
||||||
|
timestamp = data.get("timestamp", now)
|
||||||
|
|
||||||
|
# Use composite ID: {chat_id}-{message_id}
|
||||||
|
composite_id = f"{chat_id}-{message_id}"
|
||||||
|
|
||||||
|
existing = db.get(ChatMessage, composite_id)
|
||||||
|
if existing:
|
||||||
|
# Update existing
|
||||||
|
if "role" in data:
|
||||||
|
existing.role = data["role"]
|
||||||
|
if "parent_id" in data:
|
||||||
|
existing.parent_id = data.get("parent_id") or data.get("parentId")
|
||||||
|
if "content" in data:
|
||||||
|
existing.content = data.get("content")
|
||||||
|
if "output" in data:
|
||||||
|
existing.output = data.get("output")
|
||||||
|
if "model_id" in data or "model" in data:
|
||||||
|
existing.model_id = data.get("model_id") or data.get("model")
|
||||||
|
if "files" in data:
|
||||||
|
existing.files = data.get("files")
|
||||||
|
if "sources" in data:
|
||||||
|
existing.sources = data.get("sources")
|
||||||
|
if "embeds" in data:
|
||||||
|
existing.embeds = data.get("embeds")
|
||||||
|
if "done" in data:
|
||||||
|
existing.done = data.get("done", True)
|
||||||
|
if "status_history" in data or "statusHistory" in data:
|
||||||
|
existing.status_history = data.get("status_history") or data.get(
|
||||||
|
"statusHistory"
|
||||||
|
)
|
||||||
|
if "error" in data:
|
||||||
|
existing.error = data.get("error")
|
||||||
|
# Extract usage - check direct field first, then info.usage
|
||||||
|
usage = data.get("usage")
|
||||||
|
if not usage:
|
||||||
|
info = data.get("info", {})
|
||||||
|
usage = info.get("usage") if info else None
|
||||||
|
if usage:
|
||||||
|
existing.usage = usage
|
||||||
|
existing.updated_at = now
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return ChatMessageModel.model_validate(existing)
|
||||||
|
else:
|
||||||
|
# Insert new
|
||||||
|
# Extract usage - check direct field first, then info.usage
|
||||||
|
usage = data.get("usage")
|
||||||
|
if not usage:
|
||||||
|
info = data.get("info", {})
|
||||||
|
usage = info.get("usage") if info else None
|
||||||
|
message = ChatMessage(
|
||||||
|
id=composite_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=data.get("role", "user"),
|
||||||
|
parent_id=data.get("parent_id") or data.get("parentId"),
|
||||||
|
content=data.get("content"),
|
||||||
|
output=data.get("output"),
|
||||||
|
model_id=data.get("model_id") or data.get("model"),
|
||||||
|
files=data.get("files"),
|
||||||
|
sources=data.get("sources"),
|
||||||
|
embeds=data.get("embeds"),
|
||||||
|
done=data.get("done", True),
|
||||||
|
status_history=data.get("status_history")
|
||||||
|
or data.get("statusHistory"),
|
||||||
|
error=data.get("error"),
|
||||||
|
usage=usage,
|
||||||
|
created_at=timestamp,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(message)
|
||||||
|
return ChatMessageModel.model_validate(message)
|
||||||
|
|
||||||
|
def get_message_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[ChatMessageModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
message = db.get(ChatMessage, id)
|
||||||
|
return ChatMessageModel.model_validate(message) if message else None
|
||||||
|
|
||||||
|
def get_messages_by_chat_id(
|
||||||
|
self, chat_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[ChatMessageModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
messages = (
|
||||||
|
db.query(ChatMessage)
|
||||||
|
.filter_by(chat_id=chat_id)
|
||||||
|
.order_by(ChatMessage.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||||
|
|
||||||
|
def get_messages_by_user_id(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[ChatMessageModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
messages = (
|
||||||
|
db.query(ChatMessage)
|
||||||
|
.filter_by(user_id=user_id)
|
||||||
|
.order_by(ChatMessage.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||||
|
|
||||||
|
def get_messages_by_model_id(
|
||||||
|
self,
|
||||||
|
model_id: str,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[ChatMessageModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query = db.query(ChatMessage).filter_by(model_id=model_id)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
messages = (
|
||||||
|
query.order_by(ChatMessage.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||||
|
|
||||||
|
def get_chat_ids_by_model_id(
|
||||||
|
self,
|
||||||
|
model_id: str,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Get distinct chat_ids that used a specific model."""
|
||||||
|
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query = db.query(
|
||||||
|
ChatMessage.chat_id,
|
||||||
|
func.max(ChatMessage.created_at).label("last_message_at"),
|
||||||
|
).filter(ChatMessage.model_id == model_id)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
|
||||||
|
# Group by chat_id and order by most recent message in each chat
|
||||||
|
chat_ids = (
|
||||||
|
query.group_by(ChatMessage.chat_id)
|
||||||
|
.order_by(func.max(ChatMessage.created_at).desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [chat_id for chat_id, _ in chat_ids]
|
||||||
|
|
||||||
|
def delete_messages_by_chat_id(
|
||||||
|
self, chat_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
db.query(ChatMessage).filter_by(chat_id=chat_id).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Analytics methods
|
||||||
|
def get_message_count_by_model(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
group_id: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from sqlalchemy import func
|
||||||
|
from open_webui.models.groups import GroupMember
|
||||||
|
|
||||||
|
query = db.query(
|
||||||
|
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
|
||||||
|
).filter(
|
||||||
|
ChatMessage.role == "assistant",
|
||||||
|
ChatMessage.model_id.isnot(None),
|
||||||
|
~ChatMessage.user_id.like("shared-%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
if group_id:
|
||||||
|
group_users = (
|
||||||
|
db.query(GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||||
|
|
||||||
|
results = query.group_by(ChatMessage.model_id).all()
|
||||||
|
return {row.model_id: row.count for row in results}
|
||||||
|
|
||||||
|
def get_token_usage_by_model(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
group_id: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, dict]:
|
||||||
|
"""Aggregate token usage by model using database-level aggregation."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from sqlalchemy import func, cast, Integer
|
||||||
|
from open_webui.models.groups import GroupMember
|
||||||
|
|
||||||
|
dialect = db.bind.dialect.name
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
input_tokens = cast(
|
||||||
|
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||||
|
)
|
||||||
|
output_tokens = cast(
|
||||||
|
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||||
|
)
|
||||||
|
elif dialect == "postgresql":
|
||||||
|
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||||
|
input_tokens = cast(
|
||||||
|
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||||
|
Integer,
|
||||||
|
)
|
||||||
|
output_tokens = cast(
|
||||||
|
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||||
|
Integer,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||||
|
|
||||||
|
query = db.query(
|
||||||
|
ChatMessage.model_id,
|
||||||
|
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||||
|
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||||
|
func.count(ChatMessage.id).label("message_count"),
|
||||||
|
).filter(
|
||||||
|
ChatMessage.role == "assistant",
|
||||||
|
ChatMessage.model_id.isnot(None),
|
||||||
|
ChatMessage.usage.isnot(None),
|
||||||
|
~ChatMessage.user_id.like("shared-%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
if group_id:
|
||||||
|
group_users = (
|
||||||
|
db.query(GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||||
|
|
||||||
|
results = query.group_by(ChatMessage.model_id).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
row.model_id: {
|
||||||
|
"input_tokens": row.input_tokens,
|
||||||
|
"output_tokens": row.output_tokens,
|
||||||
|
"total_tokens": row.input_tokens + row.output_tokens,
|
||||||
|
"message_count": row.message_count,
|
||||||
|
}
|
||||||
|
for row in results
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_token_usage_by_user(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, dict]:
|
||||||
|
"""Aggregate token usage by user using database-level aggregation."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from sqlalchemy import func, cast, Integer
|
||||||
|
|
||||||
|
dialect = db.bind.dialect.name
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
input_tokens = cast(
|
||||||
|
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||||
|
)
|
||||||
|
output_tokens = cast(
|
||||||
|
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||||
|
)
|
||||||
|
elif dialect == "postgresql":
|
||||||
|
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||||
|
input_tokens = cast(
|
||||||
|
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||||
|
Integer,
|
||||||
|
)
|
||||||
|
output_tokens = cast(
|
||||||
|
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||||
|
Integer,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||||
|
|
||||||
|
query = db.query(
|
||||||
|
ChatMessage.user_id,
|
||||||
|
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||||
|
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||||
|
func.count(ChatMessage.id).label("message_count"),
|
||||||
|
).filter(
|
||||||
|
ChatMessage.role == "assistant",
|
||||||
|
ChatMessage.user_id.isnot(None),
|
||||||
|
ChatMessage.usage.isnot(None),
|
||||||
|
~ChatMessage.user_id.like("shared-%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
|
||||||
|
results = query.group_by(ChatMessage.user_id).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
row.user_id: {
|
||||||
|
"input_tokens": row.input_tokens,
|
||||||
|
"output_tokens": row.output_tokens,
|
||||||
|
"total_tokens": row.input_tokens + row.output_tokens,
|
||||||
|
"message_count": row.message_count,
|
||||||
|
}
|
||||||
|
for row in results
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_message_count_by_user(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
group_id: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from sqlalchemy import func
|
||||||
|
from open_webui.models.groups import GroupMember
|
||||||
|
|
||||||
|
query = db.query(
|
||||||
|
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
|
||||||
|
).filter(~ChatMessage.user_id.like("shared-%"))
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
if group_id:
|
||||||
|
group_users = (
|
||||||
|
db.query(GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||||
|
|
||||||
|
results = query.group_by(ChatMessage.user_id).all()
|
||||||
|
return {row.user_id: row.count for row in results}
|
||||||
|
|
||||||
|
def get_message_count_by_chat(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
group_id: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from sqlalchemy import func
|
||||||
|
from open_webui.models.groups import GroupMember
|
||||||
|
|
||||||
|
query = db.query(
|
||||||
|
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
|
||||||
|
).filter(~ChatMessage.user_id.like("shared-%"))
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
if group_id:
|
||||||
|
group_users = (
|
||||||
|
db.query(GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||||
|
|
||||||
|
results = query.group_by(ChatMessage.chat_id).all()
|
||||||
|
return {row.chat_id: row.count for row in results}
|
||||||
|
|
||||||
|
def get_daily_message_counts_by_model(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
group_id: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, dict[str, int]]:
|
||||||
|
"""Get message counts grouped by day and model."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from open_webui.models.groups import GroupMember
|
||||||
|
|
||||||
|
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||||
|
ChatMessage.role == "assistant",
|
||||||
|
ChatMessage.model_id.isnot(None),
|
||||||
|
~ChatMessage.user_id.like("shared-%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
if group_id:
|
||||||
|
group_users = (
|
||||||
|
db.query(GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||||
|
|
||||||
|
results = query.all()
|
||||||
|
|
||||||
|
# Group by date -> model -> count
|
||||||
|
daily_counts: dict[str, dict[str, int]] = {}
|
||||||
|
for timestamp, model_id in results:
|
||||||
|
date_str = datetime.fromtimestamp(
|
||||||
|
_normalize_timestamp(timestamp)
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
if date_str not in daily_counts:
|
||||||
|
daily_counts[date_str] = {}
|
||||||
|
daily_counts[date_str][model_id] = (
|
||||||
|
daily_counts[date_str].get(model_id, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fill in missing days
|
||||||
|
if start_date and end_date:
|
||||||
|
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
|
||||||
|
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||||
|
while current <= end_dt:
|
||||||
|
date_str = current.strftime("%Y-%m-%d")
|
||||||
|
if date_str not in daily_counts:
|
||||||
|
daily_counts[date_str] = {}
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return daily_counts
|
||||||
|
|
||||||
|
def get_hourly_message_counts_by_model(
|
||||||
|
self,
|
||||||
|
start_date: Optional[int] = None,
|
||||||
|
end_date: Optional[int] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> dict[str, dict[str, int]]:
|
||||||
|
"""Get message counts grouped by hour and model."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||||
|
ChatMessage.role == "assistant",
|
||||||
|
ChatMessage.model_id.isnot(None),
|
||||||
|
~ChatMessage.user_id.like("shared-%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ChatMessage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ChatMessage.created_at <= end_date)
|
||||||
|
|
||||||
|
results = query.all()
|
||||||
|
|
||||||
|
# Group by hour -> model -> count
|
||||||
|
hourly_counts: dict[str, dict[str, int]] = {}
|
||||||
|
for timestamp, model_id in results:
|
||||||
|
hour_str = datetime.fromtimestamp(
|
||||||
|
_normalize_timestamp(timestamp)
|
||||||
|
).strftime("%Y-%m-%d %H:00")
|
||||||
|
if hour_str not in hourly_counts:
|
||||||
|
hourly_counts[hour_str] = {}
|
||||||
|
hourly_counts[hour_str][model_id] = (
|
||||||
|
hourly_counts[hour_str].get(model_id, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fill in missing hours
|
||||||
|
if start_date and end_date:
|
||||||
|
current = datetime.fromtimestamp(
|
||||||
|
_normalize_timestamp(start_date)
|
||||||
|
).replace(minute=0, second=0, microsecond=0)
|
||||||
|
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||||
|
while current <= end_dt:
|
||||||
|
hour_str = current.strftime("%Y-%m-%d %H:00")
|
||||||
|
if hour_str not in hourly_counts:
|
||||||
|
hourly_counts[hour_str] = {}
|
||||||
|
current += timedelta(hours=1)
|
||||||
|
|
||||||
|
return hourly_counts
|
||||||
|
|
||||||
|
|
||||||
|
ChatMessages = ChatMessageTable()
|
||||||
+867
-196
File diff suppressed because it is too large.
Load diff
@@ -3,15 +3,14 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
from open_webui.models.users import User
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@@ -21,7 +20,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)
|
||||||
@@ -62,6 +61,20 @@ class FeedbackResponse(BaseModel):
|
|||||||
updated_at: int
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackIdResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardFeedbackData(BaseModel):
|
||||||
|
"""Minimal feedback data for leaderboard computation (excludes snapshot/meta)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
data: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class RatingData(BaseModel):
|
class RatingData(BaseModel):
|
||||||
rating: Optional[str | int] = None
|
rating: Optional[str | int] = None
|
||||||
model_id: Optional[str] = None
|
model_id: Optional[str] = None
|
||||||
@@ -92,11 +105,44 @@ 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 ModelHistoryEntry(BaseModel):
|
||||||
|
date: str
|
||||||
|
won: int
|
||||||
|
lost: int
|
||||||
|
|
||||||
|
|
||||||
|
class ModelHistoryResponse(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
history: list[ModelHistoryEntry]
|
||||||
|
|
||||||
|
|
||||||
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, db: Optional[Session] = None
|
||||||
) -> Optional[FeedbackModel]:
|
) -> Optional[FeedbackModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
id = str(uuid.uuid4())
|
id = str(uuid.uuid4())
|
||||||
feedback = FeedbackModel(
|
feedback = FeedbackModel(
|
||||||
**{
|
**{
|
||||||
@@ -121,9 +167,11 @@ class FeedbackTable:
|
|||||||
log.exception(f"Error creating a new feedback: {e}")
|
log.exception(f"Error creating a new feedback: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]:
|
def get_feedback_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[FeedbackModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
feedback = db.query(Feedback).filter_by(id=id).first()
|
feedback = db.query(Feedback).filter_by(id=id).first()
|
||||||
if not feedback:
|
if not feedback:
|
||||||
return None
|
return None
|
||||||
@@ -132,10 +180,10 @@ class FeedbackTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_feedback_by_id_and_user_id(
|
def get_feedback_by_id_and_user_id(
|
||||||
self, id: str, user_id: str
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
) -> Optional[FeedbackModel]:
|
) -> Optional[FeedbackModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
|
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
|
||||||
if not feedback:
|
if not feedback:
|
||||||
return None
|
return None
|
||||||
@@ -143,8 +191,93 @@ class FeedbackTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_all_feedbacks(self) -> list[FeedbackModel]:
|
def get_feedbacks_by_chat_id(
|
||||||
with get_db() as db:
|
self, chat_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[FeedbackModel]:
|
||||||
|
"""Get all feedbacks for a specific chat."""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# meta.chat_id stores the chat reference
|
||||||
|
feedbacks = (
|
||||||
|
db.query(Feedback)
|
||||||
|
.filter(Feedback.meta["chat_id"].as_string() == chat_id)
|
||||||
|
.order_by(Feedback.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [FeedbackModel.model_validate(fb) for fb in feedbacks]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_feedback_items(
|
||||||
|
self,
|
||||||
|
filter: dict = {},
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> FeedbackListResponse:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None) -> list[FeedbackModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FeedbackModel.model_validate(feedback)
|
FeedbackModel.model_validate(feedback)
|
||||||
for feedback in db.query(Feedback)
|
for feedback in db.query(Feedback)
|
||||||
@@ -152,8 +285,110 @@ class FeedbackTable:
|
|||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_feedbacks_by_type(self, type: str) -> list[FeedbackModel]:
|
def get_all_feedback_ids(
|
||||||
with get_db() as db:
|
self, db: Optional[Session] = None
|
||||||
|
) -> list[FeedbackIdResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
return [
|
||||||
|
FeedbackIdResponse(
|
||||||
|
id=row.id,
|
||||||
|
user_id=row.user_id,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
for row in db.query(
|
||||||
|
Feedback.id,
|
||||||
|
Feedback.user_id,
|
||||||
|
Feedback.created_at,
|
||||||
|
Feedback.updated_at,
|
||||||
|
)
|
||||||
|
.order_by(Feedback.updated_at.desc())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_feedbacks_for_leaderboard(
|
||||||
|
self, db: Optional[Session] = None
|
||||||
|
) -> list[LeaderboardFeedbackData]:
|
||||||
|
"""Fetch only id and data for leaderboard computation (excludes snapshot/meta)."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
return [
|
||||||
|
LeaderboardFeedbackData(id=row.id, data=row.data)
|
||||||
|
for row in db.query(Feedback.id, Feedback.data).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_model_evaluation_history(
|
||||||
|
self, model_id: str, days: int = 30, db: Optional[Session] = None
|
||||||
|
) -> list[ModelHistoryEntry]:
|
||||||
|
"""
|
||||||
|
Get daily wins/losses for a specific model over the past N days.
|
||||||
|
If days=0, returns all time data starting from first feedback.
|
||||||
|
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
if days == 0:
|
||||||
|
# All time - no cutoff
|
||||||
|
rows = db.query(Feedback.created_at, Feedback.data).all()
|
||||||
|
else:
|
||||||
|
cutoff = int(time.time()) - (days * 86400)
|
||||||
|
rows = (
|
||||||
|
db.query(Feedback.created_at, Feedback.data)
|
||||||
|
.filter(Feedback.created_at >= cutoff)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_counts = defaultdict(lambda: {"won": 0, "lost": 0})
|
||||||
|
first_date = None
|
||||||
|
|
||||||
|
for created_at, data in rows:
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
if data.get("model_id") != model_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rating_str = str(data.get("rating", ""))
|
||||||
|
if rating_str not in ("1", "-1"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d")
|
||||||
|
if rating_str == "1":
|
||||||
|
daily_counts[date_str]["won"] += 1
|
||||||
|
else:
|
||||||
|
daily_counts[date_str]["lost"] += 1
|
||||||
|
|
||||||
|
# Track first date for this model
|
||||||
|
if first_date is None or date_str < first_date:
|
||||||
|
first_date = date_str
|
||||||
|
|
||||||
|
# Generate date range
|
||||||
|
result = []
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
if days == 0 and first_date:
|
||||||
|
# All time: start from first feedback date
|
||||||
|
start_date = datetime.strptime(first_date, "%Y-%m-%d").date()
|
||||||
|
num_days = (today - start_date).days + 1
|
||||||
|
else:
|
||||||
|
# Fixed range
|
||||||
|
num_days = days
|
||||||
|
start_date = today - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
for i in range(num_days):
|
||||||
|
d = start_date + timedelta(days=i)
|
||||||
|
date_str = d.strftime("%Y-%m-%d")
|
||||||
|
counts = daily_counts.get(date_str, {"won": 0, "lost": 0})
|
||||||
|
result.append(
|
||||||
|
ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"])
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_feedbacks_by_type(
|
||||||
|
self, type: str, db: Optional[Session] = None
|
||||||
|
) -> list[FeedbackModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FeedbackModel.model_validate(feedback)
|
FeedbackModel.model_validate(feedback)
|
||||||
for feedback in db.query(Feedback)
|
for feedback in db.query(Feedback)
|
||||||
@@ -162,8 +397,10 @@ class FeedbackTable:
|
|||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_feedbacks_by_user_id(self, user_id: str) -> list[FeedbackModel]:
|
def get_feedbacks_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[FeedbackModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FeedbackModel.model_validate(feedback)
|
FeedbackModel.model_validate(feedback)
|
||||||
for feedback in db.query(Feedback)
|
for feedback in db.query(Feedback)
|
||||||
@@ -173,9 +410,9 @@ class FeedbackTable:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def update_feedback_by_id(
|
def update_feedback_by_id(
|
||||||
self, id: str, form_data: FeedbackForm
|
self, id: str, form_data: FeedbackForm, db: Optional[Session] = None
|
||||||
) -> Optional[FeedbackModel]:
|
) -> Optional[FeedbackModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
feedback = db.query(Feedback).filter_by(id=id).first()
|
feedback = db.query(Feedback).filter_by(id=id).first()
|
||||||
if not feedback:
|
if not feedback:
|
||||||
return None
|
return None
|
||||||
@@ -193,9 +430,13 @@ class FeedbackTable:
|
|||||||
return FeedbackModel.model_validate(feedback)
|
return FeedbackModel.model_validate(feedback)
|
||||||
|
|
||||||
def update_feedback_by_id_and_user_id(
|
def update_feedback_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, form_data: FeedbackForm
|
self,
|
||||||
|
id: str,
|
||||||
|
user_id: str,
|
||||||
|
form_data: FeedbackForm,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[FeedbackModel]:
|
) -> Optional[FeedbackModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
|
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
|
||||||
if not feedback:
|
if not feedback:
|
||||||
return None
|
return None
|
||||||
@@ -212,8 +453,8 @@ class FeedbackTable:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return FeedbackModel.model_validate(feedback)
|
return FeedbackModel.model_validate(feedback)
|
||||||
|
|
||||||
def delete_feedback_by_id(self, id: str) -> bool:
|
def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
feedback = db.query(Feedback).filter_by(id=id).first()
|
feedback = db.query(Feedback).filter_by(id=id).first()
|
||||||
if not feedback:
|
if not feedback:
|
||||||
return False
|
return False
|
||||||
@@ -221,8 +462,10 @@ class FeedbackTable:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_feedback_by_id_and_user_id(self, id: str, user_id: str) -> bool:
|
def delete_feedback_by_id_and_user_id(
|
||||||
with get_db() as db:
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
with get_db_context(db) as db:
|
||||||
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
|
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
|
||||||
if not feedback:
|
if not feedback:
|
||||||
return False
|
return False
|
||||||
@@ -230,25 +473,19 @@ class FeedbackTable:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_feedbacks_by_user_id(self, user_id: str) -> bool:
|
def delete_feedbacks_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
|
) -> bool:
|
||||||
if not feedbacks:
|
with get_db_context(db) as db:
|
||||||
return False
|
result = db.query(Feedback).filter_by(user_id=user_id).delete()
|
||||||
for feedback in feedbacks:
|
|
||||||
db.delete(feedback)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return result > 0
|
||||||
|
|
||||||
def delete_all_feedbacks(self) -> bool:
|
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
feedbacks = db.query(Feedback).all()
|
result = db.query(Feedback).delete()
|
||||||
if not feedbacks:
|
|
||||||
return False
|
|
||||||
for feedback in feedbacks:
|
|
||||||
db.delete(feedback)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return result > 0
|
||||||
|
|
||||||
|
|
||||||
Feedbacks = FeedbackTable()
|
Feedbacks = FeedbackTable()
|
||||||
@@ -2,13 +2,12 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Files DB Schema
|
# Files DB Schema
|
||||||
@@ -17,7 +16,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)
|
||||||
|
|
||||||
@@ -27,8 +26,6 @@ class File(Base):
|
|||||||
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)
|
|
||||||
|
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
@@ -46,8 +43,6 @@ class FileModel(BaseModel):
|
|||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
access_control: Optional[dict] = None
|
|
||||||
|
|
||||||
created_at: Optional[int] # timestamp in epoch
|
created_at: Optional[int] # timestamp in epoch
|
||||||
updated_at: Optional[int] # timestamp in epoch
|
updated_at: Optional[int] # timestamp in epoch
|
||||||
|
|
||||||
@@ -64,6 +59,25 @@ class FileMeta(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_meta(cls, data):
|
||||||
|
"""Sanitize metadata fields to handle malformed legacy data."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Handle content_type that may be a list like ['application/pdf', None]
|
||||||
|
content_type = data.get("content_type")
|
||||||
|
if isinstance(content_type, list):
|
||||||
|
# Extract first non-None string value
|
||||||
|
data["content_type"] = next(
|
||||||
|
(item for item in content_type if isinstance(item, str)), None
|
||||||
|
)
|
||||||
|
elif content_type is not None and not isinstance(content_type, str):
|
||||||
|
data["content_type"] = None
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class FileModelResponse(BaseModel):
|
class FileModelResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -75,14 +89,15 @@ class FileModelResponse(BaseModel):
|
|||||||
meta: FileMeta
|
meta: FileMeta
|
||||||
|
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class FileMetadataResponse(BaseModel):
|
class FileMetadataResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
meta: dict
|
hash: Optional[str] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
|
|
||||||
@@ -94,12 +109,24 @@ class FileForm(BaseModel):
|
|||||||
path: str
|
path: str
|
||||||
data: dict = {}
|
data: dict = {}
|
||||||
meta: dict = {}
|
meta: dict = {}
|
||||||
access_control: Optional[dict] = None
|
|
||||||
|
|
||||||
|
class FileUpdateForm(BaseModel):
|
||||||
|
hash: Optional[str] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileListResponse(BaseModel):
|
||||||
|
items: list[FileModel]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class FilesTable:
|
class FilesTable:
|
||||||
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
|
def insert_new_file(
|
||||||
with get_db() as db:
|
self, user_id: str, form_data: FileForm, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
file = FileModel(
|
file = FileModel(
|
||||||
**{
|
**{
|
||||||
**form_data.model_dump(),
|
**form_data.model_dump(),
|
||||||
@@ -122,20 +149,41 @@ class FilesTable:
|
|||||||
log.exception(f"Error inserting a new file: {e}")
|
log.exception(f"Error inserting a new file: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_file_by_id(self, id: str) -> Optional[FileModel]:
|
def get_file_by_id(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
try:
|
||||||
|
file = db.get(File, id)
|
||||||
|
return FileModel.model_validate(file)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_file_by_id_and_user_id(
|
||||||
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
file = db.get(File, id)
|
file = db.query(File).filter_by(id=id, user_id=user_id).first()
|
||||||
return FileModel.model_validate(file)
|
if file:
|
||||||
|
return FileModel.model_validate(file)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
|
def get_file_metadata_by_id(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileMetadataResponse]:
|
||||||
|
with get_db_context(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,
|
||||||
@@ -143,12 +191,25 @@ class FilesTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_files(self) -> list[FileModel]:
|
def get_files(self, db: Optional[Session] = None) -> list[FileModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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 get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
|
def check_access_by_user_id(
|
||||||
with get_db() as db:
|
self, id, user_id, permission="write", db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
file = self.get_file_by_id(id, db=db)
|
||||||
|
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], db: Optional[Session] = None
|
||||||
|
) -> list[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FileModel.model_validate(file)
|
FileModel.model_validate(file)
|
||||||
for file in db.query(File)
|
for file in db.query(File)
|
||||||
@@ -157,62 +218,165 @@ class FilesTable:
|
|||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_file_metadatas_by_ids(self, ids: list[str]) -> list[FileMetadataResponse]:
|
def get_file_metadatas_by_ids(
|
||||||
with get_db() as db:
|
self, ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> list[FileMetadataResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
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()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_files_by_user_id(self, user_id: str) -> list[FileModel]:
|
def get_files_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FileModel.model_validate(file)
|
FileModel.model_validate(file)
|
||||||
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_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]:
|
@staticmethod
|
||||||
with get_db() as db:
|
def _glob_to_like_pattern(glob: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a glob/fnmatch pattern to a SQL LIKE pattern.
|
||||||
|
|
||||||
|
Escapes SQL special characters and converts glob wildcards:
|
||||||
|
- `*` becomes `%` (match any sequence of characters)
|
||||||
|
- `?` becomes `_` (match exactly one character)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
glob: A glob pattern (e.g., "*.txt", "file?.doc")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A SQL LIKE compatible pattern with proper escaping.
|
||||||
|
"""
|
||||||
|
# Escape SQL special characters first, then convert glob wildcards
|
||||||
|
pattern = glob.replace("\\", "\\\\")
|
||||||
|
pattern = pattern.replace("%", "\\%")
|
||||||
|
pattern = pattern.replace("_", "\\_")
|
||||||
|
pattern = pattern.replace("*", "%")
|
||||||
|
pattern = pattern.replace("?", "_")
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
def search_files(
|
||||||
|
self,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
filename: str = "*",
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[FileModel]:
|
||||||
|
"""
|
||||||
|
Search files with glob pattern matching, optional user filter, and pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Filter by user ID. If None, returns files for all users.
|
||||||
|
filename: Glob pattern to match filenames (e.g., "*.txt"). Default "*" matches all.
|
||||||
|
skip: Number of results to skip for pagination.
|
||||||
|
limit: Maximum number of results to return.
|
||||||
|
db: Optional database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching FileModel objects, ordered by updated_at descending.
|
||||||
|
"""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query = db.query(File)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.filter_by(user_id=user_id)
|
||||||
|
|
||||||
|
pattern = self._glob_to_like_pattern(filename)
|
||||||
|
if pattern != "%":
|
||||||
|
query = query.filter(File.filename.ilike(pattern, escape="\\"))
|
||||||
|
|
||||||
|
return [
|
||||||
|
FileModel.model_validate(file)
|
||||||
|
for file in query.order_by(File.updated_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
def update_file_by_id(
|
||||||
|
self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db_context(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: Optional[str], db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.hash = hash
|
file.hash = hash
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_file_data_by_id(self, id: str, data: dict) -> Optional[FileModel]:
|
def update_file_data_by_id(
|
||||||
with get_db() as db:
|
self, id: str, data: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.data = {**(file.data if file.data else {}), **data}
|
file.data = {**(file.data if file.data else {}), **data}
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_file_metadata_by_id(self, id: str, meta: dict) -> Optional[FileModel]:
|
def update_file_metadata_by_id(
|
||||||
with get_db() as db:
|
self, id: str, meta: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[FileModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.meta = {**(file.meta if file.meta else {}), **meta}
|
file.meta = {**(file.meta if file.meta else {}), **meta}
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_file_by_id(self, id: str) -> bool:
|
return False
|
||||||
with get_db() as db:
|
|
||||||
|
def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(File).filter_by(id=id).delete()
|
db.query(File).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -221,8 +385,8 @@ class FilesTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_all_files(self) -> bool:
|
def delete_all_files(self, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(File).delete()
|
db.query(File).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import re
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
|
||||||
from open_webui.models.chats import Chats
|
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
|
||||||
from open_webui.utils.access_control import get_permissions
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@@ -23,7 +21,7 @@ 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)
|
||||||
@@ -50,6 +48,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
|
||||||
####################
|
####################
|
||||||
@@ -58,14 +70,26 @@ class FolderModel(BaseModel):
|
|||||||
class FolderForm(BaseModel):
|
class FolderForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
data: Optional[dict] = None
|
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, form_data: FolderForm, parent_id: Optional[str] = None
|
self,
|
||||||
|
user_id: str,
|
||||||
|
form_data: FolderForm,
|
||||||
|
parent_id: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
id = str(uuid.uuid4())
|
id = str(uuid.uuid4())
|
||||||
folder = FolderModel(
|
folder = FolderModel(
|
||||||
**{
|
**{
|
||||||
@@ -91,10 +115,10 @@ class FolderTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_folder_by_id_and_user_id(
|
def get_folder_by_id_and_user_id(
|
||||||
self, id: str, user_id: str
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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:
|
||||||
@@ -105,15 +129,15 @@ class FolderTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
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, db: Optional[Session] = None
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[list[FolderModel]]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
folders = []
|
folders = []
|
||||||
|
|
||||||
def get_children(folder):
|
def get_children(folder):
|
||||||
children = self.get_folders_by_parent_id_and_user_id(
|
children = self.get_folders_by_parent_id_and_user_id(
|
||||||
folder.id, user_id
|
folder.id, user_id, db=db
|
||||||
)
|
)
|
||||||
for child in children:
|
for child in children:
|
||||||
get_children(child)
|
get_children(child)
|
||||||
@@ -128,18 +152,24 @@ class FolderTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]:
|
def get_folders_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[FolderModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FolderModel.model_validate(folder)
|
FolderModel.model_validate(folder)
|
||||||
for folder in db.query(Folder).filter_by(user_id=user_id).all()
|
for folder in db.query(Folder).filter_by(user_id=user_id).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_folder_by_parent_id_and_user_id_and_name(
|
def get_folder_by_parent_id_and_user_id_and_name(
|
||||||
self, parent_id: Optional[str], user_id: str, name: str
|
self,
|
||||||
|
parent_id: Optional[str],
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
# Check if folder exists
|
# Check if folder exists
|
||||||
folder = (
|
folder = (
|
||||||
db.query(Folder)
|
db.query(Folder)
|
||||||
@@ -157,9 +187,9 @@ class FolderTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_folders_by_parent_id_and_user_id(
|
def get_folders_by_parent_id_and_user_id(
|
||||||
self, parent_id: Optional[str], user_id: str
|
self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None
|
||||||
) -> list[FolderModel]:
|
) -> list[FolderModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FolderModel.model_validate(folder)
|
FolderModel.model_validate(folder)
|
||||||
for folder in db.query(Folder)
|
for folder in db.query(Folder)
|
||||||
@@ -172,9 +202,10 @@ class FolderTable:
|
|||||||
id: str,
|
id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
parent_id: str,
|
parent_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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:
|
||||||
@@ -191,10 +222,14 @@ class FolderTable:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def update_folder_by_id_and_user_id(
|
def update_folder_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, form_data: FolderForm
|
self,
|
||||||
|
id: str,
|
||||||
|
user_id: str,
|
||||||
|
form_data: FolderUpdateForm,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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:
|
||||||
@@ -222,8 +257,13 @@ class FolderTable:
|
|||||||
**form_data["data"],
|
**form_data["data"],
|
||||||
}
|
}
|
||||||
|
|
||||||
folder.updated_at = int(time.time())
|
if "meta" in form_data:
|
||||||
|
folder.meta = {
|
||||||
|
**(folder.meta or {}),
|
||||||
|
**form_data["meta"],
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return FolderModel.model_validate(folder)
|
return FolderModel.model_validate(folder)
|
||||||
@@ -232,10 +272,10 @@ class FolderTable:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def update_folder_is_expanded_by_id_and_user_id(
|
def update_folder_is_expanded_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, is_expanded: bool
|
self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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:
|
||||||
@@ -252,30 +292,26 @@ class FolderTable:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def delete_folder_by_id_and_user_id(
|
def delete_folder_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, delete_chats=True
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
) -> bool:
|
) -> list[str]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
folder_ids = []
|
||||||
|
with get_db_context(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):
|
||||||
folder_children = self.get_folders_by_parent_id_and_user_id(
|
folder_children = self.get_folders_by_parent_id_and_user_id(
|
||||||
folder.id, user_id
|
folder.id, user_id, db=db
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -284,10 +320,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], db: Optional[Session] = None
|
||||||
|
) -> 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_context(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, db=db
|
||||||
|
)
|
||||||
|
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, db: Optional[Session] = None
|
||||||
|
) -> list[FolderModel]:
|
||||||
|
"""
|
||||||
|
Partial match: normalized name contains (as substring) the normalized query.
|
||||||
|
"""
|
||||||
|
normalized_query = self.normalize_folder_name(query)
|
||||||
|
results = []
|
||||||
|
with get_db_context(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()
|
||||||
@@ -2,14 +2,13 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.models.users import Users
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.models.users import Users, UserModel
|
||||||
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"])
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Functions DB Schema
|
# Functions DB Schema
|
||||||
@@ -19,7 +18,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 +30,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 +54,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
|
||||||
@@ -82,7 +104,11 @@ class FunctionValves(BaseModel):
|
|||||||
|
|
||||||
class FunctionsTable:
|
class FunctionsTable:
|
||||||
def insert_new_function(
|
def insert_new_function(
|
||||||
self, user_id: str, type: str, form_data: FunctionForm
|
self,
|
||||||
|
user_id: str,
|
||||||
|
type: str,
|
||||||
|
form_data: FunctionForm,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[FunctionModel]:
|
) -> Optional[FunctionModel]:
|
||||||
function = FunctionModel(
|
function = FunctionModel(
|
||||||
**{
|
**{
|
||||||
@@ -95,7 +121,7 @@ class FunctionsTable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
result = Function(**function.model_dump())
|
result = Function(**function.model_dump())
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -109,11 +135,14 @@ class FunctionsTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def sync_functions(
|
def sync_functions(
|
||||||
self, user_id: str, functions: list[FunctionModel]
|
self,
|
||||||
) -> list[FunctionModel]:
|
user_id: str,
|
||||||
|
functions: list[FunctionWithValvesModel],
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[FunctionWithValvesModel]:
|
||||||
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
|
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
# Get existing functions
|
# Get existing functions
|
||||||
existing_functions = db.query(Function).all()
|
existing_functions = db.query(Function).all()
|
||||||
existing_ids = {func.id for func in existing_functions}
|
existing_ids = {func.id for func in existing_functions}
|
||||||
@@ -156,31 +185,83 @@ class FunctionsTable:
|
|||||||
log.exception(f"Error syncing functions for user {user_id}: {e}")
|
log.exception(f"Error syncing functions for user {user_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
|
def get_function_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[FunctionModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
function = db.get(Function, id)
|
function = db.get(Function, id)
|
||||||
return FunctionModel.model_validate(function)
|
return FunctionModel.model_validate(function)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_functions(self, active_only=False) -> list[FunctionModel]:
|
def get_functions_by_ids(
|
||||||
with get_db() as db:
|
self, ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> list[FunctionModel]:
|
||||||
|
"""
|
||||||
|
Batch fetch multiple functions by their IDs in a single query.
|
||||||
|
Returns functions in the same order as the input IDs (None entries filtered out).
|
||||||
|
"""
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
functions = db.query(Function).filter(Function.id.in_(ids)).all()
|
||||||
|
# Create a dict for O(1) lookup
|
||||||
|
func_dict = {f.id: FunctionModel.model_validate(f) for f in functions}
|
||||||
|
# Return in original order, filtering out any not found
|
||||||
|
return [func_dict[id] for id in ids if id in func_dict]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_functions(
|
||||||
|
self, active_only=False, include_valves=False, db: Optional[Session] = None
|
||||||
|
) -> list[FunctionModel | FunctionWithValvesModel]:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> list[FunctionUserResponse]:
|
||||||
|
with get_db_context(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, db=db) 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, db: Optional[Session] = None
|
||||||
) -> list[FunctionModel]:
|
) -> list[FunctionModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
if active_only:
|
if active_only:
|
||||||
return [
|
return [
|
||||||
FunctionModel.model_validate(function)
|
FunctionModel.model_validate(function)
|
||||||
@@ -194,8 +275,10 @@ class FunctionsTable:
|
|||||||
for function in db.query(Function).filter_by(type=type).all()
|
for function in db.query(Function).filter_by(type=type).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_global_filter_functions(self) -> list[FunctionModel]:
|
def get_global_filter_functions(
|
||||||
with get_db() as db:
|
self, db: Optional[Session] = None
|
||||||
|
) -> list[FunctionModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FunctionModel.model_validate(function)
|
FunctionModel.model_validate(function)
|
||||||
for function in db.query(Function)
|
for function in db.query(Function)
|
||||||
@@ -203,8 +286,10 @@ class FunctionsTable:
|
|||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_global_action_functions(self) -> list[FunctionModel]:
|
def get_global_action_functions(
|
||||||
with get_db() as db:
|
self, db: Optional[Session] = None
|
||||||
|
) -> list[FunctionModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
FunctionModel.model_validate(function)
|
FunctionModel.model_validate(function)
|
||||||
for function in db.query(Function)
|
for function in db.query(Function)
|
||||||
@@ -212,8 +297,10 @@ class FunctionsTable:
|
|||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_function_valves_by_id(self, id: str) -> Optional[dict]:
|
def get_function_valves_by_id(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[dict]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
function = db.get(Function, id)
|
function = db.get(Function, id)
|
||||||
return function.valves if function.valves else {}
|
return function.valves if function.valves else {}
|
||||||
@@ -222,24 +309,47 @@ class FunctionsTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def update_function_valves_by_id(
|
def update_function_valves_by_id(
|
||||||
self, id: str, valves: dict
|
self, id: str, valves: dict, db: Optional[Session] = None
|
||||||
) -> Optional[FunctionValves]:
|
) -> Optional[FunctionValves]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
function = db.get(Function, id)
|
function = db.get(Function, id)
|
||||||
function.valves = valves
|
function.valves = valves
|
||||||
function.updated_at = int(time.time())
|
function.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(function)
|
db.refresh(function)
|
||||||
return self.get_function_by_id(id)
|
return FunctionModel.model_validate(function)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def update_function_metadata_by_id(
|
||||||
|
self, id: str, metadata: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[FunctionModel]:
|
||||||
|
with get_db_context(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 FunctionModel.model_validate(function)
|
||||||
|
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, db: Optional[Session] = None
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
try:
|
try:
|
||||||
user = Users.get_user_by_id(user_id)
|
user = Users.get_user_by_id(user_id, db=db)
|
||||||
user_settings = user.settings.model_dump() if user.settings else {}
|
user_settings = user.settings.model_dump() if user.settings else {}
|
||||||
|
|
||||||
# Check if user has "functions" and "valves" settings
|
# Check if user has "functions" and "valves" settings
|
||||||
@@ -250,16 +360,14 @@ 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(
|
||||||
self, id: str, user_id: str, valves: dict
|
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
try:
|
try:
|
||||||
user = Users.get_user_by_id(user_id)
|
user = Users.get_user_by_id(user_id, db=db)
|
||||||
user_settings = user.settings.model_dump() if user.settings else {}
|
user_settings = user.settings.model_dump() if user.settings else {}
|
||||||
|
|
||||||
# Check if user has "functions" and "valves" settings
|
# Check if user has "functions" and "valves" settings
|
||||||
@@ -271,7 +379,7 @@ class FunctionsTable:
|
|||||||
user_settings["functions"]["valves"][id] = valves
|
user_settings["functions"]["valves"][id] = valves
|
||||||
|
|
||||||
# Update the user settings in the database
|
# Update the user settings in the database
|
||||||
Users.update_user_by_id(user_id, {"settings": user_settings})
|
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
|
||||||
|
|
||||||
return user_settings["functions"]["valves"][id]
|
return user_settings["functions"]["valves"][id]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -280,8 +388,10 @@ class FunctionsTable:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]:
|
def update_function_by_id(
|
||||||
with get_db() as db:
|
self, id: str, updated: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[FunctionModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(Function).filter_by(id=id).update(
|
db.query(Function).filter_by(id=id).update(
|
||||||
{
|
{
|
||||||
@@ -290,12 +400,13 @@ class FunctionsTable:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return self.get_function_by_id(id)
|
function = db.get(Function, id)
|
||||||
|
return FunctionModel.model_validate(function) if function else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def deactivate_all_functions(self) -> Optional[bool]:
|
def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(Function).update(
|
db.query(Function).update(
|
||||||
{
|
{
|
||||||
@@ -308,8 +419,8 @@ class FunctionsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_function_by_id(self, id: str) -> bool:
|
def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(Function).filter_by(id=id).delete()
|
db.query(Function).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -4,18 +4,28 @@ import time
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
|
||||||
from open_webui.models.files import FileMetadataResponse
|
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_,
|
||||||
|
select,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# UserGroup DB Schema
|
# UserGroup DB Schema
|
||||||
@@ -35,14 +45,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,49 +61,79 @@ 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):
|
||||||
|
member_count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInfoResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
permissions: Optional[dict] = None
|
member_count: Optional[int] = None
|
||||||
data: Optional[dict] = None
|
created_at: int
|
||||||
meta: Optional[dict] = None
|
updated_at: int
|
||||||
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):
|
class UserIdsForm(BaseModel):
|
||||||
user_ids: Optional[list[str]] = None
|
user_ids: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class GroupUpdateForm(GroupForm, UserIdsForm):
|
class GroupUpdateForm(GroupForm):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GroupListResponse(BaseModel):
|
||||||
|
items: list[GroupResponse] = []
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
class GroupTable:
|
class GroupTable:
|
||||||
def insert_new_group(
|
def insert_new_group(
|
||||||
self, user_id: str, form_data: GroupForm
|
self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
|
||||||
) -> Optional[GroupModel]:
|
) -> Optional[GroupModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
group = GroupModel(
|
group = GroupModel(
|
||||||
**{
|
**{
|
||||||
**form_data.model_dump(exclude_none=True),
|
**form_data.model_dump(exclude_none=True),
|
||||||
@@ -119,48 +157,266 @@ class GroupTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_groups(self) -> list[GroupModel]:
|
def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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, db: Optional[Session] = None) -> list[GroupResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
member_count = func.count(GroupMember.user_id).label("member_count")
|
||||||
|
query = db.query(Group, member_count).outerjoin(
|
||||||
|
GroupMember, GroupMember.group_id == Group.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
if "query" in filter:
|
||||||
|
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
|
||||||
|
|
||||||
|
# When share filter is present, member check is handled in the share logic
|
||||||
|
if "share" in filter:
|
||||||
|
share_value = filter["share"]
|
||||||
|
member_id = filter.get("member_id")
|
||||||
|
json_share = Group.data["config"]["share"]
|
||||||
|
json_share_str = json_share.as_string()
|
||||||
|
json_share_lower = func.lower(json_share_str)
|
||||||
|
|
||||||
|
if share_value:
|
||||||
|
anyone_can_share = or_(
|
||||||
|
Group.data.is_(None),
|
||||||
|
json_share_str.is_(None),
|
||||||
|
json_share_lower == "true",
|
||||||
|
json_share_lower == "1", # Handle SQLite boolean true
|
||||||
|
)
|
||||||
|
|
||||||
|
if member_id:
|
||||||
|
member_groups_select = select(GroupMember.group_id).where(
|
||||||
|
GroupMember.user_id == member_id
|
||||||
|
)
|
||||||
|
members_only_and_is_member = and_(
|
||||||
|
json_share_lower == "members",
|
||||||
|
Group.id.in_(member_groups_select),
|
||||||
|
)
|
||||||
|
query = query.filter(
|
||||||
|
or_(anyone_can_share, members_only_and_is_member)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.filter(anyone_can_share)
|
||||||
|
else:
|
||||||
|
query = query.filter(
|
||||||
|
and_(Group.data.isnot(None), json_share_lower == "false")
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Only apply member_id filter when share filter is NOT present
|
||||||
|
if "member_id" in filter:
|
||||||
|
query = query.filter(
|
||||||
|
Group.id.in_(
|
||||||
|
select(GroupMember.group_id).where(
|
||||||
|
GroupMember.user_id == filter["member_id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = query.group_by(Group.id).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": count or 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for group, count in results
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
|
def search_groups(
|
||||||
with get_db() as db:
|
self,
|
||||||
|
filter: Optional[dict] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> GroupListResponse:
|
||||||
|
with get_db_context(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.filter(
|
||||||
|
Group.id.in_(
|
||||||
|
select(GroupMember.group_id).where(
|
||||||
|
GroupMember.user_id == filter["member_id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "share" in filter:
|
||||||
|
share_value = filter["share"]
|
||||||
|
query = query.filter(
|
||||||
|
Group.data.op("->>")("share") == str(share_value)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
member_count = func.count(GroupMember.user_id).label("member_count")
|
||||||
|
results = (
|
||||||
|
query.add_columns(member_count)
|
||||||
|
.outerjoin(GroupMember, GroupMember.group_id == Group.id)
|
||||||
|
.group_by(Group.id)
|
||||||
|
.order_by(Group.updated_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
GroupResponse.model_validate(
|
||||||
|
{
|
||||||
|
**GroupModel.model_validate(group).model_dump(),
|
||||||
|
"member_count": count or 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for group, count in results
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_groups_by_member_id(
|
||||||
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[GroupModel]:
|
||||||
|
with get_db_context(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()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_group_by_id(self, id: str) -> Optional[GroupModel]:
|
def get_groups_by_member_ids(
|
||||||
|
self, user_ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> dict[str, list[GroupModel]]:
|
||||||
|
"""Fetch groups for multiple users in a single query to avoid N+1."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# Query GroupMember joined with Group, filtering by user_ids
|
||||||
|
results = (
|
||||||
|
db.query(GroupMember.user_id, Group)
|
||||||
|
.join(Group, Group.id == GroupMember.group_id)
|
||||||
|
.filter(GroupMember.user_id.in_(user_ids))
|
||||||
|
.order_by(Group.updated_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group groups by user_id
|
||||||
|
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
|
||||||
|
for user_id, group in results:
|
||||||
|
user_groups[user_id].append(GroupModel.model_validate(group))
|
||||||
|
|
||||||
|
return user_groups
|
||||||
|
|
||||||
|
def get_group_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[GroupModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
group = db.query(Group).filter_by(id=id).first()
|
group = db.query(Group).filter_by(id=id).first()
|
||||||
return GroupModel.model_validate(group) if group else None
|
return GroupModel.model_validate(group) if group else None
|
||||||
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(
|
||||||
group = self.get_group_by_id(id)
|
self, id: str, db: Optional[Session] = None
|
||||||
if group:
|
) -> list[str]:
|
||||||
return group.user_ids
|
with get_db_context(db) as db:
|
||||||
else:
|
members = (
|
||||||
return None
|
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not members:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [m[0] for m in members]
|
||||||
|
|
||||||
|
def get_group_user_ids_by_ids(
|
||||||
|
self, group_ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
with get_db_context(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], db: Optional[Session] = None
|
||||||
|
) -> None:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> int:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
count = (
|
||||||
|
db.query(func.count(GroupMember.user_id))
|
||||||
|
.filter(GroupMember.group_id == id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
return count if count else 0
|
||||||
|
|
||||||
|
def get_group_member_counts_by_ids(
|
||||||
|
self, ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> dict[str, int]:
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
rows = (
|
||||||
|
db.query(GroupMember.group_id, func.count(GroupMember.user_id))
|
||||||
|
.filter(GroupMember.group_id.in_(ids))
|
||||||
|
.group_by(GroupMember.group_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {group_id: count for group_id, count in rows}
|
||||||
|
|
||||||
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,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[GroupModel]:
|
) -> Optional[GroupModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(Group).filter_by(id=id).update(
|
db.query(Group).filter_by(id=id).update(
|
||||||
{
|
{
|
||||||
**form_data.model_dump(exclude_none=True),
|
**form_data.model_dump(exclude_none=True),
|
||||||
@@ -168,22 +424,22 @@ class GroupTable:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return self.get_group_by_id(id=id)
|
return self.get_group_by_id(id=id, db=db)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_group_by_id(self, id: str) -> bool:
|
def delete_group_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(Group).filter_by(id=id).delete()
|
db.query(Group).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_all_groups(self) -> bool:
|
def delete_all_groups(self, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(Group).delete()
|
db.query(Group).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -192,36 +448,47 @@ class GroupTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def remove_user_from_all_groups(self, user_id: str) -> bool:
|
def remove_user_from_all_groups(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
with get_db_context(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(
|
def create_groups_by_group_names(
|
||||||
self, user_id: str, group_names: list[str]
|
self, user_id: str, group_names: list[str], db: Optional[Session] = None
|
||||||
) -> list[GroupModel]:
|
) -> list[GroupModel]:
|
||||||
|
|
||||||
# check for existing groups
|
# check for existing groups
|
||||||
existing_groups = self.get_groups()
|
existing_groups = self.get_all_groups(db=db)
|
||||||
existing_group_names = {group.name for group in existing_groups}
|
existing_group_names = {group.name for group in existing_groups}
|
||||||
|
|
||||||
new_groups = []
|
new_groups = []
|
||||||
|
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
for group_name in group_names:
|
for group_name in group_names:
|
||||||
if group_name not in existing_group_names:
|
if group_name not in existing_group_names:
|
||||||
new_group = GroupModel(
|
new_group = GroupModel(
|
||||||
@@ -243,86 +510,136 @@ class GroupTable:
|
|||||||
continue
|
continue
|
||||||
return new_groups
|
return new_groups
|
||||||
|
|
||||||
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool:
|
def sync_groups_by_group_names(
|
||||||
with get_db() as db:
|
self, user_id: str, group_names: list[str], db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
groups = db.query(Group).filter(Group.name.in_(group_names)).all()
|
now = int(time.time())
|
||||||
group_ids = [group.id for group in groups]
|
|
||||||
|
|
||||||
# Remove user from groups not in the new list
|
# 1. Groups that SHOULD contain the user
|
||||||
existing_groups = self.get_groups_by_member_id(user_id)
|
target_groups = (
|
||||||
|
db.query(Group).filter(Group.name.in_(group_names)).all()
|
||||||
|
)
|
||||||
|
target_group_ids = {g.id for g in target_groups}
|
||||||
|
|
||||||
for group in existing_groups:
|
# 2. Groups the user is CURRENTLY in
|
||||||
if group.id not in group_ids:
|
existing_group_ids = {
|
||||||
group.user_ids.remove(user_id)
|
g.id
|
||||||
db.query(Group).filter_by(id=group.id).update(
|
for g in db.query(Group)
|
||||||
{
|
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||||
"user_ids": group.user_ids,
|
.filter(GroupMember.user_id == user_id)
|
||||||
"updated_at": int(time.time()),
|
.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,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Add user to new groups
|
if groups_to_add:
|
||||||
for group in groups:
|
db.query(Group).filter(Group.id.in_(groups_to_add)).update(
|
||||||
if user_id not in group.user_ids:
|
{"updated_at": now}, synchronize_session=False
|
||||||
group.user_ids.append(user_id)
|
)
|
||||||
db.query(Group).filter_by(id=group.id).update(
|
|
||||||
{
|
|
||||||
"user_ids": group.user_ids,
|
|
||||||
"updated_at": int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
|
db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def add_users_to_group(
|
def add_users_to_group(
|
||||||
self, id: str, user_ids: Optional[list[str]] = None
|
self,
|
||||||
|
id: str,
|
||||||
|
user_ids: Optional[list[str]] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[GroupModel]:
|
) -> Optional[GroupModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
group = db.query(Group).filter_by(id=id).first()
|
group = db.query(Group).filter_by(id=id).first()
|
||||||
if not group:
|
if not group:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not group.user_ids:
|
now = int(time.time())
|
||||||
group.user_ids = []
|
|
||||||
|
|
||||||
for user_id in user_ids:
|
for user_id in user_ids or []:
|
||||||
if user_id not in group.user_ids:
|
try:
|
||||||
group.user_ids.append(user_id)
|
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 = int(time.time())
|
group.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(group)
|
db.refresh(group)
|
||||||
|
|
||||||
return GroupModel.model_validate(group)
|
return GroupModel.model_validate(group)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def remove_users_from_group(
|
def remove_users_from_group(
|
||||||
self, id: str, user_ids: Optional[list[str]] = None
|
self,
|
||||||
|
id: str,
|
||||||
|
user_ids: Optional[list[str]] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[GroupModel]:
|
) -> Optional[GroupModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
group = db.query(Group).filter_by(id=id).first()
|
group = db.query(Group).filter_by(id=id).first()
|
||||||
if not group:
|
if not group:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not group.user_ids:
|
if not user_ids:
|
||||||
return GroupModel.model_validate(group)
|
return GroupModel.model_validate(group)
|
||||||
|
|
||||||
for user_id in user_ids:
|
# Remove users from group_member in batch
|
||||||
if user_id in group.user_ids:
|
db.query(GroupMember).filter(
|
||||||
group.user_ids.remove(user_id)
|
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Update group timestamp
|
||||||
group.updated_at = int(time.time())
|
group.updated_at = int(time.time())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(group)
|
db.refresh(group)
|
||||||
return GroupModel.model_validate(group)
|
return GroupModel.model_validate(group)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,20 +4,33 @@ import time
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
|
||||||
from open_webui.models.files import FileMetadataResponse
|
from open_webui.models.files import (
|
||||||
from open_webui.models.users import Users, UserResponse
|
File,
|
||||||
|
FileModel,
|
||||||
|
FileMetadataResponse,
|
||||||
|
FileModelResponse,
|
||||||
|
)
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||||
|
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
from open_webui.utils.access_control import has_access
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
JSON,
|
||||||
|
UniqueConstraint,
|
||||||
|
or_,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Knowledge DB Schema
|
# Knowledge DB Schema
|
||||||
@@ -33,26 +46,8 @@ 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.
|
|
||||||
# Defines access control rules for this entry.
|
|
||||||
# - `None`: Public access, available to all users with the "user" role.
|
|
||||||
# - `{}`: Private access, restricted exclusively to the owner.
|
|
||||||
# - Custom permissions: Specific access control for reading and writing;
|
|
||||||
# Can specify group or user-level restrictions:
|
|
||||||
# {
|
|
||||||
# "read": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# },
|
|
||||||
# "write": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
@@ -66,20 +61,50 @@ 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_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
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
|
||||||
|
|
||||||
@@ -89,103 +114,544 @@ class KnowledgeResponse(KnowledgeModel):
|
|||||||
|
|
||||||
|
|
||||||
class KnowledgeUserResponse(KnowledgeUserModel):
|
class KnowledgeUserResponse(KnowledgeUserModel):
|
||||||
files: Optional[list[FileMetadataResponse | dict]] = None
|
pass
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeForm(BaseModel):
|
class KnowledgeForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
data: Optional[dict] = None
|
access_grants: Optional[list[dict]] = None
|
||||||
access_control: Optional[dict] = None
|
|
||||||
|
|
||||||
|
class FileUserResponse(FileModelResponse):
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeListResponse(BaseModel):
|
||||||
|
items: list[KnowledgeUserModel]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFileListResponse(BaseModel):
|
||||||
|
items: list[FileUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeTable:
|
class KnowledgeTable:
|
||||||
|
def _get_access_grants(
|
||||||
|
self, knowledge_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
|
||||||
|
|
||||||
|
def _to_knowledge_model(
|
||||||
|
self, knowledge: Knowledge, db: Optional[Session] = None
|
||||||
|
) -> KnowledgeModel:
|
||||||
|
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
|
||||||
|
exclude={"access_grants"}
|
||||||
|
)
|
||||||
|
knowledge_data["access_grants"] = self._get_access_grants(
|
||||||
|
knowledge_data["id"], db=db
|
||||||
|
)
|
||||||
|
return KnowledgeModel.model_validate(knowledge_data)
|
||||||
|
|
||||||
def insert_new_knowledge(
|
def insert_new_knowledge(
|
||||||
self, user_id: str, form_data: KnowledgeForm
|
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
|
||||||
) -> Optional[KnowledgeModel]:
|
) -> Optional[KnowledgeModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
knowledge = KnowledgeModel(
|
knowledge = KnowledgeModel(
|
||||||
**{
|
**{
|
||||||
**form_data.model_dump(),
|
**form_data.model_dump(exclude={"access_grants"}),
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
|
"access_grants": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = Knowledge(**knowledge.model_dump(exclude={"access_grants"}))
|
||||||
|
db.add(result)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(result)
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"knowledge", result.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return self._to_knowledge_model(result, db=db)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_knowledge_bases(
|
||||||
|
self, skip: int = 0, limit: int = 30, db: Optional[Session] = None
|
||||||
|
) -> list[KnowledgeUserModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
all_knowledge = (
|
||||||
|
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
||||||
|
)
|
||||||
|
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
|
knowledge_bases = []
|
||||||
|
for knowledge in all_knowledge:
|
||||||
|
user = users_dict.get(knowledge.user_id)
|
||||||
|
knowledge_bases.append(
|
||||||
|
KnowledgeUserModel.model_validate(
|
||||||
|
{
|
||||||
|
**self._to_knowledge_model(knowledge, db=db).model_dump(),
|
||||||
|
"user": user.model_dump() if user else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return knowledge_bases
|
||||||
|
|
||||||
|
def search_knowledge_bases(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> KnowledgeListResponse:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query = db.query(Knowledge, User).outerjoin(
|
||||||
|
User, User.id == Knowledge.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Knowledge.name.ilike(f"%{query_key}%"),
|
||||||
|
Knowledge.description.ilike(f"%{query_key}%"),
|
||||||
|
User.name.ilike(f"%{query_key}%"),
|
||||||
|
User.email.ilike(f"%{query_key}%"),
|
||||||
|
User.username.ilike(f"%{query_key}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Knowledge.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(Knowledge.user_id != user_id)
|
||||||
|
|
||||||
|
query = AccessGrants.has_permission_filter(
|
||||||
|
db=db,
|
||||||
|
query=query,
|
||||||
|
DocumentModel=Knowledge,
|
||||||
|
filter=filter,
|
||||||
|
resource_type="knowledge",
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
knowledge_bases = []
|
||||||
|
for knowledge_base, user in items:
|
||||||
|
knowledge_bases.append(
|
||||||
|
KnowledgeUserModel.model_validate(
|
||||||
|
{
|
||||||
|
**self._to_knowledge_model(
|
||||||
|
knowledge_base, db=db
|
||||||
|
).model_dump(),
|
||||||
|
"user": (
|
||||||
|
UserModel.model_validate(user).model_dump()
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeListResponse(items=knowledge_bases, total=total)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return KnowledgeListResponse(items=[], total=0)
|
||||||
|
|
||||||
|
def search_knowledge_files(
|
||||||
|
self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[Session] = None
|
||||||
|
) -> KnowledgeFileListResponse:
|
||||||
|
"""
|
||||||
|
Scalable version: search files across all knowledge bases the user has
|
||||||
|
READ access to, without loading all KBs or using large IN() lists.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# Base query: join Knowledge → KnowledgeFile → File
|
||||||
|
query = (
|
||||||
|
db.query(File, User, Knowledge)
|
||||||
|
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||||
|
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
|
||||||
|
.outerjoin(User, User.id == KnowledgeFile.user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply access-control directly to the joined query
|
||||||
|
# This makes the database handle filtering, even with 10k+ KBs
|
||||||
|
query = AccessGrants.has_permission_filter(
|
||||||
|
db=db,
|
||||||
|
query=query,
|
||||||
|
DocumentModel=Knowledge,
|
||||||
|
filter=filter,
|
||||||
|
resource_type="knowledge",
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filename search
|
||||||
|
if filter:
|
||||||
|
q = filter.get("query")
|
||||||
|
if q:
|
||||||
|
query = query.filter(File.filename.ilike(f"%{q}%"))
|
||||||
|
|
||||||
|
# Order by file changes
|
||||||
|
query = query.order_by(File.updated_at.desc(), File.id.asc())
|
||||||
|
|
||||||
|
# Count before pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
rows = query.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for file, user, knowledge in rows:
|
||||||
|
items.append(
|
||||||
|
FileUserResponse(
|
||||||
|
**FileModel.model_validate(file).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(
|
||||||
|
**UserModel.model_validate(user).model_dump()
|
||||||
|
)
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
collection=self._to_knowledge_model(
|
||||||
|
knowledge, db=db
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeFileListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("search_knowledge_files error:", e)
|
||||||
|
return KnowledgeFileListResponse(items=[], total=0)
|
||||||
|
|
||||||
|
def check_access_by_user_id(
|
||||||
|
self, id, user_id, permission="write", db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
knowledge = self.get_knowledge_by_id(id, db=db)
|
||||||
|
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, db=db)
|
||||||
|
}
|
||||||
|
return AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="knowledge",
|
||||||
|
resource_id=knowledge.id,
|
||||||
|
permission=permission,
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_knowledge_bases_by_user_id(
|
||||||
|
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||||
|
) -> list[KnowledgeUserModel]:
|
||||||
|
knowledge_bases = self.get_knowledge_bases(db=db)
|
||||||
|
user_group_ids = {
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
knowledge_base
|
||||||
|
for knowledge_base in knowledge_bases
|
||||||
|
if knowledge_base.user_id == user_id
|
||||||
|
or AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="knowledge",
|
||||||
|
resource_id=knowledge_base.id,
|
||||||
|
permission=permission,
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_knowledge_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[KnowledgeModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
knowledge = db.query(Knowledge).filter_by(id=id).first()
|
||||||
|
return self._to_knowledge_model(knowledge, db=db) if knowledge else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_knowledge_by_id_and_user_id(
|
||||||
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[KnowledgeModel]:
|
||||||
|
knowledge = self.get_knowledge_by_id(id, db=db)
|
||||||
|
if not knowledge:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if knowledge.user_id == user_id:
|
||||||
|
return knowledge
|
||||||
|
|
||||||
|
user_group_ids = {
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
}
|
||||||
|
if AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="knowledge",
|
||||||
|
resource_id=knowledge.id,
|
||||||
|
permission="write",
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
):
|
||||||
|
return knowledge
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_knowledges_by_file_id(
|
||||||
|
self, file_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[KnowledgeModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
knowledges = (
|
||||||
|
db.query(Knowledge)
|
||||||
|
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
|
||||||
|
.filter(KnowledgeFile.file_id == file_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
self._to_knowledge_model(knowledge, db=db)
|
||||||
|
for knowledge in knowledges
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def search_files_by_id(
|
||||||
|
self,
|
||||||
|
knowledge_id: str,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> KnowledgeFileListResponse:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query = (
|
||||||
|
db.query(File, User)
|
||||||
|
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||||
|
.outerjoin(User, User.id == KnowledgeFile.user_id)
|
||||||
|
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default sort: updated_at descending
|
||||||
|
primary_sort = File.updated_at.desc()
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(KnowledgeFile.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(KnowledgeFile.user_id != user_id)
|
||||||
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
is_asc = direction == "asc"
|
||||||
|
|
||||||
|
if order_by == "name":
|
||||||
|
primary_sort = (
|
||||||
|
File.filename.asc() if is_asc else File.filename.desc()
|
||||||
|
)
|
||||||
|
elif order_by == "created_at":
|
||||||
|
primary_sort = (
|
||||||
|
File.created_at.asc() if is_asc else File.created_at.desc()
|
||||||
|
)
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
primary_sort = (
|
||||||
|
File.updated_at.asc() if is_asc else File.updated_at.desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply sort with secondary key for deterministic pagination
|
||||||
|
query = query.order_by(primary_sort, File.id.asc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for file, user in items:
|
||||||
|
files.append(
|
||||||
|
FileUserResponse(
|
||||||
|
**FileModel.model_validate(file).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(
|
||||||
|
**UserModel.model_validate(user).model_dump()
|
||||||
|
)
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeFileListResponse(items=files, total=total)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return KnowledgeFileListResponse(items=[], total=0)
|
||||||
|
|
||||||
|
def get_files_by_id(
|
||||||
|
self, knowledge_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[FileModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> list[FileMetadataResponse]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
files = self.get_files_by_id(knowledge_id, db=db)
|
||||||
|
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,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[KnowledgeFileModel]:
|
||||||
|
with get_db_context(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:
|
try:
|
||||||
result = Knowledge(**knowledge.model_dump())
|
result = KnowledgeFile(**knowledge_file.model_dump())
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
if result:
|
if result:
|
||||||
return KnowledgeModel.model_validate(result)
|
return KnowledgeFileModel.model_validate(result)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
|
def remove_file_from_knowledge_by_id(
|
||||||
with get_db() as db:
|
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
|
||||||
knowledge_bases = []
|
) -> bool:
|
||||||
for knowledge in (
|
|
||||||
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
|
||||||
):
|
|
||||||
user = Users.get_user_by_id(knowledge.user_id)
|
|
||||||
knowledge_bases.append(
|
|
||||||
KnowledgeUserModel.model_validate(
|
|
||||||
{
|
|
||||||
**KnowledgeModel.model_validate(knowledge).model_dump(),
|
|
||||||
"user": user.model_dump() if user else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return knowledge_bases
|
|
||||||
|
|
||||||
def get_knowledge_bases_by_user_id(
|
|
||||||
self, user_id: str, permission: str = "write"
|
|
||||||
) -> list[KnowledgeUserModel]:
|
|
||||||
knowledge_bases = self.get_knowledge_bases()
|
|
||||||
return [
|
|
||||||
knowledge_base
|
|
||||||
for knowledge_base in knowledge_bases
|
|
||||||
if knowledge_base.user_id == user_id
|
|
||||||
or has_access(user_id, permission, knowledge_base.access_control)
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
|
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
knowledge = db.query(Knowledge).filter_by(id=id).first()
|
db.query(KnowledgeFile).filter_by(
|
||||||
return KnowledgeModel.model_validate(knowledge) if knowledge else None
|
knowledge_id=knowledge_id, file_id=file_id
|
||||||
|
).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return False
|
||||||
|
|
||||||
def update_knowledge_by_id(
|
def reset_knowledge_by_id(
|
||||||
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
|
self, id: str, db: Optional[Session] = None
|
||||||
) -> Optional[KnowledgeModel]:
|
) -> Optional[KnowledgeModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
knowledge = self.get_knowledge_by_id(id=id)
|
# 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(
|
db.query(Knowledge).filter_by(id=id).update(
|
||||||
{
|
{
|
||||||
**form_data.model_dump(),
|
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return self.get_knowledge_by_id(id=id)
|
|
||||||
|
return self.get_knowledge_by_id(id=id, db=db)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_knowledge_by_id(
|
||||||
|
self,
|
||||||
|
id: str,
|
||||||
|
form_data: KnowledgeForm,
|
||||||
|
overwrite: bool = False,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[KnowledgeModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
knowledge = self.get_knowledge_by_id(id=id, db=db)
|
||||||
|
db.query(Knowledge).filter_by(id=id).update(
|
||||||
|
{
|
||||||
|
**form_data.model_dump(exclude={"access_grants"}),
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
if form_data.access_grants is not None:
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"knowledge", id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
return self.get_knowledge_by_id(id=id, db=db)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_knowledge_data_by_id(
|
def update_knowledge_data_by_id(
|
||||||
self, id: str, data: dict
|
self, id: str, data: dict, db: Optional[Session] = None
|
||||||
) -> Optional[KnowledgeModel]:
|
) -> Optional[KnowledgeModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
knowledge = self.get_knowledge_by_id(id=id)
|
knowledge = self.get_knowledge_by_id(id=id, db=db)
|
||||||
db.query(Knowledge).filter_by(id=id).update(
|
db.query(Knowledge).filter_by(id=id).update(
|
||||||
{
|
{
|
||||||
"data": data,
|
"data": data,
|
||||||
@@ -193,23 +659,27 @@ class KnowledgeTable:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return self.get_knowledge_by_id(id=id)
|
return self.get_knowledge_by_id(id=id, db=db)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_knowledge_by_id(self, id: str) -> bool:
|
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
|
AccessGrants.revoke_all_access("knowledge", id, db=db)
|
||||||
db.query(Knowledge).filter_by(id=id).delete()
|
db.query(Knowledge).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_all_knowledge(self) -> bool:
|
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
|
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
|
||||||
|
for knowledge_id in knowledge_ids:
|
||||||
|
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
|
||||||
db.query(Knowledge).delete()
|
db.query(Knowledge).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, get_db, get_db_context
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Column, String, Text
|
from sqlalchemy import BigInteger, Column, String, Text
|
||||||
|
|
||||||
@@ -14,7 +15,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)
|
||||||
@@ -41,8 +42,9 @@ class MemoriesTable:
|
|||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[MemoryModel]:
|
) -> Optional[MemoryModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
id = str(uuid.uuid4())
|
id = str(uuid.uuid4())
|
||||||
|
|
||||||
memory = MemoryModel(
|
memory = MemoryModel(
|
||||||
@@ -68,43 +70,53 @@ class MemoriesTable:
|
|||||||
id: str,
|
id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[MemoryModel]:
|
) -> Optional[MemoryModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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)
|
db.refresh(memory)
|
||||||
|
return MemoryModel.model_validate(memory)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_memories(self) -> list[MemoryModel]:
|
def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
memories = db.query(Memory).all()
|
memories = db.query(Memory).all()
|
||||||
return [MemoryModel.model_validate(memory) for memory in memories]
|
return [MemoryModel.model_validate(memory) for memory in memories]
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_memories_by_user_id(self, user_id: str) -> list[MemoryModel]:
|
def get_memories_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[MemoryModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
memories = db.query(Memory).filter_by(user_id=user_id).all()
|
memories = db.query(Memory).filter_by(user_id=user_id).all()
|
||||||
return [MemoryModel.model_validate(memory) for memory in memories]
|
return [MemoryModel.model_validate(memory) for memory in memories]
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_memory_by_id(self, id: str) -> Optional[MemoryModel]:
|
def get_memory_by_id(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[MemoryModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
memory = db.get(Memory, id)
|
memory = db.get(Memory, id)
|
||||||
return MemoryModel.model_validate(memory)
|
return MemoryModel.model_validate(memory)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_memory_by_id(self, id: str) -> bool:
|
def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(Memory).filter_by(id=id).delete()
|
db.query(Memory).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -114,8 +126,10 @@ class MemoriesTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_memories_by_user_id(self, user_id: str) -> bool:
|
def delete_memories_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
db.query(Memory).filter_by(user_id=user_id).delete()
|
db.query(Memory).filter_by(user_id=user_id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -124,10 +138,17 @@ class MemoriesTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
|
def delete_memory_by_id_and_user_id(
|
||||||
with get_db() as db:
|
self, id: str, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
with get_db_context(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
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
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, field_validator
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||||
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,7 +22,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 +41,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 +69,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 +91,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 +101,36 @@ 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 MessageUserSlimResponse(MessageUserResponse):
|
||||||
|
data: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("data", mode="before")
|
||||||
|
def convert_data_to_bool(cls, v):
|
||||||
|
# No data or not a dict → False
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# True if ANY value in the dict is non-empty
|
||||||
|
return any(bool(val) for val in v.values())
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReplyToResponse(MessageUserResponse):
|
||||||
|
reply_to_message: Optional[MessageUserSlimResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageWithReactionsResponse(MessageUserSlimResponse):
|
||||||
|
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]
|
||||||
@@ -96,18 +138,28 @@ class MessageResponse(MessageModel):
|
|||||||
|
|
||||||
class MessageTable:
|
class MessageTable:
|
||||||
def insert_new_message(
|
def insert_new_message(
|
||||||
self, form_data: MessageForm, channel_id: str, user_id: str
|
self,
|
||||||
|
form_data: MessageForm,
|
||||||
|
channel_id: str,
|
||||||
|
user_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[MessageModel]:
|
) -> Optional[MessageModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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,52 +167,145 @@ 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)
|
||||||
return MessageModel.model_validate(result) if result else None
|
return MessageModel.model_validate(result) if result else None
|
||||||
|
|
||||||
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
|
def get_message_by_id(
|
||||||
with get_db() as db:
|
self,
|
||||||
|
id: str,
|
||||||
|
include_thread_replies: Optional[bool] = True,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[MessageResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
message = db.get(Message, id)
|
message = db.get(Message, id)
|
||||||
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, include_thread_replies=False, db=db
|
||||||
|
)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return MessageResponse(
|
reactions = self.get_reactions_by_message_id(id, db=db)
|
||||||
**{
|
|
||||||
|
thread_replies = []
|
||||||
|
if include_thread_replies:
|
||||||
|
thread_replies = self.get_thread_replies_by_message_id(id, db=db)
|
||||||
|
|
||||||
|
# Check if message was sent by webhook (webhook info in meta takes precedence)
|
||||||
|
webhook_info = message.meta.get("webhook") if message.meta else None
|
||||||
|
if webhook_info and webhook_info.get("id"):
|
||||||
|
# Look up webhook by ID to get current name
|
||||||
|
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
|
||||||
|
if webhook:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook.id,
|
||||||
|
"name": webhook.name,
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Webhook was deleted, use placeholder
|
||||||
|
user_info = {
|
||||||
|
"id": webhook_info.get("id"),
|
||||||
|
"name": "Deleted Webhook",
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
user = Users.get_user_by_id(message.user_id, db=db)
|
||||||
|
user_info = user.model_dump() if user else None
|
||||||
|
|
||||||
|
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_info,
|
||||||
"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(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> list[MessageReplyToResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
all_messages = (
|
all_messages = (
|
||||||
db.query(Message)
|
db.query(Message)
|
||||||
.filter_by(parent_id=id)
|
.filter_by(parent_id=id)
|
||||||
.order_by(Message.created_at.desc())
|
.order_by(Message.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [MessageModel.model_validate(message) for message in all_messages]
|
|
||||||
|
|
||||||
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
|
messages = []
|
||||||
with get_db() as db:
|
for message in all_messages:
|
||||||
|
reply_to_message = (
|
||||||
|
self.get_message_by_id(
|
||||||
|
message.reply_to_id, include_thread_replies=False, db=db
|
||||||
|
)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_info = message.meta.get("webhook") if message.meta else None
|
||||||
|
user_info = None
|
||||||
|
if webhook_info and webhook_info.get("id"):
|
||||||
|
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
|
||||||
|
if webhook:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook.id,
|
||||||
|
"name": webhook.name,
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook_info.get("id"),
|
||||||
|
"name": "Deleted Webhook",
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
MessageReplyToResponse.model_validate(
|
||||||
|
{
|
||||||
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"user": user_info,
|
||||||
|
"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, db: Optional[Session] = None
|
||||||
|
) -> list[str]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
message.user_id
|
message.user_id
|
||||||
for message in db.query(Message).filter_by(parent_id=id).all()
|
for message in db.query(Message).filter_by(parent_id=id).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_messages_by_channel_id(
|
def get_messages_by_channel_id(
|
||||||
self, channel_id: str, skip: int = 0, limit: int = 50
|
self,
|
||||||
) -> list[MessageModel]:
|
channel_id: str,
|
||||||
with get_db() as db:
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[MessageReplyToResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
all_messages = (
|
all_messages = (
|
||||||
db.query(Message)
|
db.query(Message)
|
||||||
.filter_by(channel_id=channel_id, parent_id=None)
|
.filter_by(channel_id=channel_id, parent_id=None)
|
||||||
@@ -169,12 +314,58 @@ 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, include_thread_replies=False, db=db
|
||||||
|
)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_info = message.meta.get("webhook") if message.meta else None
|
||||||
|
user_info = None
|
||||||
|
if webhook_info and webhook_info.get("id"):
|
||||||
|
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
|
||||||
|
if webhook:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook.id,
|
||||||
|
"name": webhook.name,
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook_info.get("id"),
|
||||||
|
"name": "Deleted Webhook",
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
MessageReplyToResponse.model_validate(
|
||||||
|
{
|
||||||
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"user": user_info,
|
||||||
|
"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,
|
||||||
) -> list[MessageModel]:
|
channel_id: str,
|
||||||
with get_db() as db:
|
parent_id: str,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[MessageReplyToResponse]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
message = db.get(Message, parent_id)
|
message = db.get(Message, parent_id)
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
@@ -193,25 +384,143 @@ 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, include_thread_replies=False, db=db
|
||||||
|
)
|
||||||
|
if message.reply_to_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_info = message.meta.get("webhook") if message.meta else None
|
||||||
|
user_info = None
|
||||||
|
if webhook_info and webhook_info.get("id"):
|
||||||
|
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
|
||||||
|
if webhook:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook.id,
|
||||||
|
"name": webhook.name,
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
user_info = {
|
||||||
|
"id": webhook_info.get("id"),
|
||||||
|
"name": "Deleted Webhook",
|
||||||
|
"role": "webhook",
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
MessageReplyToResponse.model_validate(
|
||||||
|
{
|
||||||
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"user": user_info,
|
||||||
|
"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, db: Optional[Session] = None
|
||||||
|
) -> Optional[MessageModel]:
|
||||||
|
with get_db_context(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,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[MessageModel]:
|
||||||
|
with get_db_context(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(
|
||||||
self, id: str, form_data: MessageForm
|
self, id: str, form_data: MessageForm, db: Optional[Session] = None
|
||||||
) -> Optional[MessageModel]:
|
) -> Optional[MessageModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[MessageModel]:
|
||||||
|
with get_db_context(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,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> int:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
) -> Optional[MessageReactionModel]:
|
) -> Optional[MessageReactionModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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,
|
||||||
@@ -226,47 +535,62 @@ class MessageTable:
|
|||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
return MessageReactionModel.model_validate(result) if result else None
|
return MessageReactionModel.model_validate(result) if result else None
|
||||||
|
|
||||||
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
|
def get_reactions_by_message_id(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
|
) -> list[Reactions]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
# 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()]
|
||||||
|
|
||||||
def remove_reaction_by_id_and_user_id_and_name(
|
def remove_reaction_by_id_and_user_id_and_name(
|
||||||
self, id: str, user_id: str, name: str
|
self, id: str, user_id: str, name: str, db: Optional[Session] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(MessageReaction).filter_by(
|
db.query(MessageReaction).filter_by(
|
||||||
message_id=id, user_id=user_id, name=name
|
message_id=id, user_id=user_id, name=name
|
||||||
).delete()
|
).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_reactions_by_id(self, id: str) -> bool:
|
def delete_reactions_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(MessageReaction).filter_by(message_id=id).delete()
|
db.query(MessageReaction).filter_by(message_id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_replies_by_id(self, id: str) -> bool:
|
def delete_replies_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(Message).filter_by(parent_id=id).delete()
|
db.query(Message).filter_by(parent_id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_message_by_id(self, id: str) -> bool:
|
def delete_message_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(Message).filter_by(id=id).delete()
|
db.query(Message).filter_by(id=id).delete()
|
||||||
|
|
||||||
# Delete all reactions to this message
|
# Delete all reactions to this message
|
||||||
@@ -275,5 +599,35 @@ class MessageTable:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def search_messages_by_channel_ids(
|
||||||
|
self,
|
||||||
|
channel_ids: list[str],
|
||||||
|
query: str,
|
||||||
|
start_timestamp: Optional[int] = None,
|
||||||
|
end_timestamp: Optional[int] = None,
|
||||||
|
limit: int = 10,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[MessageModel]:
|
||||||
|
"""Search messages in specified channels by content."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query_builder = db.query(Message).filter(
|
||||||
|
Message.channel_id.in_(channel_ids),
|
||||||
|
Message.content.ilike(f"%{query}%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_timestamp:
|
||||||
|
query_builder = query_builder.filter(
|
||||||
|
Message.created_at >= start_timestamp
|
||||||
|
)
|
||||||
|
if end_timestamp:
|
||||||
|
query_builder = query_builder.filter(
|
||||||
|
Message.created_at <= end_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = (
|
||||||
|
query_builder.order_by(Message.created_at.desc()).limit(limit).all()
|
||||||
|
)
|
||||||
|
return [MessageModel.model_validate(msg) for msg in messages]
|
||||||
|
|
||||||
|
|
||||||
Messages = MessageTable()
|
Messages = MessageTable()
|
||||||
@@ -2,24 +2,23 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from sqlalchemy.orm import Session
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
|
||||||
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 open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
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 import BigInteger, Column, Text, JSON, Boolean
|
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils.access_control import has_access
|
|
||||||
|
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy import BigInteger, Column, Text, Boolean
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@@ -52,7 +51,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.
|
||||||
"""
|
"""
|
||||||
@@ -78,23 +77,6 @@ class Model(Base):
|
|||||||
Holds a JSON encoded blob of metadata, see `ModelMeta`.
|
Holds a JSON encoded blob of metadata, see `ModelMeta`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
|
||||||
# Defines access control rules for this entry.
|
|
||||||
# - `None`: Public access, available to all users with the "user" role.
|
|
||||||
# - `{}`: Private access, restricted exclusively to the owner.
|
|
||||||
# - Custom permissions: Specific access control for reading and writing;
|
|
||||||
# Can specify group or user-level restrictions:
|
|
||||||
# {
|
|
||||||
# "read": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# },
|
|
||||||
# "write": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
@@ -110,7 +92,7 @@ class ModelModel(BaseModel):
|
|||||||
params: ModelParams
|
params: ModelParams
|
||||||
meta: ModelMeta
|
meta: ModelMeta
|
||||||
|
|
||||||
access_control: Optional[dict] = None
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
|
||||||
is_active: bool
|
is_active: bool
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
@@ -128,130 +110,298 @@ class ModelUserResponse(ModelModel):
|
|||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAccessResponse(ModelUserResponse):
|
||||||
|
write_access: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class ModelResponse(ModelModel):
|
class ModelResponse(ModelModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelListResponse(BaseModel):
|
||||||
|
items: list[ModelUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAccessListResponse(BaseModel):
|
||||||
|
items: list[ModelAccessResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class ModelForm(BaseModel):
|
class ModelForm(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
base_model_id: Optional[str] = None
|
base_model_id: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
meta: ModelMeta
|
meta: ModelMeta
|
||||||
params: ModelParams
|
params: ModelParams
|
||||||
access_control: Optional[dict] = None
|
access_grants: Optional[list[dict]] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ModelsTable:
|
class ModelsTable:
|
||||||
def insert_new_model(
|
def _get_access_grants(
|
||||||
self, form_data: ModelForm, user_id: str
|
self, model_id: str, db: Optional[Session] = None
|
||||||
) -> Optional[ModelModel]:
|
) -> list[AccessGrantModel]:
|
||||||
model = ModelModel(
|
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
|
||||||
**{
|
|
||||||
**form_data.model_dump(),
|
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
|
||||||
"user_id": user_id,
|
model_data = ModelModel.model_validate(model).model_dump(
|
||||||
"created_at": int(time.time()),
|
exclude={"access_grants"}
|
||||||
"updated_at": int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
|
||||||
|
return ModelModel.model_validate(model_data)
|
||||||
|
|
||||||
|
def insert_new_model(
|
||||||
|
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[ModelModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
result = Model(**model.model_dump())
|
result = Model(
|
||||||
|
**{
|
||||||
|
**form_data.model_dump(exclude={"access_grants"}),
|
||||||
|
"user_id": user_id,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"model", result.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return ModelModel.model_validate(result)
|
return self._to_model_model(result, db=db)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Failed to insert a new model: {e}")
|
log.exception(f"Failed to insert a new model: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_all_models(self) -> list[ModelModel]:
|
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
|
return [
|
||||||
|
self._to_model_model(model, db=db) for model in db.query(Model).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
|
||||||
|
with get_db_context(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, db=db) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
def get_models(self) -> list[ModelUserResponse]:
|
|
||||||
with get_db() as db:
|
|
||||||
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(
|
||||||
{
|
{
|
||||||
**ModelModel.model_validate(model).model_dump(),
|
**self._to_model_model(model, db=db).model_dump(),
|
||||||
"user": user.model_dump() if user else None,
|
"user": user.model_dump() if user else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return models
|
return models
|
||||||
|
|
||||||
def get_base_models(self) -> list[ModelModel]:
|
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
ModelModel.model_validate(model)
|
self._to_model_model(model, db=db)
|
||||||
for model in db.query(Model).filter(Model.base_model_id == None).all()
|
for model in db.query(Model).filter(Model.base_model_id == None).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_models_by_user_id(
|
def get_models_by_user_id(
|
||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||||
) -> list[ModelUserResponse]:
|
) -> list[ModelUserResponse]:
|
||||||
models = self.get_models()
|
models = self.get_models(db=db)
|
||||||
|
user_group_ids = {
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
}
|
||||||
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 AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="model",
|
||||||
|
resource_id=model.id,
|
||||||
|
permission=permission,
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
|
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||||
|
return AccessGrants.has_permission_filter(
|
||||||
|
db=db,
|
||||||
|
query=query,
|
||||||
|
DocumentModel=Model,
|
||||||
|
filter=filter,
|
||||||
|
resource_type="model",
|
||||||
|
permission=permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_models(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict = {},
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> ModelListResponse:
|
||||||
|
with get_db_context(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}%"),
|
||||||
|
User.name.ilike(f"%{query_key}%"),
|
||||||
|
User.email.ilike(f"%{query_key}%"),
|
||||||
|
User.username.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="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
**self._to_model_model(model, db=db).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, db: Optional[Session] = None
|
||||||
|
) -> Optional[ModelModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
model = db.get(Model, id)
|
model = db.get(Model, id)
|
||||||
return ModelModel.model_validate(model)
|
return self._to_model_model(model, db=db) if model else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]:
|
def get_models_by_ids(
|
||||||
with get_db() as db:
|
self, ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> list[ModelModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
models = db.query(Model).filter(Model.id.in_(ids)).all()
|
||||||
|
return [self._to_model_model(model, db=db) for model in models]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def toggle_model_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[ModelModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
is_active = db.query(Model).filter_by(id=id).first().is_active
|
model = db.query(Model).filter_by(id=id).first()
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
|
||||||
db.query(Model).filter_by(id=id).update(
|
model.is_active = not model.is_active
|
||||||
{
|
model.updated_at = int(time.time())
|
||||||
"is_active": not is_active,
|
|
||||||
"updated_at": int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
db.refresh(model)
|
||||||
|
|
||||||
return self.get_model_by_id(id)
|
return self._to_model_model(model, db=db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]:
|
def update_model_by_id(
|
||||||
|
self, id: str, model: ModelForm, db: Optional[Session] = None
|
||||||
|
) -> Optional[ModelModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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", "access_grants"})
|
||||||
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()
|
|
||||||
|
|
||||||
model = db.get(Model, id)
|
db.commit()
|
||||||
db.refresh(model)
|
if model.access_grants is not None:
|
||||||
return ModelModel.model_validate(model)
|
AccessGrants.set_access_grants(
|
||||||
|
"model", id, model.access_grants, db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_model_by_id(id, db=db)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Failed to update the model by id {id}: {e}")
|
log.exception(f"Failed to update the model by id {id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_model_by_id(self, id: str) -> bool:
|
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
|
AccessGrants.revoke_all_access("model", id, db=db)
|
||||||
db.query(Model).filter_by(id=id).delete()
|
db.query(Model).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -259,9 +409,12 @@ class ModelsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_all_models(self) -> bool:
|
def delete_all_models(self, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
|
model_ids = [row[0] for row in db.query(Model.id).all()]
|
||||||
|
for model_id in model_ids:
|
||||||
|
AccessGrants.revoke_all_access("model", model_id, db=db)
|
||||||
db.query(Model).delete()
|
db.query(Model).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -269,5 +422,56 @@ class ModelsTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def sync_models(
|
||||||
|
self, user_id: str, models: list[ModelModel], db: Optional[Session] = None
|
||||||
|
) -> list[ModelModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(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(exclude={"access_grants"}),
|
||||||
|
"user_id": user_id,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_model = Model(
|
||||||
|
**{
|
||||||
|
**model.model_dump(exclude={"access_grants"}),
|
||||||
|
"user_id": user_id,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(new_model)
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"model", model.id, model.access_grants, db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove models that are no longer present
|
||||||
|
for model in existing_models:
|
||||||
|
if model.id not in new_model_ids:
|
||||||
|
AccessGrants.revoke_all_access("model", model.id, db=db)
|
||||||
|
db.delete(model)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return [
|
||||||
|
self._to_model_model(model, db=db)
|
||||||
|
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,16 +2,18 @@ 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 sqlalchemy.orm import Session
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.internal.db import Base, get_db, get_db_context
|
||||||
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 open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Column, Text, JSON
|
||||||
from sqlalchemy import or_, func, select, and_, text
|
from sqlalchemy import or_, func, cast
|
||||||
from sqlalchemy.sql import exists
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Note DB Schema
|
# Note DB Schema
|
||||||
@@ -21,15 +23,13 @@ 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)
|
||||||
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)
|
|
||||||
|
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class NoteModel(BaseModel):
|
|||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
access_control: Optional[dict] = None
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
@@ -59,68 +59,226 @@ class NoteForm(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
access_control: Optional[dict] = None
|
access_grants: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class NoteUpdateForm(BaseModel):
|
class NoteUpdateForm(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
access_control: Optional[dict] = None
|
access_grants: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class NoteUserResponse(NoteModel):
|
class NoteUserResponse(NoteModel):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteItemResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
data: Optional[dict]
|
||||||
|
updated_at: int
|
||||||
|
created_at: int
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteListResponse(BaseModel):
|
||||||
|
items: list[NoteUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class NoteTable:
|
class NoteTable:
|
||||||
|
def _get_access_grants(
|
||||||
|
self, note_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
|
||||||
|
|
||||||
|
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
|
||||||
|
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
|
||||||
|
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
|
||||||
|
return NoteModel.model_validate(note_data)
|
||||||
|
|
||||||
|
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||||
|
return AccessGrants.has_permission_filter(
|
||||||
|
db=db,
|
||||||
|
query=query,
|
||||||
|
DocumentModel=Note,
|
||||||
|
filter=filter,
|
||||||
|
resource_type="note",
|
||||||
|
permission=permission,
|
||||||
|
)
|
||||||
|
|
||||||
def insert_new_note(
|
def insert_new_note(
|
||||||
self,
|
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
|
||||||
form_data: NoteForm,
|
|
||||||
user_id: str,
|
|
||||||
) -> Optional[NoteModel]:
|
) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
note = NoteModel(
|
note = NoteModel(
|
||||||
**{
|
**{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
**form_data.model_dump(),
|
**form_data.model_dump(exclude={"access_grants"}),
|
||||||
"created_at": int(time.time_ns()),
|
"created_at": int(time.time_ns()),
|
||||||
"updated_at": int(time.time_ns()),
|
"updated_at": int(time.time_ns()),
|
||||||
|
"access_grants": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
new_note = Note(**note.model_dump())
|
new_note = Note(**note.model_dump(exclude={"access_grants"}))
|
||||||
|
|
||||||
db.add(new_note)
|
db.add(new_note)
|
||||||
db.commit()
|
db.commit()
|
||||||
return note
|
AccessGrants.set_access_grants(
|
||||||
|
"note", note.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
return self._to_note_model(new_note, db=db)
|
||||||
|
|
||||||
def get_notes(self) -> list[NoteModel]:
|
def get_notes(
|
||||||
with get_db() as db:
|
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
|
||||||
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
|
) -> list[NoteModel]:
|
||||||
return [NoteModel.model_validate(note) for note in notes]
|
with get_db_context(db) as db:
|
||||||
|
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 [self._to_note_model(note, db=db) for note in notes]
|
||||||
|
|
||||||
|
def search_notes(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict = {},
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> NoteListResponse:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
# Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do")
|
||||||
|
normalized_query = query_key.replace("-", "").replace(" ", "")
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
func.replace(
|
||||||
|
func.replace(Note.title, "-", ""), " ", ""
|
||||||
|
).ilike(f"%{normalized_query}%"),
|
||||||
|
func.replace(
|
||||||
|
func.replace(
|
||||||
|
cast(Note.data["content"]["md"], Text), "-", ""
|
||||||
|
),
|
||||||
|
" ",
|
||||||
|
"",
|
||||||
|
).ilike(f"%{normalized_query}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Note.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(Note.user_id != user_id)
|
||||||
|
|
||||||
|
# Apply access control filtering
|
||||||
|
if "permission" in filter:
|
||||||
|
permission = filter["permission"]
|
||||||
|
else:
|
||||||
|
permission = "write"
|
||||||
|
|
||||||
|
query = self._has_permission(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
filter,
|
||||||
|
permission=permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by == "name":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Note.title.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.title.desc())
|
||||||
|
elif order_by == "created_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Note.created_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.created_at.desc())
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Note.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
for note, user in items:
|
||||||
|
notes.append(
|
||||||
|
NoteUserResponse(
|
||||||
|
**self._to_note_model(note, db=db).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return NoteListResponse(items=notes, total=total)
|
||||||
|
|
||||||
def get_notes_by_user_id(
|
def get_notes_by_user_id(
|
||||||
self, user_id: str, permission: str = "write"
|
self,
|
||||||
|
user_id: str,
|
||||||
|
permission: str = "read",
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> list[NoteModel]:
|
) -> list[NoteModel]:
|
||||||
notes = self.get_notes()
|
with get_db_context(db) as db:
|
||||||
return [
|
user_group_ids = [
|
||||||
note
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
for note in notes
|
]
|
||||||
if note.user_id == user_id
|
|
||||||
or has_access(user_id, permission, note.access_control)
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||||
with get_db() as db:
|
query = self._has_permission(
|
||||||
|
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
|
||||||
|
)
|
||||||
|
|
||||||
|
if skip is not None:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
notes = query.all()
|
||||||
|
return [self._to_note_model(note, db=db) for note in notes]
|
||||||
|
|
||||||
|
def get_note_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[NoteModel]:
|
||||||
|
with get_db_context(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 self._to_note_model(note, db=db) if note else None
|
||||||
|
|
||||||
def update_note_by_id(
|
def update_note_by_id(
|
||||||
self, id: str, form_data: NoteUpdateForm
|
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
|
||||||
) -> Optional[NoteModel]:
|
) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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
|
||||||
@@ -134,19 +292,25 @@ class NoteTable:
|
|||||||
if "meta" in form_data:
|
if "meta" in form_data:
|
||||||
note.meta = {**note.meta, **form_data["meta"]}
|
note.meta = {**note.meta, **form_data["meta"]}
|
||||||
|
|
||||||
if "access_control" in form_data:
|
if "access_grants" in form_data:
|
||||||
note.access_control = form_data["access_control"]
|
AccessGrants.set_access_grants(
|
||||||
|
"note", id, form_data["access_grants"], db=db
|
||||||
|
)
|
||||||
|
|
||||||
note.updated_at = int(time.time_ns())
|
note.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return NoteModel.model_validate(note) if note else None
|
return self._to_note_model(note, db=db) if note else None
|
||||||
|
|
||||||
def delete_note_by_id(self, id: str):
|
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
with get_db() as db:
|
try:
|
||||||
db.query(Note).filter(Note.id == id).delete()
|
with get_db_context(db) as db:
|
||||||
db.commit()
|
AccessGrants.revoke_all_access("note", id, db=db)
|
||||||
return True
|
db.query(Note).filter(Note.id == id).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
Notes = NoteTable()
|
Notes = NoteTable()
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, List
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, get_db, get_db_context
|
||||||
|
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger, Column, String, Text, Index
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# 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: {type(e).__name__}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
provider: str,
|
||||||
|
token: dict,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Create a new OAuth session"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Get OAuth session by ID"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Get OAuth session by ID and user ID"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Get OAuth session by provider and user ID"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> List[OAuthSessionModel]:
|
||||||
|
"""Get all OAuth sessions for a user"""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for session in sessions:
|
||||||
|
try:
|
||||||
|
session.token = self._decrypt_token(session.token)
|
||||||
|
results.append(OAuthSessionModel.model_validate(session))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(
|
||||||
|
f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
db.query(OAuthSession).filter_by(id=session.id).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
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, db: Optional[Session] = None
|
||||||
|
) -> Optional[OAuthSessionModel]:
|
||||||
|
"""Update OAuth session tokens"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete an OAuth session"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete all OAuth sessions for a user"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete all OAuth sessions for a provider"""
|
||||||
|
try:
|
||||||
|
with get_db_context(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()
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Prompt history model for version tracking."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, get_db_context
|
||||||
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger, Column, Text, JSON, Index
|
||||||
|
|
||||||
|
####################
|
||||||
|
# PromptHistory DB Schema
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class PromptHistory(Base):
|
||||||
|
__tablename__ = "prompt_history"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
prompt_id = Column(Text, nullable=False, index=True)
|
||||||
|
parent_id = Column(Text, nullable=True) # Reference to parent commit
|
||||||
|
snapshot = Column(JSON, nullable=False)
|
||||||
|
user_id = Column(Text, nullable=False)
|
||||||
|
commit_message = Column(Text, nullable=True)
|
||||||
|
created_at = Column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptHistoryModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
prompt_id: str
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
snapshot: dict
|
||||||
|
user_id: str
|
||||||
|
commit_message: Optional[str] = None
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptHistoryResponse(PromptHistoryModel):
|
||||||
|
"""Response model with user info."""
|
||||||
|
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PromptHistoryTable:
|
||||||
|
def create_history_entry(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
snapshot: dict,
|
||||||
|
user_id: str,
|
||||||
|
parent_id: Optional[str] = None,
|
||||||
|
commit_message: Optional[str] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[PromptHistoryModel]:
|
||||||
|
"""Create a new history entry (commit) for a prompt."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
history = PromptHistory(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
prompt_id=prompt_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
snapshot=snapshot,
|
||||||
|
user_id=user_id,
|
||||||
|
commit_message=commit_message,
|
||||||
|
created_at=int(time.time()),
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(history)
|
||||||
|
return PromptHistoryModel.model_validate(history)
|
||||||
|
|
||||||
|
def get_history_by_prompt_id(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> list[PromptHistoryResponse]:
|
||||||
|
"""Get all history entries for a prompt, ordered by created_at desc."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
entries = (
|
||||||
|
db.query(PromptHistory)
|
||||||
|
.filter(PromptHistory.prompt_id == prompt_id)
|
||||||
|
.order_by(PromptHistory.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user info for each entry
|
||||||
|
user_ids = list(set(e.user_id for e in entries))
|
||||||
|
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
|
return [
|
||||||
|
PromptHistoryResponse(
|
||||||
|
**PromptHistoryModel.model_validate(entry).model_dump(),
|
||||||
|
user=(
|
||||||
|
users_dict.get(entry.user_id).model_dump()
|
||||||
|
if users_dict.get(entry.user_id)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_history_entry_by_id(
|
||||||
|
self,
|
||||||
|
history_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[PromptHistoryModel]:
|
||||||
|
"""Get a specific history entry by ID."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
entry = (
|
||||||
|
db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
|
||||||
|
)
|
||||||
|
if entry:
|
||||||
|
return PromptHistoryModel.model_validate(entry)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_latest_history_entry(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[PromptHistoryModel]:
|
||||||
|
"""Get the most recent history entry for a prompt."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
entry = (
|
||||||
|
db.query(PromptHistory)
|
||||||
|
.filter(PromptHistory.prompt_id == prompt_id)
|
||||||
|
.order_by(PromptHistory.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if entry:
|
||||||
|
return PromptHistoryModel.model_validate(entry)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_history_count(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Get the number of history entries for a prompt."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
return (
|
||||||
|
db.query(PromptHistory)
|
||||||
|
.filter(PromptHistory.prompt_id == prompt_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
def compute_diff(
|
||||||
|
self,
|
||||||
|
from_id: str,
|
||||||
|
to_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Compute diff between two history entries."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from_entry = (
|
||||||
|
db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
|
||||||
|
)
|
||||||
|
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
|
||||||
|
|
||||||
|
if not from_entry or not to_entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from_snapshot = from_entry.snapshot
|
||||||
|
to_snapshot = to_entry.snapshot
|
||||||
|
|
||||||
|
# Compute diff for content field
|
||||||
|
from_content = from_snapshot.get("content", "")
|
||||||
|
to_content = to_snapshot.get("content", "")
|
||||||
|
|
||||||
|
diff_lines = list(
|
||||||
|
difflib.unified_diff(
|
||||||
|
from_content.splitlines(keepends=True),
|
||||||
|
to_content.splitlines(keepends=True),
|
||||||
|
fromfile=f"v{from_id[:8]}",
|
||||||
|
tofile=f"v{to_id[:8]}",
|
||||||
|
lineterm="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"from_id": from_id,
|
||||||
|
"to_id": to_id,
|
||||||
|
"from_snapshot": from_snapshot,
|
||||||
|
"to_snapshot": to_snapshot,
|
||||||
|
"content_diff": diff_lines,
|
||||||
|
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_history_by_prompt_id(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete all history entries for a prompt."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
db.query(PromptHistory).filter(
|
||||||
|
PromptHistory.prompt_id == prompt_id
|
||||||
|
).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_history_entry(
|
||||||
|
self,
|
||||||
|
history_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a history entry and reparent its children to grandparent."""
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
entry = db.query(PromptHistory).filter_by(id=history_id).first()
|
||||||
|
if not entry:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Find children that reference this entry as parent
|
||||||
|
children = db.query(PromptHistory).filter_by(parent_id=history_id).all()
|
||||||
|
|
||||||
|
# Reparent children to grandparent
|
||||||
|
for child in children:
|
||||||
|
child.parent_id = entry.parent_id
|
||||||
|
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
PromptHistories = PromptHistoryTable()
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
from open_webui.models.prompt_history import PromptHistories
|
||||||
|
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
|
||||||
|
|
||||||
from open_webui.utils.access_control import has_access
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Prompts DB Schema
|
# Prompts DB Schema
|
||||||
@@ -17,38 +21,35 @@ from open_webui.utils.access_control import has_access
|
|||||||
class Prompt(Base):
|
class Prompt(Base):
|
||||||
__tablename__ = "prompt"
|
__tablename__ = "prompt"
|
||||||
|
|
||||||
command = Column(String, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
|
command = Column(String, unique=True, index=True)
|
||||||
user_id = Column(String)
|
user_id = Column(String)
|
||||||
title = Column(Text)
|
name = Column(Text)
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
timestamp = Column(BigInteger)
|
data = Column(JSON, nullable=True)
|
||||||
|
meta = Column(JSON, nullable=True)
|
||||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
tags = Column(JSON, nullable=True)
|
||||||
# Defines access control rules for this entry.
|
is_active = Column(Boolean, default=True)
|
||||||
# - `None`: Public access, available to all users with the "user" role.
|
version_id = Column(Text, nullable=True) # Points to active history entry
|
||||||
# - `{}`: Private access, restricted exclusively to the owner.
|
created_at = Column(BigInteger, nullable=True)
|
||||||
# - Custom permissions: Specific access control for reading and writing;
|
updated_at = Column(BigInteger, nullable=True)
|
||||||
# Can specify group or user-level restrictions:
|
|
||||||
# {
|
|
||||||
# "read": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# },
|
|
||||||
# "write": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
class PromptModel(BaseModel):
|
class PromptModel(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
command: str
|
command: str
|
||||||
user_id: str
|
user_id: str
|
||||||
title: str
|
name: str
|
||||||
content: str
|
content: str
|
||||||
timestamp: int # timestamp in epoch
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
tags: Optional[list[str]] = None
|
||||||
|
is_active: Optional[bool] = True
|
||||||
|
version_id: Optional[str] = None
|
||||||
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
|
||||||
access_control: Optional[dict] = None
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,56 +62,161 @@ class PromptUserResponse(PromptModel):
|
|||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PromptAccessResponse(PromptUserResponse):
|
||||||
|
write_access: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class PromptListResponse(BaseModel):
|
||||||
|
items: list[PromptUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PromptAccessListResponse(BaseModel):
|
||||||
|
items: list[PromptAccessResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class PromptForm(BaseModel):
|
class PromptForm(BaseModel):
|
||||||
|
|
||||||
command: str
|
command: str
|
||||||
title: str
|
name: str # Changed from title
|
||||||
content: str
|
content: str
|
||||||
access_control: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
tags: Optional[list[str]] = None
|
||||||
|
access_grants: Optional[list[dict]] = None
|
||||||
|
version_id: Optional[str] = None # Active version
|
||||||
|
commit_message: Optional[str] = None # For history tracking
|
||||||
|
is_production: Optional[bool] = True # Whether to set new version as production
|
||||||
|
|
||||||
|
|
||||||
class PromptsTable:
|
class PromptsTable:
|
||||||
|
def _get_access_grants(
|
||||||
|
self, prompt_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
|
||||||
|
|
||||||
|
def _to_prompt_model(
|
||||||
|
self, prompt: Prompt, db: Optional[Session] = None
|
||||||
|
) -> PromptModel:
|
||||||
|
prompt_data = PromptModel.model_validate(prompt).model_dump(
|
||||||
|
exclude={"access_grants"}
|
||||||
|
)
|
||||||
|
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
|
||||||
|
return PromptModel.model_validate(prompt_data)
|
||||||
|
|
||||||
def insert_new_prompt(
|
def insert_new_prompt(
|
||||||
self, user_id: str, form_data: PromptForm
|
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
|
||||||
) -> Optional[PromptModel]:
|
) -> Optional[PromptModel]:
|
||||||
|
now = int(time.time())
|
||||||
|
prompt_id = str(uuid.uuid4())
|
||||||
|
|
||||||
prompt = PromptModel(
|
prompt = PromptModel(
|
||||||
**{
|
id=prompt_id,
|
||||||
"user_id": user_id,
|
user_id=user_id,
|
||||||
**form_data.model_dump(),
|
command=form_data.command,
|
||||||
"timestamp": int(time.time()),
|
name=form_data.name,
|
||||||
}
|
content=form_data.content,
|
||||||
|
data=form_data.data or {},
|
||||||
|
meta=form_data.meta or {},
|
||||||
|
tags=form_data.tags or [],
|
||||||
|
access_grants=[],
|
||||||
|
is_active=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
result = Prompt(**prompt.model_dump())
|
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"prompt", prompt_id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return PromptModel.model_validate(result)
|
current_access_grants = self._get_access_grants(prompt_id, db=db)
|
||||||
|
snapshot = {
|
||||||
|
"name": form_data.name,
|
||||||
|
"content": form_data.content,
|
||||||
|
"command": form_data.command,
|
||||||
|
"data": form_data.data or {},
|
||||||
|
"meta": form_data.meta or {},
|
||||||
|
"tags": form_data.tags or [],
|
||||||
|
"access_grants": [
|
||||||
|
grant.model_dump() for grant in current_access_grants
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
history_entry = PromptHistories.create_history_entry(
|
||||||
|
prompt_id=prompt_id,
|
||||||
|
snapshot=snapshot,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_id=None, # Initial commit has no parent
|
||||||
|
commit_message=form_data.commit_message or "Initial version",
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the initial version as the production version
|
||||||
|
if history_entry:
|
||||||
|
result.version_id = history_entry.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(result)
|
||||||
|
|
||||||
|
return self._to_prompt_model(result, db=db)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_prompt_by_command(self, command: str) -> Optional[PromptModel]:
|
def get_prompt_by_id(
|
||||||
|
self, prompt_id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[PromptModel]:
|
||||||
|
"""Get prompt by UUID."""
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||||
return PromptModel.model_validate(prompt)
|
if prompt:
|
||||||
|
return self._to_prompt_model(prompt, db=db)
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_prompts(self) -> list[PromptUserResponse]:
|
def get_prompt_by_command(
|
||||||
with get_db() as db:
|
self, command: str, db: Optional[Session] = None
|
||||||
prompts = []
|
) -> Optional[PromptModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||||
|
if prompt:
|
||||||
|
return self._to_prompt_model(prompt, db=db)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all():
|
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
|
||||||
user = Users.get_user_by_id(prompt.user_id)
|
with get_db_context(db) as db:
|
||||||
|
all_prompts = (
|
||||||
|
db.query(Prompt)
|
||||||
|
.filter(Prompt.is_active == True)
|
||||||
|
.order_by(Prompt.updated_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ids = list(set(prompt.user_id for prompt in all_prompts))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids, db=db) 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(
|
||||||
{
|
{
|
||||||
**PromptModel.model_validate(prompt).model_dump(),
|
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||||
"user": user.model_dump() if user else None,
|
"user": user.model_dump() if user else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -119,41 +225,411 @@ class PromptsTable:
|
|||||||
return prompts
|
return prompts
|
||||||
|
|
||||||
def get_prompts_by_user_id(
|
def get_prompts_by_user_id(
|
||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||||
) -> list[PromptUserResponse]:
|
) -> list[PromptUserResponse]:
|
||||||
prompts = self.get_prompts()
|
prompts = self.get_prompts(db=db)
|
||||||
|
user_group_ids = {
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
}
|
||||||
|
|
||||||
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 AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="prompt",
|
||||||
|
resource_id=prompt.id,
|
||||||
|
permission=permission,
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def search_prompts(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict = {},
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> PromptListResponse:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from open_webui.models.users import User, UserModel
|
||||||
|
|
||||||
|
# Join with User table for user filtering and sorting
|
||||||
|
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
|
||||||
|
query = query.filter(Prompt.is_active == True)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Prompt.name.ilike(f"%{query_key}%"),
|
||||||
|
Prompt.command.ilike(f"%{query_key}%"),
|
||||||
|
Prompt.content.ilike(f"%{query_key}%"),
|
||||||
|
User.name.ilike(f"%{query_key}%"),
|
||||||
|
User.email.ilike(f"%{query_key}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Prompt.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(Prompt.user_id != user_id)
|
||||||
|
|
||||||
|
# Apply access grant filtering
|
||||||
|
query = AccessGrants.has_permission_filter(
|
||||||
|
db=db,
|
||||||
|
query=query,
|
||||||
|
DocumentModel=Prompt,
|
||||||
|
filter=filter,
|
||||||
|
resource_type="prompt",
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = filter.get("tag")
|
||||||
|
if tag:
|
||||||
|
# Search for tag in JSON array field
|
||||||
|
like_pattern = f'%"{tag.lower()}"%'
|
||||||
|
tags_text = func.lower(cast(Prompt.tags, String))
|
||||||
|
query = query.filter(tags_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(Prompt.name.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Prompt.name.desc())
|
||||||
|
elif order_by == "created_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Prompt.created_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Prompt.created_at.desc())
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(Prompt.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Prompt.updated_at.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Prompt.updated_at.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Prompt.updated_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
prompts = []
|
||||||
|
for prompt, user in items:
|
||||||
|
prompts.append(
|
||||||
|
PromptUserResponse(
|
||||||
|
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return PromptListResponse(items=prompts, total=total)
|
||||||
|
|
||||||
def update_prompt_by_command(
|
def update_prompt_by_command(
|
||||||
self, command: str, form_data: PromptForm
|
self,
|
||||||
|
command: str,
|
||||||
|
form_data: PromptForm,
|
||||||
|
user_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[PromptModel]:
|
) -> Optional[PromptModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||||
prompt.title = form_data.title
|
if not prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_history = PromptHistories.get_latest_history_entry(
|
||||||
|
prompt.id, db=db
|
||||||
|
)
|
||||||
|
parent_id = latest_history.id if latest_history else None
|
||||||
|
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||||
|
|
||||||
|
# Check if content changed to decide on history creation
|
||||||
|
content_changed = (
|
||||||
|
prompt.name != form_data.name
|
||||||
|
or prompt.content != form_data.content
|
||||||
|
or form_data.access_grants is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update prompt fields
|
||||||
|
prompt.name = form_data.name
|
||||||
prompt.content = form_data.content
|
prompt.content = form_data.content
|
||||||
prompt.access_control = form_data.access_control
|
prompt.data = form_data.data or prompt.data
|
||||||
prompt.timestamp = int(time.time())
|
prompt.meta = form_data.meta or prompt.meta
|
||||||
|
prompt.updated_at = int(time.time())
|
||||||
|
if form_data.access_grants is not None:
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"prompt", prompt.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return PromptModel.model_validate(prompt)
|
|
||||||
|
# Create history entry only if content changed
|
||||||
|
if content_changed:
|
||||||
|
snapshot = {
|
||||||
|
"name": form_data.name,
|
||||||
|
"content": form_data.content,
|
||||||
|
"command": command,
|
||||||
|
"data": form_data.data or {},
|
||||||
|
"meta": form_data.meta or {},
|
||||||
|
"access_grants": [
|
||||||
|
grant.model_dump() for grant in current_access_grants
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
history_entry = PromptHistories.create_history_entry(
|
||||||
|
prompt_id=prompt.id,
|
||||||
|
snapshot=snapshot,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
commit_message=form_data.commit_message,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set as production if flag is True (default)
|
||||||
|
if form_data.is_production and history_entry:
|
||||||
|
prompt.version_id = history_entry.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return self._to_prompt_model(prompt, db=db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_prompt_by_command(self, command: str) -> bool:
|
def update_prompt_by_id(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
form_data: PromptForm,
|
||||||
|
user_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[PromptModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(Prompt).filter_by(command=command).delete()
|
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||||
|
if not prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_history = PromptHistories.get_latest_history_entry(
|
||||||
|
prompt.id, db=db
|
||||||
|
)
|
||||||
|
parent_id = latest_history.id if latest_history else None
|
||||||
|
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||||
|
|
||||||
|
# Check if content changed to decide on history creation
|
||||||
|
content_changed = (
|
||||||
|
prompt.name != form_data.name
|
||||||
|
or prompt.command != form_data.command
|
||||||
|
or prompt.content != form_data.content
|
||||||
|
or form_data.access_grants is not None
|
||||||
|
or (form_data.tags is not None and prompt.tags != form_data.tags)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update prompt fields
|
||||||
|
prompt.name = form_data.name
|
||||||
|
prompt.command = form_data.command
|
||||||
|
prompt.content = form_data.content
|
||||||
|
prompt.data = form_data.data or prompt.data
|
||||||
|
prompt.meta = form_data.meta or prompt.meta
|
||||||
|
|
||||||
|
if form_data.tags is not None:
|
||||||
|
prompt.tags = form_data.tags
|
||||||
|
|
||||||
|
if form_data.access_grants is not None:
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"prompt", prompt.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||||
|
|
||||||
|
prompt.updated_at = int(time.time())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return True
|
# Create history entry only if content changed
|
||||||
|
if content_changed:
|
||||||
|
snapshot = {
|
||||||
|
"name": form_data.name,
|
||||||
|
"content": form_data.content,
|
||||||
|
"command": prompt.command,
|
||||||
|
"data": form_data.data or {},
|
||||||
|
"meta": form_data.meta or {},
|
||||||
|
"tags": prompt.tags or [],
|
||||||
|
"access_grants": [
|
||||||
|
grant.model_dump() for grant in current_access_grants
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
history_entry = PromptHistories.create_history_entry(
|
||||||
|
prompt_id=prompt.id,
|
||||||
|
snapshot=snapshot,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
commit_message=form_data.commit_message,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set as production if flag is True (default)
|
||||||
|
if form_data.is_production and history_entry:
|
||||||
|
prompt.version_id = history_entry.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return self._to_prompt_model(prompt, db=db)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_prompt_metadata(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
name: str,
|
||||||
|
command: str,
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[PromptModel]:
|
||||||
|
"""Update only name, command, and tags (no history created)."""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||||
|
if not prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt.name = name
|
||||||
|
prompt.command = command
|
||||||
|
|
||||||
|
if tags is not None:
|
||||||
|
prompt.tags = tags
|
||||||
|
|
||||||
|
prompt.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return self._to_prompt_model(prompt, db=db)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_prompt_version(
|
||||||
|
self,
|
||||||
|
prompt_id: str,
|
||||||
|
version_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[PromptModel]:
|
||||||
|
"""Set the active version of a prompt and restore content from that version's snapshot."""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||||
|
if not prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
history_entry = PromptHistories.get_history_entry_by_id(
|
||||||
|
version_id, db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if not history_entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Restore prompt content from the snapshot
|
||||||
|
snapshot = history_entry.snapshot
|
||||||
|
if snapshot:
|
||||||
|
prompt.name = snapshot.get("name", prompt.name)
|
||||||
|
prompt.content = snapshot.get("content", prompt.content)
|
||||||
|
prompt.data = snapshot.get("data", prompt.data)
|
||||||
|
prompt.meta = snapshot.get("meta", prompt.meta)
|
||||||
|
prompt.tags = snapshot.get("tags", prompt.tags)
|
||||||
|
# Note: command and access_grants are not restored from snapshot
|
||||||
|
|
||||||
|
prompt.version_id = version_id
|
||||||
|
prompt.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return self._to_prompt_model(prompt, db=db)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_prompt_by_command(
|
||||||
|
self, command: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Soft delete a prompt by setting is_active to False."""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||||
|
if prompt:
|
||||||
|
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||||
|
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||||
|
|
||||||
|
prompt.is_active = False
|
||||||
|
prompt.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
|
||||||
|
"""Soft delete a prompt by setting is_active to False."""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||||
|
if prompt:
|
||||||
|
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||||
|
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||||
|
|
||||||
|
prompt.is_active = False
|
||||||
|
prompt.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def hard_delete_prompt_by_command(
|
||||||
|
self, command: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Permanently delete a prompt and its history."""
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||||
|
if prompt:
|
||||||
|
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||||
|
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||||
|
|
||||||
|
# Delete prompt
|
||||||
|
db.query(Prompt).filter_by(command=command).delete()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_tags(self, db: Optional[Session] = None) -> list[str]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
prompts = db.query(Prompt).filter_by(is_active=True).all()
|
||||||
|
tags = set()
|
||||||
|
for prompt in prompts:
|
||||||
|
if prompt.tags:
|
||||||
|
for tag in prompt.tags:
|
||||||
|
if tag:
|
||||||
|
tags.add(tag)
|
||||||
|
return sorted(list(tags))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
Prompts = PromptsTable()
|
Prompts = PromptsTable()
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, get_db, get_db_context
|
||||||
|
from open_webui.models.users import Users, UserResponse
|
||||||
|
from open_webui.models.groups import Groups
|
||||||
|
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Skills DB Schema
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class Skill(Base):
|
||||||
|
__tablename__ = "skill"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, unique=True)
|
||||||
|
user_id = Column(String)
|
||||||
|
name = Column(Text, unique=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
content = Column(Text)
|
||||||
|
meta = Column(JSON)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
updated_at = Column(BigInteger)
|
||||||
|
created_at = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillMeta(BaseModel):
|
||||||
|
tags: Optional[list[str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SkillModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
meta: SkillMeta
|
||||||
|
is_active: bool = True
|
||||||
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Forms
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class SkillUserModel(SkillModel):
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
meta: SkillMeta
|
||||||
|
is_active: bool = True
|
||||||
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
updated_at: int # timestamp in epoch
|
||||||
|
created_at: int # timestamp in epoch
|
||||||
|
|
||||||
|
|
||||||
|
class SkillUserResponse(SkillResponse):
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillAccessResponse(SkillUserResponse):
|
||||||
|
write_access: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class SkillForm(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
meta: SkillMeta = SkillMeta()
|
||||||
|
is_active: bool = True
|
||||||
|
access_grants: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillListResponse(BaseModel):
|
||||||
|
items: list[SkillUserResponse] = []
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SkillAccessListResponse(BaseModel):
|
||||||
|
items: list[SkillAccessResponse] = []
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsTable:
|
||||||
|
def _get_access_grants(
|
||||||
|
self, skill_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[AccessGrantModel]:
|
||||||
|
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
|
||||||
|
|
||||||
|
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
|
||||||
|
skill_data = SkillModel.model_validate(skill).model_dump(
|
||||||
|
exclude={"access_grants"}
|
||||||
|
)
|
||||||
|
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
|
||||||
|
return SkillModel.model_validate(skill_data)
|
||||||
|
|
||||||
|
def insert_new_skill(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
form_data: SkillForm,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[SkillModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
try:
|
||||||
|
result = Skill(
|
||||||
|
**{
|
||||||
|
**form_data.model_dump(exclude={"access_grants"}),
|
||||||
|
"user_id": user_id,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(result)
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"skill", result.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return self._to_skill_model(result, db=db)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error creating a new skill: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_skill_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[SkillModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
skill = db.get(Skill, id)
|
||||||
|
return self._to_skill_model(skill, db=db) if skill else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_skill_by_name(
|
||||||
|
self, name: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[SkillModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
skill = db.query(Skill).filter_by(name=name).first()
|
||||||
|
return self._to_skill_model(skill, db=db) if skill else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
|
||||||
|
|
||||||
|
user_ids = list(set(skill.user_id for skill in all_skills))
|
||||||
|
|
||||||
|
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||||
|
users_dict = {user.id: user for user in users}
|
||||||
|
|
||||||
|
skills = []
|
||||||
|
for skill in all_skills:
|
||||||
|
user = users_dict.get(skill.user_id)
|
||||||
|
skills.append(
|
||||||
|
SkillUserModel.model_validate(
|
||||||
|
{
|
||||||
|
**self._to_skill_model(skill, db=db).model_dump(),
|
||||||
|
"user": user.model_dump() if user else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def get_skills_by_user_id(
|
||||||
|
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||||
|
) -> list[SkillUserModel]:
|
||||||
|
skills = self.get_skills(db=db)
|
||||||
|
user_group_ids = {
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
skill
|
||||||
|
for skill in skills
|
||||||
|
if skill.user_id == user_id
|
||||||
|
or AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="skill",
|
||||||
|
resource_id=skill.id,
|
||||||
|
permission=permission,
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def search_skills(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict = {},
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> SkillListResponse:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
from open_webui.models.users import User, UserModel
|
||||||
|
|
||||||
|
# Join with User table for user filtering
|
||||||
|
query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Skill.name.ilike(f"%{query_key}%"),
|
||||||
|
Skill.description.ilike(f"%{query_key}%"),
|
||||||
|
Skill.id.ilike(f"%{query_key}%"),
|
||||||
|
User.name.ilike(f"%{query_key}%"),
|
||||||
|
User.email.ilike(f"%{query_key}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(Skill.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(Skill.user_id != user_id)
|
||||||
|
|
||||||
|
# Apply access grant filtering
|
||||||
|
query = AccessGrants.has_permission_filter(
|
||||||
|
db=db,
|
||||||
|
query=query,
|
||||||
|
DocumentModel=Skill,
|
||||||
|
filter=filter,
|
||||||
|
resource_type="skill",
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.order_by(Skill.updated_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
skills = []
|
||||||
|
for skill, user in items:
|
||||||
|
skills.append(
|
||||||
|
SkillUserResponse(
|
||||||
|
**self._to_skill_model(skill, db=db).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(
|
||||||
|
**UserModel.model_validate(user).model_dump()
|
||||||
|
)
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SkillListResponse(items=skills, total=total)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error searching skills: {e}")
|
||||||
|
return SkillListResponse(items=[], total=0)
|
||||||
|
|
||||||
|
def update_skill_by_id(
|
||||||
|
self, id: str, updated: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[SkillModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
access_grants = updated.pop("access_grants", None)
|
||||||
|
db.query(Skill).filter_by(id=id).update(
|
||||||
|
{**updated, "updated_at": int(time.time())}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
if access_grants is not None:
|
||||||
|
AccessGrants.set_access_grants("skill", id, access_grants, db=db)
|
||||||
|
|
||||||
|
skill = db.query(Skill).get(id)
|
||||||
|
db.refresh(skill)
|
||||||
|
return self._to_skill_model(skill, db=db)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def toggle_skill_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[SkillModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
try:
|
||||||
|
skill = db.query(Skill).filter_by(id=id).first()
|
||||||
|
if not skill:
|
||||||
|
return None
|
||||||
|
|
||||||
|
skill.is_active = not skill.is_active
|
||||||
|
skill.updated_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
db.refresh(skill)
|
||||||
|
|
||||||
|
return self._to_skill_model(skill, db=db)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
AccessGrants.revoke_all_access("skill", id, db=db)
|
||||||
|
db.query(Skill).filter_by(id=id).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
Skills = SkillsTable()
|
||||||
@@ -3,15 +3,14 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, get_db
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@@ -24,6 +23,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"),)
|
||||||
|
|
||||||
@@ -47,8 +51,10 @@ class TagChatIdForm(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TagTable:
|
class TagTable:
|
||||||
def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]:
|
def insert_new_tag(
|
||||||
with get_db() as db:
|
self, name: str, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[TagModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
id = name.replace(" ", "_").lower()
|
id = name.replace(" ", "_").lower()
|
||||||
tag = TagModel(**{"id": id, "user_id": user_id, "name": name})
|
tag = TagModel(**{"id": id, "user_id": user_id, "name": name})
|
||||||
try:
|
try:
|
||||||
@@ -65,27 +71,29 @@ class TagTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_tag_by_name_and_user_id(
|
def get_tag_by_name_and_user_id(
|
||||||
self, name: str, user_id: str
|
self, name: str, user_id: str, db: Optional[Session] = None
|
||||||
) -> Optional[TagModel]:
|
) -> Optional[TagModel]:
|
||||||
try:
|
try:
|
||||||
id = name.replace(" ", "_").lower()
|
id = name.replace(" ", "_").lower()
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
tag = db.query(Tag).filter_by(id=id, user_id=user_id).first()
|
tag = db.query(Tag).filter_by(id=id, user_id=user_id).first()
|
||||||
return TagModel.model_validate(tag)
|
return TagModel.model_validate(tag)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_tags_by_user_id(self, user_id: str) -> list[TagModel]:
|
def get_tags_by_user_id(
|
||||||
with get_db() as db:
|
self, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> list[TagModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
TagModel.model_validate(tag)
|
TagModel.model_validate(tag)
|
||||||
for tag in (db.query(Tag).filter_by(user_id=user_id).all())
|
for tag in (db.query(Tag).filter_by(user_id=user_id).all())
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_tags_by_ids_and_user_id(
|
def get_tags_by_ids_and_user_id(
|
||||||
self, ids: list[str], user_id: str
|
self, ids: list[str], user_id: str, db: Optional[Session] = None
|
||||||
) -> list[TagModel]:
|
) -> list[TagModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
return [
|
return [
|
||||||
TagModel.model_validate(tag)
|
TagModel.model_validate(tag)
|
||||||
for tag in (
|
for tag in (
|
||||||
@@ -93,9 +101,11 @@ class TagTable:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def delete_tag_by_name_and_user_id(self, name: str, user_id: str) -> bool:
|
def delete_tag_by_name_and_user_id(
|
||||||
|
self, name: str, user_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
id = name.replace(" ", "_").lower()
|
id = name.replace(" ", "_").lower()
|
||||||
res = db.query(Tag).filter_by(id=id, user_id=user_id).delete()
|
res = db.query(Tag).filter_by(id=id, user_id=user_id).delete()
|
||||||
log.debug(f"res: {res}")
|
log.debug(f"res: {res}")
|
||||||
@@ -105,5 +115,45 @@ class TagTable:
|
|||||||
log.error(f"delete_tag: {e}")
|
log.error(f"delete_tag: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def delete_tags_by_ids_and_user_id(
|
||||||
|
self, ids: list[str], user_id: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete all tags whose id is in *ids* for the given user, in one query."""
|
||||||
|
if not ids:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"delete_tags_by_ids: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_tags_exist(
|
||||||
|
self, names: list[str], user_id: str, db: Optional[Session] = None
|
||||||
|
) -> None:
|
||||||
|
"""Create tag rows for any *names* that don't already exist for *user_id*."""
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
ids = [n.replace(" ", "_").lower() for n in names]
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
existing = {
|
||||||
|
t.id
|
||||||
|
for t in db.query(Tag.id)
|
||||||
|
.filter(Tag.id.in_(ids), Tag.user_id == user_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
new_tags = [
|
||||||
|
Tag(id=tag_id, name=name, user_id=user_id)
|
||||||
|
for tag_id, name in zip(ids, names)
|
||||||
|
if tag_id not in existing
|
||||||
|
]
|
||||||
|
if new_tags:
|
||||||
|
db.add_all(new_tags)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
Tags = TagTable()
|
Tags = TagTable()
|
||||||
@@ -2,17 +2,16 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from sqlalchemy.orm import Session
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import Users, UserResponse
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.models.groups import Groups
|
||||||
from pydantic import BaseModel, ConfigDict
|
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
|
||||||
|
|
||||||
from open_webui.utils.access_control import has_access
|
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import BigInteger, Column, String, Text
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Tools DB Schema
|
# Tools DB Schema
|
||||||
@@ -22,7 +21,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)
|
||||||
@@ -30,23 +29,6 @@ class Tool(Base):
|
|||||||
meta = Column(JSONField)
|
meta = Column(JSONField)
|
||||||
valves = Column(JSONField)
|
valves = Column(JSONField)
|
||||||
|
|
||||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
|
||||||
# Defines access control rules for this entry.
|
|
||||||
# - `None`: Public access, available to all users with the "user" role.
|
|
||||||
# - `{}`: Private access, restricted exclusively to the owner.
|
|
||||||
# - Custom permissions: Specific access control for reading and writing;
|
|
||||||
# Can specify group or user-level restrictions:
|
|
||||||
# {
|
|
||||||
# "read": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# },
|
|
||||||
# "write": {
|
|
||||||
# "group_ids": ["group_id1", "group_id2"],
|
|
||||||
# "user_ids": ["user_id1", "user_id2"]
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
|
|
||||||
@@ -63,7 +45,7 @@ class ToolModel(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
specs: list[dict]
|
specs: list[dict]
|
||||||
meta: ToolMeta
|
meta: ToolMeta
|
||||||
access_control: Optional[dict] = None
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
|
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
@@ -85,7 +67,7 @@ class ToolResponse(BaseModel):
|
|||||||
user_id: str
|
user_id: str
|
||||||
name: str
|
name: str
|
||||||
meta: ToolMeta
|
meta: ToolMeta
|
||||||
access_control: Optional[dict] = None
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||||
updated_at: int # timestamp in epoch
|
updated_at: int # timestamp in epoch
|
||||||
created_at: int # timestamp in epoch
|
created_at: int # timestamp in epoch
|
||||||
|
|
||||||
@@ -93,13 +75,19 @@ class ToolResponse(BaseModel):
|
|||||||
class ToolUserResponse(ToolResponse):
|
class ToolUserResponse(ToolResponse):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolAccessResponse(ToolUserResponse):
|
||||||
|
write_access: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class ToolForm(BaseModel):
|
class ToolForm(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
content: str
|
content: str
|
||||||
meta: ToolMeta
|
meta: ToolMeta
|
||||||
access_control: Optional[dict] = None
|
access_grants: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class ToolValves(BaseModel):
|
class ToolValves(BaseModel):
|
||||||
@@ -107,50 +95,74 @@ class ToolValves(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ToolsTable:
|
class ToolsTable:
|
||||||
def insert_new_tool(
|
def _get_access_grants(
|
||||||
self, user_id: str, form_data: ToolForm, specs: list[dict]
|
self, tool_id: str, db: Optional[Session] = None
|
||||||
) -> Optional[ToolModel]:
|
) -> list[AccessGrantModel]:
|
||||||
with get_db() as db:
|
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
|
||||||
tool = ToolModel(
|
|
||||||
**{
|
|
||||||
**form_data.model_dump(),
|
|
||||||
"specs": specs,
|
|
||||||
"user_id": user_id,
|
|
||||||
"updated_at": int(time.time()),
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
|
||||||
|
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
|
||||||
|
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
|
||||||
|
return ToolModel.model_validate(tool_data)
|
||||||
|
|
||||||
|
def insert_new_tool(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
form_data: ToolForm,
|
||||||
|
specs: list[dict],
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[ToolModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
try:
|
try:
|
||||||
result = Tool(**tool.model_dump())
|
result = Tool(
|
||||||
|
**{
|
||||||
|
**form_data.model_dump(exclude={"access_grants"}),
|
||||||
|
"specs": specs,
|
||||||
|
"user_id": user_id,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
|
AccessGrants.set_access_grants(
|
||||||
|
"tool", result.id, form_data.access_grants, db=db
|
||||||
|
)
|
||||||
if result:
|
if result:
|
||||||
return ToolModel.model_validate(result)
|
return self._to_tool_model(result, db=db)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error creating a new tool: {e}")
|
log.exception(f"Error creating a new tool: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_tool_by_id(self, id: str) -> Optional[ToolModel]:
|
def get_tool_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[ToolModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
tool = db.get(Tool, id)
|
tool = db.get(Tool, id)
|
||||||
return ToolModel.model_validate(tool)
|
return self._to_tool_model(tool, db=db) if tool else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_tools(self) -> list[ToolUserModel]:
|
def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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, db=db) 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(
|
||||||
{
|
{
|
||||||
**ToolModel.model_validate(tool).model_dump(),
|
**self._to_tool_model(tool, db=db).model_dump(),
|
||||||
"user": user.model_dump() if user else None,
|
"user": user.model_dump() if user else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -158,42 +170,56 @@ class ToolsTable:
|
|||||||
return tools
|
return tools
|
||||||
|
|
||||||
def get_tools_by_user_id(
|
def get_tools_by_user_id(
|
||||||
self, user_id: str, permission: str = "write"
|
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||||
) -> list[ToolUserModel]:
|
) -> list[ToolUserModel]:
|
||||||
tools = self.get_tools()
|
tools = self.get_tools(db=db)
|
||||||
|
user_group_ids = {
|
||||||
|
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||||
|
}
|
||||||
|
|
||||||
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 AccessGrants.has_access(
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="tool",
|
||||||
|
resource_id=tool.id,
|
||||||
|
permission=permission,
|
||||||
|
user_group_ids=user_group_ids,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
|
def get_tool_valves_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[dict]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
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, db: Optional[Session] = None
|
||||||
|
) -> Optional[ToolValves]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(Tool).filter_by(id=id).update(
|
db.query(Tool).filter_by(id=id).update(
|
||||||
{"valves": valves, "updated_at": int(time.time())}
|
{"valves": valves, "updated_at": int(time.time())}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return self.get_tool_by_id(id)
|
return self.get_tool_by_id(id, db=db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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, db: Optional[Session] = None
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
try:
|
try:
|
||||||
user = Users.get_user_by_id(user_id)
|
user = Users.get_user_by_id(user_id, db=db)
|
||||||
user_settings = user.settings.model_dump() if user.settings else {}
|
user_settings = user.settings.model_dump() if user.settings else {}
|
||||||
|
|
||||||
# Check if user has "tools" and "valves" settings
|
# Check if user has "tools" and "valves" settings
|
||||||
@@ -210,10 +236,10 @@ class ToolsTable:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_valves_by_id_and_user_id(
|
def update_user_valves_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, valves: dict
|
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
try:
|
try:
|
||||||
user = Users.get_user_by_id(user_id)
|
user = Users.get_user_by_id(user_id, db=db)
|
||||||
user_settings = user.settings.model_dump() if user.settings else {}
|
user_settings = user.settings.model_dump() if user.settings else {}
|
||||||
|
|
||||||
# Check if user has "tools" and "valves" settings
|
# Check if user has "tools" and "valves" settings
|
||||||
@@ -225,7 +251,7 @@ class ToolsTable:
|
|||||||
user_settings["tools"]["valves"][id] = valves
|
user_settings["tools"]["valves"][id] = valves
|
||||||
|
|
||||||
# Update the user settings in the database
|
# Update the user settings in the database
|
||||||
Users.update_user_by_id(user_id, {"settings": user_settings})
|
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
|
||||||
|
|
||||||
return user_settings["tools"]["valves"][id]
|
return user_settings["tools"]["valves"][id]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -234,23 +260,29 @@ class ToolsTable:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]:
|
def update_tool_by_id(
|
||||||
|
self, id: str, updated: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[ToolModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
|
access_grants = updated.pop("access_grants", None)
|
||||||
db.query(Tool).filter_by(id=id).update(
|
db.query(Tool).filter_by(id=id).update(
|
||||||
{**updated, "updated_at": int(time.time())}
|
{**updated, "updated_at": int(time.time())}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if access_grants is not None:
|
||||||
|
AccessGrants.set_access_grants("tool", id, access_grants, db=db)
|
||||||
|
|
||||||
tool = db.query(Tool).get(id)
|
tool = db.query(Tool).get(id)
|
||||||
db.refresh(tool)
|
db.refresh(tool)
|
||||||
return ToolModel.model_validate(tool)
|
return self._to_tool_model(tool, db=db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_tool_by_id(self, id: str) -> bool:
|
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
|
AccessGrants.revoke_all_access("tool", id, db=db)
|
||||||
db.query(Tool).filter_by(id=id).delete()
|
db.query(Tool).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
+571
-131
@@ -1,65 +1,152 @@
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.internal.db import Base, JSONField, get_db
|
from sqlalchemy.orm import Session, defer
|
||||||
|
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||||
|
|
||||||
|
|
||||||
|
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 open_webui.utils.validate import validate_profile_image_url
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
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, func
|
||||||
|
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)
|
||||||
|
scim = 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"
|
||||||
profile_image_url: str
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
profile_image_url: Optional[str] = None
|
||||||
|
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
|
||||||
|
scim: 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
|
@model_validator(mode="after")
|
||||||
|
def set_profile_image_url(self):
|
||||||
|
if not self.profile_image_url:
|
||||||
|
self.profile_image_url = f"/api/v1/users/{self.id}/profile/image"
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
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,16 +156,62 @@ 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
|
||||||
|
|
||||||
|
@field_validator("profile_image_url")
|
||||||
|
@classmethod
|
||||||
|
def check_profile_image_url(cls, v: str) -> str:
|
||||||
|
return validate_profile_image_url(v)
|
||||||
|
|
||||||
|
|
||||||
|
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 UserInfoResponse(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
|
||||||
|
bio: Optional[str] = None
|
||||||
|
groups: Optional[list] = []
|
||||||
|
is_active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdNameResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdNameStatusResponse(UserStatus):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class UserInfoListResponse(BaseModel):
|
class UserInfoListResponse(BaseModel):
|
||||||
@@ -86,18 +219,23 @@ class UserInfoListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserIdNameListResponse(BaseModel):
|
||||||
id: str
|
users: list[UserIdNameResponse]
|
||||||
name: str
|
total: int
|
||||||
email: str
|
|
||||||
role: str
|
|
||||||
profile_image_url: str
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +251,11 @@ class UserUpdateForm(BaseModel):
|
|||||||
profile_image_url: str
|
profile_image_url: str
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("profile_image_url")
|
||||||
|
@classmethod
|
||||||
|
def check_profile_image_url(cls, v: str) -> str:
|
||||||
|
return validate_profile_image_url(v)
|
||||||
|
|
||||||
|
|
||||||
class UsersTable:
|
class UsersTable:
|
||||||
def insert_new_user(
|
def insert_new_user(
|
||||||
@@ -122,20 +265,23 @@ 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,
|
username: Optional[str] = None,
|
||||||
|
oauth: Optional[dict] = None,
|
||||||
|
db: Optional[Session] = None,
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
with get_db() as db:
|
with get_db_context(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,
|
"username": username,
|
||||||
|
"oauth": oauth,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = User(**user.model_dump())
|
result = User(**user.model_dump())
|
||||||
@@ -147,35 +293,86 @@ class UsersTable:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_by_id(self, id: str) -> Optional[UserModel]:
|
def get_user_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
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)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
|
def get_user_by_api_key(
|
||||||
|
self, api_key: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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
|
||||||
|
|
||||||
def get_user_by_email(self, email: str) -> Optional[UserModel]:
|
def get_user_by_email(
|
||||||
|
self, email: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
user = db.query(User).filter_by(email=email).first()
|
user = (
|
||||||
return UserModel.model_validate(user)
|
db.query(User)
|
||||||
|
.filter(func.lower(User.email) == email.lower())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return UserModel.model_validate(user) if user else None
|
||||||
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, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(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)
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def get_user_by_scim_external_id(
|
||||||
|
self, provider: str, external_id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db: # type: Session
|
||||||
|
dialect_name = db.bind.dialect.name
|
||||||
|
|
||||||
|
query = db.query(User)
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
query = query.filter(
|
||||||
|
User.scim.contains({provider: {"external_id": external_id}})
|
||||||
|
)
|
||||||
|
elif dialect_name == "postgresql":
|
||||||
|
query = query.filter(
|
||||||
|
User.scim[provider].cast(JSONB)["external_id"].astext
|
||||||
|
== external_id
|
||||||
|
)
|
||||||
|
|
||||||
|
user = query.first()
|
||||||
|
return UserModel.model_validate(user) if user else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -184,9 +381,11 @@ 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:
|
db: Optional[Session] = None,
|
||||||
with get_db() as db:
|
) -> dict:
|
||||||
query = db.query(User)
|
with get_db_context(db) as db:
|
||||||
|
# Join GroupMember so we can order by group_id when requested
|
||||||
|
query = db.query(User).options(defer(User.profile_image_url))
|
||||||
|
|
||||||
if filter:
|
if filter:
|
||||||
query_key = filter.get("query")
|
query_key = filter.get("query")
|
||||||
@@ -198,14 +397,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())
|
||||||
@@ -238,37 +499,67 @@ 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(
|
||||||
with get_db() as db:
|
self, group_id: str, db: Optional[Session] = None
|
||||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
) -> list[UserModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
users = (
|
||||||
|
db.query(User)
|
||||||
|
.options(defer(User.profile_image_url))
|
||||||
|
.join(GroupMember, User.id == GroupMember.user_id)
|
||||||
|
.filter(GroupMember.group_id == group_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
return [UserModel.model_validate(user) for user in users]
|
return [UserModel.model_validate(user) for user in users]
|
||||||
|
|
||||||
def get_num_users(self) -> Optional[int]:
|
def get_users_by_user_ids(
|
||||||
with get_db() as db:
|
self, user_ids: list[str], db: Optional[Session] = None
|
||||||
|
) -> list[UserStatusModel]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
users = (
|
||||||
|
db.query(User)
|
||||||
|
.options(defer(User.profile_image_url))
|
||||||
|
.filter(User.id.in_(user_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [UserModel.model_validate(user) for user in users]
|
||||||
|
|
||||||
|
def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
|
||||||
|
with get_db_context(db) as db:
|
||||||
return db.query(User).count()
|
return db.query(User).count()
|
||||||
|
|
||||||
def get_first_user(self) -> UserModel:
|
def has_users(self, db: Optional[Session] = None) -> bool:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
return db.query(db.query(User).exists()).scalar()
|
||||||
|
|
||||||
|
def get_first_user(self, db: Optional[Session] = None) -> UserModel:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
user = db.query(User).order_by(User.created_at).first()
|
user = db.query(User).order_by(User.created_at).first()
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_webhook_url_by_id(self, id: str) -> Optional[str]:
|
def get_user_webhook_url_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
user = db.query(User).filter_by(id=id).first()
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
|
||||||
if user.settings is None:
|
if user.settings is None:
|
||||||
@@ -282,73 +573,168 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
|
def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[int]:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(User).filter_by(id=id).update({"role": role})
|
|
||||||
db.commit()
|
|
||||||
user = db.query(User).filter_by(id=id).first()
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.role = role
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return UserModel.model_validate(user)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_user_status_by_id(
|
||||||
|
self, id: str, form_data: UserStatus, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
for key, value in form_data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(user, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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, db: Optional[Session] = None
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(User).filter_by(id=id).update(
|
|
||||||
{"profile_image_url": profile_image_url}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
user = db.query(User).filter_by(id=id).first()
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.profile_image_url = profile_image_url
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
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)
|
||||||
try:
|
def update_last_active_by_id(
|
||||||
with get_db() as db:
|
self, id: str, db: Optional[Session] = None
|
||||||
db.query(User).filter_by(id=id).update(
|
|
||||||
{"last_active_at": int(time.time())}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
user = db.query(User).filter_by(id=id).first()
|
|
||||||
return UserModel.model_validate(user)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update_user_oauth_sub_by_id(
|
|
||||||
self, id: str, oauth_sub: str
|
|
||||||
) -> Optional[UserModel]:
|
) -> Optional[UserModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub})
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
user = db.query(User).filter_by(id=id).first()
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.last_active_at = int(time.time())
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
|
def update_user_oauth_by_id(
|
||||||
|
self, id: str, provider: str, sub: str, db: Optional[Session] = None
|
||||||
|
) -> 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_context(db) as db:
|
||||||
db.query(User).filter_by(id=id).update(updated)
|
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)
|
||||||
# return UserModel(**user.dict())
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
|
def update_user_scim_by_id(
|
||||||
|
self,
|
||||||
|
id: str,
|
||||||
|
provider: str,
|
||||||
|
external_id: str,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
) -> Optional[UserModel]:
|
||||||
|
"""
|
||||||
|
Update or insert a SCIM provider/external_id pair into the user's scim JSON field.
|
||||||
|
Example resulting structure:
|
||||||
|
{
|
||||||
|
"microsoft": { "external_id": "abc" },
|
||||||
|
"okta": { "external_id": "def" }
|
||||||
|
}
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
user_settings = db.query(User).filter_by(id=id).first().settings
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scim = user.scim or {}
|
||||||
|
scim[provider] = {"external_id": external_id}
|
||||||
|
|
||||||
|
db.query(User).filter_by(id=id).update({"scim": scim})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return UserModel.model_validate(user)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_user_by_id(
|
||||||
|
self, id: str, updated: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
for key, value in updated.items():
|
||||||
|
setattr(user, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return UserModel.model_validate(user)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_user_settings_by_id(
|
||||||
|
self, id: str, updated: dict, db: Optional[Session] = None
|
||||||
|
) -> Optional[UserModel]:
|
||||||
|
try:
|
||||||
|
with get_db_context(db) as db:
|
||||||
|
user = db.query(User).filter_by(id=id).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_settings = user.settings
|
||||||
|
|
||||||
if user_settings is None:
|
if user_settings is None:
|
||||||
user_settings = {}
|
user_settings = {}
|
||||||
@@ -363,15 +749,15 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete_user_by_id(self, id: str) -> bool:
|
def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
# Remove User from Groups
|
# Remove User from Groups
|
||||||
Groups.remove_user_from_all_groups(id)
|
Groups.remove_user_from_all_groups(id)
|
||||||
|
|
||||||
# Delete User Chats
|
# Delete User Chats
|
||||||
result = Chats.delete_chats_by_user_id(id)
|
result = Chats.delete_chats_by_user_id(id, db=db)
|
||||||
if result:
|
if result:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
# Delete User
|
# Delete User
|
||||||
db.query(User).filter_by(id=id).delete()
|
db.query(User).filter_by(id=id).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -382,35 +768,89 @@ class UsersTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
|
def get_user_api_key_by_id(
|
||||||
|
self, id: str, db: Optional[Session] = None
|
||||||
|
) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
result = db.query(User).filter_by(id=id).update({"api_key": api_key})
|
api_key = db.query(ApiKey).filter_by(user_id=id).first()
|
||||||
db.commit()
|
return api_key.key if api_key else None
|
||||||
return True if result == 1 else False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
|
|
||||||
try:
|
|
||||||
with get_db() as db:
|
|
||||||
user = db.query(User).filter_by(id=id).first()
|
|
||||||
return user.api_key
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
|
def update_user_api_key_by_id(
|
||||||
with get_db() as db:
|
self, id: str, api_key: str, db: Optional[Session] = None
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db_context(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, db: Optional[Session] = None) -> bool:
|
||||||
|
try:
|
||||||
|
with get_db_context(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], db: Optional[Session] = None
|
||||||
|
) -> list[str]:
|
||||||
|
with get_db_context(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 [user.id for user in users]
|
return [user.id for user in users]
|
||||||
|
|
||||||
def get_super_admin_user(self) -> Optional[UserModel]:
|
def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]:
|
||||||
with get_db() as db:
|
with get_db_context(db) as db:
|
||||||
user = db.query(User).filter_by(role="admin").first()
|
user = db.query(User).filter_by(role="admin").first()
|
||||||
if user:
|
if user:
|
||||||
return UserModel.model_validate(user)
|
return UserModel.model_validate(user)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_active_user_count(self, db: Optional[Session] = None) -> int:
|
||||||
|
with get_db_context(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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_active(user: UserModel) -> bool:
|
||||||
|
"""Compute active status from an already-loaded UserModel (no DB hit)."""
|
||||||
|
if user.last_active_at:
|
||||||
|
three_minutes_ago = int(time.time()) - 180
|
||||||
|
return user.last_active_at >= three_minutes_ago
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
|
||||||
|
with get_db_context(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()
|
||||||
@@ -15,24 +15,28 @@ class DatalabMarkerLoader:
|
|||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
langs: Optional[str] = None,
|
api_base_url: str,
|
||||||
|
additional_config: Optional[str] = None,
|
||||||
use_llm: bool = False,
|
use_llm: bool = False,
|
||||||
skip_cache: bool = False,
|
skip_cache: bool = False,
|
||||||
force_ocr: bool = False,
|
force_ocr: bool = False,
|
||||||
paginate: bool = False,
|
paginate: bool = False,
|
||||||
strip_existing_ocr: bool = False,
|
strip_existing_ocr: bool = False,
|
||||||
disable_image_extraction: bool = False,
|
disable_image_extraction: bool = False,
|
||||||
|
format_lines: bool = False,
|
||||||
output_format: str = None,
|
output_format: str = None,
|
||||||
):
|
):
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.langs = langs
|
self.api_base_url = api_base_url
|
||||||
|
self.additional_config = additional_config
|
||||||
self.use_llm = use_llm
|
self.use_llm = use_llm
|
||||||
self.skip_cache = skip_cache
|
self.skip_cache = skip_cache
|
||||||
self.force_ocr = force_ocr
|
self.force_ocr = force_ocr
|
||||||
self.paginate = paginate
|
self.paginate = paginate
|
||||||
self.strip_existing_ocr = strip_existing_ocr
|
self.strip_existing_ocr = strip_existing_ocr
|
||||||
self.disable_image_extraction = disable_image_extraction
|
self.disable_image_extraction = disable_image_extraction
|
||||||
|
self.format_lines = format_lines
|
||||||
self.output_format = output_format
|
self.output_format = output_format
|
||||||
|
|
||||||
def _get_mime_type(self, filename: str) -> str:
|
def _get_mime_type(self, filename: str) -> str:
|
||||||
@@ -60,7 +64,7 @@ class DatalabMarkerLoader:
|
|||||||
return mime_map.get(ext, "application/octet-stream")
|
return mime_map.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
def check_marker_request_status(self, request_id: str) -> dict:
|
def check_marker_request_status(self, request_id: str) -> dict:
|
||||||
url = f"https://www.datalab.to/api/v1/marker/{request_id}"
|
url = f"{self.api_base_url}/{request_id}"
|
||||||
headers = {"X-Api-Key": self.api_key}
|
headers = {"X-Api-Key": self.api_key}
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers)
|
||||||
@@ -81,22 +85,24 @@ class DatalabMarkerLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def load(self) -> List[Document]:
|
def load(self) -> List[Document]:
|
||||||
url = "https://www.datalab.to/api/v1/marker"
|
|
||||||
filename = os.path.basename(self.file_path)
|
filename = os.path.basename(self.file_path)
|
||||||
mime_type = self._get_mime_type(filename)
|
mime_type = self._get_mime_type(filename)
|
||||||
headers = {"X-Api-Key": self.api_key}
|
headers = {"X-Api-Key": self.api_key}
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
"langs": self.langs,
|
|
||||||
"use_llm": str(self.use_llm).lower(),
|
"use_llm": str(self.use_llm).lower(),
|
||||||
"skip_cache": str(self.skip_cache).lower(),
|
"skip_cache": str(self.skip_cache).lower(),
|
||||||
"force_ocr": str(self.force_ocr).lower(),
|
"force_ocr": str(self.force_ocr).lower(),
|
||||||
"paginate": str(self.paginate).lower(),
|
"paginate": str(self.paginate).lower(),
|
||||||
"strip_existing_ocr": str(self.strip_existing_ocr).lower(),
|
"strip_existing_ocr": str(self.strip_existing_ocr).lower(),
|
||||||
"disable_image_extraction": str(self.disable_image_extraction).lower(),
|
"disable_image_extraction": str(self.disable_image_extraction).lower(),
|
||||||
|
"format_lines": str(self.format_lines).lower(),
|
||||||
"output_format": self.output_format,
|
"output_format": self.output_format,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.additional_config and self.additional_config.strip():
|
||||||
|
form_data["additional_config"] = self.additional_config
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
|
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
|
||||||
)
|
)
|
||||||
@@ -105,7 +111,10 @@ class DatalabMarkerLoader:
|
|||||||
with open(self.file_path, "rb") as f:
|
with open(self.file_path, "rb") as f:
|
||||||
files = {"file": (filename, f, mime_type)}
|
files = {"file": (filename, f, mime_type)}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url, data=form_data, files=files, headers=headers
|
f"{self.api_base_url}",
|
||||||
|
data=form_data,
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -133,74 +142,92 @@ class DatalabMarkerLoader:
|
|||||||
|
|
||||||
check_url = result.get("request_check_url")
|
check_url = result.get("request_check_url")
|
||||||
request_id = result.get("request_id")
|
request_id = result.get("request_id")
|
||||||
if not check_url:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned."
|
|
||||||
)
|
|
||||||
|
|
||||||
for _ in range(300): # Up to 10 minutes
|
# Check if this is a direct response (self-hosted) or polling response (DataLab)
|
||||||
time.sleep(2)
|
if check_url:
|
||||||
try:
|
# DataLab polling pattern
|
||||||
poll_response = requests.get(check_url, headers=headers)
|
for _ in range(300): # Up to 10 minutes
|
||||||
poll_response.raise_for_status()
|
time.sleep(2)
|
||||||
poll_result = poll_response.json()
|
try:
|
||||||
except (requests.HTTPError, ValueError) as e:
|
poll_response = requests.get(check_url, headers=headers)
|
||||||
raw_body = poll_response.text
|
poll_response.raise_for_status()
|
||||||
log.error(f"Polling error: {e}, response body: {raw_body}")
|
poll_result = poll_response.json()
|
||||||
raise HTTPException(
|
except (requests.HTTPError, ValueError) as e:
|
||||||
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
|
raw_body = poll_response.text
|
||||||
)
|
log.error(f"Polling error: {e}, response body: {raw_body}")
|
||||||
|
raise HTTPException(
|
||||||
status_val = poll_result.get("status")
|
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
|
||||||
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:
|
status_val = poll_result.get("status")
|
||||||
log.error(
|
success_val = poll_result.get("success")
|
||||||
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
|
|
||||||
)
|
if status_val == "complete":
|
||||||
error_msg = (
|
summary = {
|
||||||
poll_result.get("error")
|
k: poll_result.get(k)
|
||||||
or "Marker returned failure without error message"
|
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(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Marker processing failed: {error_msg}",
|
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:
|
else:
|
||||||
raise HTTPException(
|
# Self-hosted direct response - content in "output" field
|
||||||
status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out"
|
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 not poll_result.get("success", False):
|
if self.output_format.lower() == "json":
|
||||||
error_msg = poll_result.get("error") or "Unknown processing error"
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Final processing failed: {error_msg}",
|
|
||||||
)
|
|
||||||
|
|
||||||
content_key = self.output_format.lower()
|
|
||||||
raw_content = poll_result.get(content_key)
|
|
||||||
|
|
||||||
if content_key == "json":
|
|
||||||
full_text = json.dumps(raw_content, indent=2)
|
full_text = json.dumps(raw_content, indent=2)
|
||||||
elif content_key in {"markdown", "html"}:
|
elif self.output_format.lower() in {"markdown", "html"}:
|
||||||
full_text = str(raw_content).strip()
|
full_text = str(raw_content).strip()
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -211,14 +238,14 @@ class DatalabMarkerLoader:
|
|||||||
if not full_text:
|
if not full_text:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Datalab Marker returned empty content",
|
detail="Marker returned empty content",
|
||||||
)
|
)
|
||||||
|
|
||||||
marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output")
|
marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output")
|
||||||
os.makedirs(marker_output_dir, exist_ok=True)
|
os.makedirs(marker_output_dir, exist_ok=True)
|
||||||
|
|
||||||
file_ext_map = {"markdown": "md", "json": "json", "html": "html"}
|
file_ext_map = {"markdown": "md", "json": "json", "html": "html"}
|
||||||
file_ext = file_ext_map.get(content_key, "txt")
|
file_ext = file_ext_map.get(self.output_format.lower(), "txt")
|
||||||
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
|
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
|
||||||
output_path = os.path.join(marker_output_dir, output_filename)
|
output_path = os.path.join(marker_output_dir, output_filename)
|
||||||
|
|
||||||
@@ -231,13 +258,13 @@ class DatalabMarkerLoader:
|
|||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"source": filename,
|
"source": filename,
|
||||||
"output_format": poll_result.get("output_format", self.output_format),
|
"output_format": final_result.get("output_format", self.output_format),
|
||||||
"page_count": poll_result.get("page_count", 0),
|
"page_count": final_result.get("page_count", 0),
|
||||||
"processed_with_llm": self.use_llm,
|
"processed_with_llm": self.use_llm,
|
||||||
"request_id": request_id or "",
|
"request_id": request_id or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
images = poll_result.get("images", {})
|
images = final_result.get("images", {})
|
||||||
if images:
|
if images:
|
||||||
metadata["image_count"] = len(images)
|
metadata["image_count"] = len(images)
|
||||||
metadata["images"] = json.dumps(list(images.keys()))
|
metadata["images"] = json.dumps(list(images.keys()))
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import requests
|
import requests
|
||||||
import logging, os
|
import logging, os
|
||||||
from typing import Iterator, List, Union
|
from typing import Iterator, List, Union
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from langchain_core.document_loaders import BaseLoader
|
from langchain_core.document_loaders import BaseLoader
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalDocumentLoader(BaseLoader):
|
class ExternalDocumentLoader(BaseLoader):
|
||||||
@@ -17,6 +17,7 @@ class ExternalDocumentLoader(BaseLoader):
|
|||||||
url: str,
|
url: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
mime_type=None,
|
mime_type=None,
|
||||||
|
user=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.url = url
|
self.url = url
|
||||||
@@ -25,6 +26,8 @@ class ExternalDocumentLoader(BaseLoader):
|
|||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.mime_type = mime_type
|
self.mime_type = mime_type
|
||||||
|
|
||||||
|
self.user = user
|
||||||
|
|
||||||
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:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
@@ -37,10 +40,13 @@ class ExternalDocumentLoader(BaseLoader):
|
|||||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
headers["X-Filename"] = os.path.basename(self.file_path)
|
headers["X-Filename"] = quote(os.path.basename(self.file_path))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if self.user is not None:
|
||||||
|
headers = include_user_info_headers(headers, self.user)
|
||||||
|
|
||||||
url = self.url
|
url = self.url
|
||||||
if url.endswith("/"):
|
if url.endswith("/"):
|
||||||
url = url[:-1]
|
url = url[:-1]
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ from typing import Iterator, List, Union
|
|||||||
|
|
||||||
from langchain_core.document_loaders import BaseLoader
|
from langchain_core.document_loaders import BaseLoader
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalWebLoader(BaseLoader):
|
class ExternalWebLoader(BaseLoader):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ftfy
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from azure.identity import DefaultAzureCredential
|
||||||
from langchain_community.document_loaders import (
|
from langchain_community.document_loaders import (
|
||||||
AzureAIDocumentIntelligenceLoader,
|
AzureAIDocumentIntelligenceLoader,
|
||||||
BSHTMLLoader,
|
BSHTMLLoader,
|
||||||
@@ -26,13 +27,13 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade
|
|||||||
|
|
||||||
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.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 GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
known_source_ext = [
|
known_source_ext = [
|
||||||
"go",
|
"go",
|
||||||
@@ -113,7 +114,7 @@ class TikaLoader:
|
|||||||
endpoint += "/"
|
endpoint += "/"
|
||||||
endpoint += "tika/text"
|
endpoint += "tika/text"
|
||||||
|
|
||||||
r = requests.put(endpoint, data=data, headers=headers)
|
r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
|
||||||
|
|
||||||
if r.ok:
|
if r.ok:
|
||||||
raw_metadata = r.json()
|
raw_metadata = r.json()
|
||||||
@@ -130,8 +131,9 @@ class TikaLoader:
|
|||||||
|
|
||||||
|
|
||||||
class DoclingLoader:
|
class DoclingLoader:
|
||||||
def __init__(self, url, file_path=None, mime_type=None, params=None):
|
def __init__(self, url, api_key=None, file_path=None, mime_type=None, params=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
|
||||||
|
|
||||||
@@ -139,51 +141,25 @@ class DoclingLoader:
|
|||||||
|
|
||||||
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:
|
||||||
files = {
|
headers = {}
|
||||||
"files": (
|
if self.api_key:
|
||||||
self.file_path,
|
headers["X-Api-Key"] = f"{self.api_key}"
|
||||||
f,
|
|
||||||
self.mime_type or "application/octet-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
|
r = requests.post(
|
||||||
|
f"{self.url}/v1/convert/file",
|
||||||
if self.params:
|
files={
|
||||||
if self.params.get("do_picture_description"):
|
"files": (
|
||||||
params["do_picture_description"] = self.params.get(
|
self.file_path,
|
||||||
"do_picture_description"
|
f,
|
||||||
|
self.mime_type or "application/octet-stream",
|
||||||
)
|
)
|
||||||
|
},
|
||||||
picture_description_mode = self.params.get(
|
data={
|
||||||
"picture_description_mode", ""
|
"image_export_mode": "placeholder",
|
||||||
).lower()
|
**self.params,
|
||||||
|
},
|
||||||
if picture_description_mode == "local" and self.params.get(
|
headers=headers,
|
||||||
"picture_description_local", {}
|
)
|
||||||
):
|
|
||||||
params["picture_description_local"] = json.dumps(
|
|
||||||
self.params.get("picture_description_local", {})
|
|
||||||
)
|
|
||||||
|
|
||||||
elif picture_description_mode == "api" and self.params.get(
|
|
||||||
"picture_description_api", {}
|
|
||||||
):
|
|
||||||
params["picture_description_api"] = json.dumps(
|
|
||||||
self.params.get("picture_description_api", {})
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
|
|
||||||
params["ocr_engine"] = self.params.get("ocr_engine")
|
|
||||||
params["ocr_lang"] = [
|
|
||||||
lang.strip()
|
|
||||||
for lang in self.params.get("ocr_lang").split(",")
|
|
||||||
if lang.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
endpoint = f"{self.url}/v1alpha/convert/file"
|
|
||||||
r = requests.post(endpoint, files=files, data=params)
|
|
||||||
|
|
||||||
if r.ok:
|
if r.ok:
|
||||||
result = r.json()
|
result = r.json()
|
||||||
document_data = result.get("document", {})
|
document_data = result.get("document", {})
|
||||||
@@ -192,7 +168,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}"
|
||||||
@@ -209,6 +184,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(
|
||||||
@@ -245,6 +221,7 @@ class Loader:
|
|||||||
url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"),
|
url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"),
|
||||||
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
|
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
|
||||||
mime_type=file_content_type,
|
mime_type=file_content_type,
|
||||||
|
user=self.user,
|
||||||
)
|
)
|
||||||
elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
|
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):
|
||||||
@@ -253,7 +230,6 @@ class Loader:
|
|||||||
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 (
|
elif (
|
||||||
@@ -281,10 +257,15 @@ class Loader:
|
|||||||
"tiff",
|
"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(
|
loader = DatalabMarkerLoader(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
|
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
|
||||||
langs=self.kwargs.get("DATALAB_MARKER_LANGS"),
|
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),
|
use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False),
|
||||||
skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False),
|
skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False),
|
||||||
force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False),
|
force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False),
|
||||||
@@ -295,6 +276,7 @@ class Loader:
|
|||||||
disable_image_extraction=self.kwargs.get(
|
disable_image_extraction=self.kwargs.get(
|
||||||
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
|
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
|
||||||
),
|
),
|
||||||
|
format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", False),
|
||||||
output_format=self.kwargs.get(
|
output_format=self.kwargs.get(
|
||||||
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
|
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
|
||||||
),
|
),
|
||||||
@@ -314,6 +296,7 @@ class Loader:
|
|||||||
|
|
||||||
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,
|
||||||
params=params,
|
params=params,
|
||||||
@@ -321,23 +304,48 @@ class Loader:
|
|||||||
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
|
||||||
|
|
||||||
|
mineru_timeout = self.kwargs.get("MINERU_API_TIMEOUT", 300)
|
||||||
|
if mineru_timeout:
|
||||||
|
try:
|
||||||
|
mineru_timeout = int(mineru_timeout)
|
||||||
|
except ValueError:
|
||||||
|
mineru_timeout = 300
|
||||||
|
|
||||||
|
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", {}),
|
||||||
|
timeout=mineru_timeout,
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
self.engine == "mistral_ocr"
|
self.engine == "mistral_ocr"
|
||||||
@@ -346,21 +354,16 @@ 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"),
|
||||||
elif (
|
file_path=file_path,
|
||||||
self.engine == "external"
|
|
||||||
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
|
|
||||||
and file_ext
|
|
||||||
in ["pdf"] # Mistral OCR currently only supports PDF and images
|
|
||||||
):
|
|
||||||
loader = MistralLoader(
|
|
||||||
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if file_ext == "pdf":
|
if file_ext == "pdf":
|
||||||
loader = PyPDFLoader(
|
loader = PyPDFLoader(
|
||||||
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
|
file_path,
|
||||||
|
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
|
||||||
|
mode=self.kwargs.get("PDF_LOADER_MODE", "page"),
|
||||||
)
|
)
|
||||||
elif file_ext == "csv":
|
elif file_ext == "csv":
|
||||||
loader = CSVLoader(file_path, autodetect_encoding=True)
|
loader = CSVLoader(file_path, autodetect_encoding=True)
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
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,
|
||||||
|
timeout: Optional[int] = 300,
|
||||||
|
):
|
||||||
|
self.file_path = file_path
|
||||||
|
self.api_mode = api_mode.lower()
|
||||||
|
self.api_url = api_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
# 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=self.timeout,
|
||||||
|
)
|
||||||
|
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=self.timeout,
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -9,11 +9,10 @@ from typing import List, Dict, Any
|
|||||||
from contextlib import asynccontextmanager
|
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 GLOBAL_LOG_LEVEL
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class MistralLoader:
|
class MistralLoader:
|
||||||
@@ -30,10 +29,9 @@ class MistralLoader:
|
|||||||
- Enhanced error handling with retryable error classification
|
- Enhanced error handling with retryable error classification
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_API_URL = "https://api.mistral.ai/v1"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
base_url: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
timeout: int = 300, # 5 minutes default
|
timeout: int = 300, # 5 minutes default
|
||||||
@@ -55,6 +53,9 @@ class MistralLoader:
|
|||||||
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.timeout = timeout
|
self.timeout = timeout
|
||||||
@@ -240,7 +241,7 @@ class MistralLoader:
|
|||||||
in a context manager to minimize memory usage duration.
|
in a context manager to minimize memory usage duration.
|
||||||
"""
|
"""
|
||||||
log.info("Uploading file to Mistral API")
|
log.info("Uploading file to Mistral API")
|
||||||
url = f"{self.BASE_API_URL}/files"
|
url = f"{self.base_url}/files"
|
||||||
|
|
||||||
def upload_request():
|
def upload_request():
|
||||||
# MEMORY OPTIMIZATION: Use context manager to minimize file handle lifetime
|
# MEMORY OPTIMIZATION: Use context manager to minimize file handle lifetime
|
||||||
@@ -275,7 +276,7 @@ class MistralLoader:
|
|||||||
|
|
||||||
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
|
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
|
||||||
"""Async file upload with streaming for better memory efficiency."""
|
"""Async file upload with streaming for better memory efficiency."""
|
||||||
url = f"{self.BASE_API_URL}/files"
|
url = f"{self.base_url}/files"
|
||||||
|
|
||||||
async def upload_request():
|
async def upload_request():
|
||||||
# Create multipart writer for streaming upload
|
# Create multipart writer for streaming upload
|
||||||
@@ -321,7 +322,7 @@ class MistralLoader:
|
|||||||
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 (sync version)."""
|
"""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"}
|
||||||
|
|
||||||
@@ -346,7 +347,7 @@ class MistralLoader:
|
|||||||
self, session: aiohttp.ClientSession, file_id: str
|
self, session: aiohttp.ClientSession, file_id: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Async signed URL retrieval."""
|
"""Async signed URL retrieval."""
|
||||||
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}
|
||||||
|
|
||||||
headers = {**self.headers, "Accept": "application/json"}
|
headers = {**self.headers, "Accept": "application/json"}
|
||||||
@@ -373,7 +374,7 @@ class MistralLoader:
|
|||||||
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 (sync version)."""
|
"""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",
|
||||||
@@ -407,7 +408,7 @@ class MistralLoader:
|
|||||||
self, session: aiohttp.ClientSession, signed_url: str
|
self, session: aiohttp.ClientSession, signed_url: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Async OCR processing with timing metrics."""
|
"""Async OCR processing with timing metrics."""
|
||||||
url = f"{self.BASE_API_URL}/ocr"
|
url = f"{self.base_url}/ocr"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
**self.headers,
|
**self.headers,
|
||||||
@@ -446,7 +447,7 @@ class MistralLoader:
|
|||||||
def _delete_file(self, file_id: str) -> None:
|
def _delete_file(self, file_id: str) -> None:
|
||||||
"""Deletes the file from Mistral storage (sync version)."""
|
"""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}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.delete(
|
response = requests.delete(
|
||||||
@@ -467,7 +468,7 @@ class MistralLoader:
|
|||||||
async def delete_request():
|
async def delete_request():
|
||||||
self._debug_log(f"Deleting file ID: {file_id}")
|
self._debug_log(f"Deleting file ID: {file_id}")
|
||||||
async with session.delete(
|
async with session.delete(
|
||||||
url=f"{self.BASE_API_URL}/files/{file_id}",
|
url=f"{self.base_url}/files/{file_id}",
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=aiohttp.ClientTimeout(
|
timeout=aiohttp.ClientTimeout(
|
||||||
total=self.cleanup_timeout
|
total=self.cleanup_timeout
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ from typing import Iterator, List, Literal, Union
|
|||||||
|
|
||||||
from langchain_core.document_loaders import BaseLoader
|
from langchain_core.document_loaders import BaseLoader
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class TavilyLoader(BaseLoader):
|
class TavilyLoader(BaseLoader):
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ 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
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
ALLOWED_SCHEMES = {"http", "https"}
|
ALLOWED_SCHEMES = {"http", "https"}
|
||||||
ALLOWED_NETLOCS = {
|
ALLOWED_NETLOCS = {
|
||||||
@@ -83,6 +81,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. '
|
||||||
@@ -90,18 +89,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,
|
)
|
||||||
}
|
|
||||||
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 []
|
||||||
@@ -158,3 +155,10 @@ class YoutubeLoader:
|
|||||||
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(self.video_id, self.language, list(transcript_list))
|
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -5,12 +5,10 @@ import numpy as np
|
|||||||
from colbert.infra import ColBERTConfig
|
from colbert.infra import ColBERTConfig
|
||||||
from colbert.modeling.checkpoint import Checkpoint
|
from colbert.modeling.checkpoint import Checkpoint
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
|
||||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class ColBERT(BaseReranker):
|
class ColBERT(BaseReranker):
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ from typing import Optional, List, Tuple
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
|
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
|
||||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||||
|
from open_webui.utils.headers import include_user_info_headers
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalReranker(BaseReranker):
|
class ExternalReranker(BaseReranker):
|
||||||
@@ -18,10 +17,12 @@ class ExternalReranker(BaseReranker):
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
url: str = "http://localhost:8080/v1/rerank",
|
url: str = "http://localhost:8080/v1/rerank",
|
||||||
model: str = "reranker",
|
model: str = "reranker",
|
||||||
|
timeout: Optional[int] = None,
|
||||||
):
|
):
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.url = url
|
self.url = url
|
||||||
self.model = model
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
def predict(
|
def predict(
|
||||||
self, sentences: List[Tuple[str, str]], user=None
|
self, sentences: List[Tuple[str, str]], user=None
|
||||||
@@ -40,23 +41,20 @@ class ExternalReranker(BaseReranker):
|
|||||||
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}",
|
|
||||||
**(
|
|
||||||
{
|
|
||||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
|
||||||
}
|
|
||||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
},
|
|
||||||
json=payload,
|
json=payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=REQUESTS_VERIFY,
|
||||||
)
|
)
|
||||||
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
@@ -22,10 +24,8 @@ from open_webui.config import (
|
|||||||
CHROMA_CLIENT_AUTH_PROVIDER,
|
CHROMA_CLIENT_AUTH_PROVIDER,
|
||||||
CHROMA_CLIENT_AUTH_CREDENTIALS,
|
CHROMA_CLIENT_AUTH_CREDENTIALS,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class ChromaClient(VectorDBBase):
|
class ChromaClient(VectorDBBase):
|
||||||
@@ -69,7 +69,11 @@ class ChromaClient(VectorDBBase):
|
|||||||
return self.client.delete_collection(name=collection_name)
|
return self.client.delete_collection(name=collection_name)
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, collection_name: str, vectors: list[list[float | int]], limit: int
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
vectors: list[list[float | int]],
|
||||||
|
filter: Optional[dict] = None,
|
||||||
|
limit: int = 10,
|
||||||
) -> Optional[SearchResult]:
|
) -> Optional[SearchResult]:
|
||||||
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
||||||
try:
|
try:
|
||||||
@@ -78,6 +82,7 @@ class ChromaClient(VectorDBBase):
|
|||||||
result = collection.query(
|
result = collection.query(
|
||||||
query_embeddings=vectors,
|
query_embeddings=vectors,
|
||||||
n_results=limit,
|
n_results=limit,
|
||||||
|
where=filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
|
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
|
||||||
@@ -144,7 +149,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 +169,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,
|
||||||
@@ -151,7 +153,11 @@ class ElasticsearchClient(VectorDBBase):
|
|||||||
|
|
||||||
# Status: works
|
# Status: works
|
||||||
def search(
|
def search(
|
||||||
self, collection_name: str, vectors: list[list[float]], limit: int
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
vectors: list[list[float]],
|
||||||
|
filter: Optional[dict] = None,
|
||||||
|
limit: int = 10,
|
||||||
) -> Optional[SearchResult]:
|
) -> Optional[SearchResult]:
|
||||||
query = {
|
query = {
|
||||||
"size": limit,
|
"size": limit,
|
||||||
@@ -243,7 +249,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 +270,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,11 +22,11 @@ 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
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
|
||||||
|
|
||||||
|
|
||||||
class MilvusClient(VectorDBBase):
|
class MilvusClient(VectorDBBase):
|
||||||
@@ -127,12 +131,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.
|
||||||
@@ -169,7 +179,11 @@ class MilvusClient(VectorDBBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, collection_name: str, vectors: list[list[float | int]], limit: int
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
vectors: list[list[float | int]],
|
||||||
|
filter: Optional[dict] = None,
|
||||||
|
limit: int = 10,
|
||||||
) -> Optional[SearchResult]:
|
) -> Optional[SearchResult]:
|
||||||
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
||||||
collection_name = collection_name.replace("-", "_")
|
collection_name = collection_name.replace("-", "_")
|
||||||
@@ -185,86 +199,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 +261,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 +293,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 +329,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
|
||||||
],
|
],
|
||||||
|
|||||||
Loaded 100 of 745 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user