Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
b0720ec2aa | ||
|
|
f704d5b0b3 | ||
|
|
4ca43004ed | ||
|
|
7c0bf59c7c | ||
|
|
b50ae5bf69 | ||
|
|
05223f720d | ||
|
|
ceaafbbfd2 | ||
|
|
7b0ce47eb8 | ||
|
|
a64586f9c2 | ||
|
|
e214d59d10 | ||
|
|
8df74dde80 | ||
|
|
c6d80496ab |
@@ -13,6 +13,8 @@ body:
|
||||
|
||||
- **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.**
|
||||
|
||||
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed!**
|
||||
|
||||
- **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.
|
||||
|
||||
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
|
||||
|
||||
@@ -8,8 +8,9 @@ body:
|
||||
value: |
|
||||
## Important Notes
|
||||
### 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.
|
||||
If your feature request might impact others in the community, consider opening a discussion instead and evaluate whether and how to implement it.
|
||||
This will help us efficiently focus on improving the project.
|
||||
|
||||
### Collaborate respectfully
|
||||
@@ -35,7 +36,7 @@ body:
|
||||
label: Check Existing Issues
|
||||
description: Please confirm that you've checked for existing similar requests
|
||||
options:
|
||||
- label: I have searched the existing issues and discussions.
|
||||
- label: I have searched all existing open AND closed issues and discussions for similar requests. I have found none that is comparable to my request.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. Not targeting the `dev` branch may lead to immediate closure of the PR.
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
|
||||
- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
|
||||
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
|
||||
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
|
||||
- [ ] **Testing:** Perform manual tests to verify the implemented fix/feature works as intended AND does not break any other functionality. Take this as an opportunity to make screenshots of the feature/fix and include it in the PR description.
|
||||
- [ ] **Agentic AI Code:**: Confirm this Pull Request is **not written by any AI Agent** or has at least gone through additional human review **and** manual testing. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
- **ci**: Changes to our continuous integration processes or workflows
|
||||
|
||||
+197
@@ -5,6 +5,203 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.34] - 2025-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- 📄 MinerU is now supported as a document parser backend, with support for both local and managed API deployments. [#18306](https://github.com/open-webui/open-webui/pull/18306)
|
||||
- 🔒 JWT token expiration default is now set to 4 weeks instead of never expiring, with security warnings displayed in backend logs and admin UI when set to unlimited. [#18261](https://github.com/open-webui/open-webui/pull/18261), [#18262](https://github.com/open-webui/open-webui/pull/18262)
|
||||
- ⚡ Page loading performance is improved by preventing unnecessary API requests when sidebar folders are not expanded. [#18179](https://github.com/open-webui/open-webui/pull/18179), [#17476](https://github.com/open-webui/open-webui/issues/17476)
|
||||
- 📁 File hash values are now included in the knowledge endpoint response, enabling efficient file synchronization through hash comparison. [#18284](https://github.com/open-webui/open-webui/pull/18284), [#18283](https://github.com/open-webui/open-webui/issues/18283)
|
||||
- 🎨 Chat dialog scrollbar visibility is improved by increasing its width, making it easier to use for navigation. [#18369](https://github.com/open-webui/open-webui/pull/18369), [#11782](https://github.com/open-webui/open-webui/issues/11782)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Catalan, Chinese, Czech, Finnish, German, Kabyle, Korean, Portuguese (Brazil), Spanish, Thai, and Turkish were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📚 Focused retrieval mode now works correctly, preventing the system from forcing full context mode and loading all documents in a knowledge base regardless of settings. [#18133](https://github.com/open-webui/open-webui/issues/18133)
|
||||
- 🔧 Filter inlet functions now correctly execute on tool call continuations, ensuring parameter persistence throughout tool interactions. [#18222](https://github.com/open-webui/open-webui/issues/18222)
|
||||
- 🛠️ External tool servers now properly support DELETE requests with body data. [#18289](https://github.com/open-webui/open-webui/pull/18289), [#18287](https://github.com/open-webui/open-webui/issues/18287)
|
||||
- 🗄️ Oracle23ai vector database client now correctly handles variable initialization, resolving UnboundLocalError when retrieving items from collections. [#18356](https://github.com/open-webui/open-webui/issues/18356)
|
||||
- 🔧 Model auto-pull functionality now works correctly even when user settings remain unmodified. [#18324](https://github.com/open-webui/open-webui/pull/18324)
|
||||
- 🎨 Duplicate HTML content in artifacts is now prevented by improving code block detection logic. [#18195](https://github.com/open-webui/open-webui/pull/18195), [#6154](https://github.com/open-webui/open-webui/issues/6154)
|
||||
- 💬 Pinned chats now appear in the Reference Chats list and can be referenced in conversations. [#18288](https://github.com/open-webui/open-webui/issues/18288)
|
||||
- 📝 Misleading knowledge base warning text in documents settings is clarified to correctly instruct users about reindexing vectors. [#18263](https://github.com/open-webui/open-webui/pull/18263)
|
||||
- 🔔 Toast notifications can now be dismissed even when a modal is open. [#18260](https://github.com/open-webui/open-webui/pull/18260)
|
||||
- 🔘 The "Chats" button in the sidebar now correctly toggles chat list visibility without navigating away from the current page. [#18232](https://github.com/open-webui/open-webui/pull/18232)
|
||||
- 🎯 The Integrations menu no longer closes prematurely when clicking outside the Valves modal. [#18310](https://github.com/open-webui/open-webui/pull/18310)
|
||||
- 🛠️ Tool ID display issues where "undefined" was incorrectly shown in the interface are now resolved. [#18178](https://github.com/open-webui/open-webui/pull/18178)
|
||||
- 🛠️ Model management issues caused by excessively long model IDs are now prevented through validation that limits model IDs to 256 characters. [#18125](https://github.com/open-webui/open-webui/issues/18125)
|
||||
|
||||
## [0.6.33] - 2025-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- 🎨 Workspace interface received a comprehensive redesign across Models, Knowledge, Prompts, and Tools sections, featuring reorganized controls, view filters for created vs shared items, tag selectors, improved visual hierarchy, and streamlined import/export functionality. [Commit](https://github.com/open-webui/open-webui/commit/2c59a288603d8c5f004f223ee00fef37cc763a8e), [Commit](https://github.com/open-webui/open-webui/commit/6050c86ab6ef6b8c96dd3f99c62a6867011b67a4), [Commit](https://github.com/open-webui/open-webui/commit/96ecb47bc71c072aa34ef2be10781b042bef4e8c), [Commit](https://github.com/open-webui/open-webui/commit/2250d102b28075a9611696e911536547abb8b38a), [Commit](https://github.com/open-webui/open-webui/commit/23c8f6d507bfee75ab0015a3e2972d5c26f7e9bf), [Commit](https://github.com/open-webui/open-webui/commit/a743b16728c6ae24b8befbc2d7f24eb9e20c4ad5)
|
||||
- 🛠️ Functions admin interface received a comprehensive redesign with creator attribution display, ownership filters for created vs shared items, improved organization, and refined styling. [Commit](https://github.com/open-webui/open-webui/commit/f5e1a42f51acc0b9d5b63a33c1ca2e42470239c1)
|
||||
- ⚡ Page initialization performance is significantly improved through parallel data loading and optimized folder API calls, reducing initial page load time. [#17559](https://github.com/open-webui/open-webui/pull/17559), [#17889](https://github.com/open-webui/open-webui/pull/17889)
|
||||
- ⚡ Chat overview component is now dynamically loaded on demand, reducing initial page bundle size by approximately 470KB and improving first-screen loading speed. [#17595](https://github.com/open-webui/open-webui/pull/17595)
|
||||
- 📁 Folders can now be attached to chats using the "#" command, automatically expanding to include all files within the folder for streamlined knowledge base integration. [Commit](https://github.com/open-webui/open-webui/commit/d2cb78179d66dc85188172a08622d4c97a2ea1ee)
|
||||
- 📱 Progressive Web App now supports Android share target functionality, allowing users to share web pages, YouTube videos, and text directly to Open WebUI from the system share menu. [#17633](https://github.com/open-webui/open-webui/pull/17633), [#17125](https://github.com/open-webui/open-webui/issues/17125)
|
||||
- 🗄️ Redis session storage is now available as an experimental option for OAuth authentication flows via the ENABLE_STAR_SESSIONS_MIDDLEWARE environment variable, providing shared session state across multi-replica deployments to address CSRF errors, though currently only basic Redis setups are supported. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373), [Docs:Commit](https://github.com/open-webui/docs/commit/14052347f165d1b597615370373d7289ce44c7f9)
|
||||
- 📊 Vega and Vega-Lite chart visualization renderers are now supported in code blocks, enabling inline rendering of data visualizations with automatic compilation of Vega-Lite specifications. [#18033](https://github.com/open-webui/open-webui/pull/18033), [#18040](https://github.com/open-webui/open-webui/pull/18040), [#18022](https://github.com/open-webui/open-webui/issues/18022)
|
||||
- 🔗 OpenAI connections now support custom HTTP headers, enabling users to configure authentication and routing headers for specific deployment requirements. [#18021](https://github.com/open-webui/open-webui/pull/18021), [#9732](https://github.com/open-webui/open-webui/discussions/9732)
|
||||
- 🔐 OpenID Connect authentication now supports OIDC providers without email scope via the ENABLE_OAUTH_WITHOUT_EMAIL environment variable, enabling compatibility with identity providers that don't expose email addresses. [#18047](https://github.com/open-webui/open-webui/pull/18047), [#18045](https://github.com/open-webui/open-webui/issues/18045)
|
||||
- 🤖 Ollama model management modal now features individual model update cancellation, comprehensive tooltips for all buttons, and streamlined notification behavior to reduce toast spam. [#16863](https://github.com/open-webui/open-webui/pull/16863)
|
||||
- ☁️ OneDrive file picker now includes search functionality and "My Organization" pivot for business accounts, enabling easier file discovery across organizational content. [#17930](https://github.com/open-webui/open-webui/pull/17930), [#17929](https://github.com/open-webui/open-webui/issues/17929)
|
||||
- 📊 Chat overview flow diagram now supports toggling between vertical and horizontal layout orientations for improved visualization flexibility. [#17941](https://github.com/open-webui/open-webui/pull/17941)
|
||||
- 🔊 OpenAI Text-to-Speech engine now supports additional parameters, allowing users to customize TTS behavior with provider-specific options via JSON configuration. [#17985](https://github.com/open-webui/open-webui/issues/17985), [#17188](https://github.com/open-webui/open-webui/pull/17188)
|
||||
- 🛠️ Tool server list now displays server name, URL, and type (OpenAPI or MCP) for easier identification and management. [#18062](https://github.com/open-webui/open-webui/issues/18062)
|
||||
- 📁 Folders now remember the last selected model, automatically applying it when starting new chats within that folder. [#17836](https://github.com/open-webui/open-webui/issues/17836)
|
||||
- 🔢 Ollama embedding endpoint now supports the optional dimensions parameter for controlling embedding output size, compatible with Ollama v0.11.11 and later. [#17942](https://github.com/open-webui/open-webui/pull/17942)
|
||||
- ⚡ Workspace knowledge page load time is improved by removing redundant API calls, enhancing overall responsiveness. [#18057](https://github.com/open-webui/open-webui/pull/18057)
|
||||
- ⚡ File metadata query performance is enhanced by selecting only relevant columns instead of retrieving entire records, reducing database overhead. [#18013](https://github.com/open-webui/open-webui/pull/18013)
|
||||
- 📄 Note PDF exports now include titles and properly render in dark mode with appropriate background colors. [Commit](https://github.com/open-webui/open-webui/commit/216fb5c3db1a223ffe6e72d97aa9551fe0e2d028)
|
||||
- 📄 Docling document extraction now supports additional parameters for VLM pipeline configuration, enabling customized vision model settings. [#17363](https://github.com/open-webui/open-webui/pull/17363)
|
||||
- ⚙️ Server startup script now supports passing arbitrary arguments to uvicorn, enabling custom server configuration options. [#17919](https://github.com/open-webui/open-webui/pull/17919), [#17918](https://github.com/open-webui/open-webui/issues/17918)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for German, Danish, Spanish, Korean, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💬 System prompts are no longer duplicated in chat requests, eliminating confusion and excessive token usage caused by repeated instructions being sent to models. [#17198](https://github.com/open-webui/open-webui/issues/17198), [#16855](https://github.com/open-webui/open-webui/issues/16855)
|
||||
- 🔐 MCP OAuth 2.1 authentication now complies with the standard by implementing PKCE with S256 code challenge method and explicitly passing client credentials during token authorization, resolving "code_challenge: Field required" and "client_id: Field required" errors when connecting to OAuth-secured MCP servers. [Commit](https://github.com/open-webui/open-webui/commit/911a114ad459f5deebd97543c13c2b90196efb54), [#18010](https://github.com/open-webui/open-webui/issues/18010), [#18087](https://github.com/open-webui/open-webui/pull/18087)
|
||||
- 🔐 OAuth signup flow now handles password hashing correctly by migrating from passlib to native bcrypt, preventing failures when passwords exceed 72 bytes. [#17917](https://github.com/open-webui/open-webui/issues/17917)
|
||||
- 🔐 OAuth token refresh errors are resolved by properly registering and storing OAuth clients, fixing "Constructor parameter should be str" exceptions for Google, Microsoft, and OIDC providers. [#17829](https://github.com/open-webui/open-webui/issues/17829)
|
||||
- 🔐 OAuth server metadata URL is now correctly accessed via the proper attribute, fixing automatic token refresh and logout functionality for Microsoft OAuth provider when OPENID_PROVIDER_URL is not set. [#18065](https://github.com/open-webui/open-webui/pull/18065)
|
||||
- 🔐 OAuth credential decryption failures now allow the application to start gracefully with clear error messages instead of crashing, preventing complete service outages when WEBUI_SECRET_KEY mismatches occur during database migrations or environment changes. [#18094](https://github.com/open-webui/open-webui/pull/18094), [#18092](https://github.com/open-webui/open-webui/issues/18092)
|
||||
- 🔐 OAuth 2.1 server discovery now correctly attempts all configured discovery URLs in sequence instead of only trying the first URL. [#17906](https://github.com/open-webui/open-webui/pull/17906), [#17904](https://github.com/open-webui/open-webui/issues/17904), [#18026](https://github.com/open-webui/open-webui/pull/18026)
|
||||
- 🔐 Login redirect now correctly honors the redirect query parameter after authentication, ensuring users are returned to their intended destination with query parameters intact instead of defaulting to the homepage. [#18071](https://github.com/open-webui/open-webui/issues/18071)
|
||||
- ☁️ OneDrive Business integration authentication regression is resolved, ensuring the popup now properly triggers when connecting to OneDrive accounts. [#17902](https://github.com/open-webui/open-webui/pull/17902), [#17825](https://github.com/open-webui/open-webui/discussions/17825), [#17816](https://github.com/open-webui/open-webui/issues/17816)
|
||||
- 👥 Default group settings now persist correctly after page navigation, ensuring configuration changes are properly saved and retained. [#17899](https://github.com/open-webui/open-webui/issues/17899), [#18003](https://github.com/open-webui/open-webui/issues/18003)
|
||||
- 📁 Folder data integrity is now verified on retrieval, automatically fixing orphaned folders with invalid parent references and ensuring proper cascading deletion of nested folder structures. [Commit](https://github.com/open-webui/open-webui/commit/5448618dd5ea181b9635b77040cef60926a902ff)
|
||||
- 🗄️ Redis Sentinel and Redis Cluster configurations with the experimental ENABLE_STAR_SESSIONS_MIDDLEWARE feature are now properly isolated by making the feature opt-in only, preventing ReadOnlyError failures when connecting to read replicas in multi-node Redis deployments. [#18073](https://github.com/open-webui/open-webui/issues/18073)
|
||||
- 📊 Mermaid and Vega diagram rendering now displays error toast notifications when syntax errors are detected, helping users identify and fix diagram issues instead of silently failing. [#18068](https://github.com/open-webui/open-webui/pull/18068)
|
||||
- 🤖 Reasoning models that return reasoning_content instead of content no longer cause NoneType errors during chat title generation, follow-up suggestions, and tag generation. [#18080](https://github.com/open-webui/open-webui/pull/18080)
|
||||
- 📚 Citation rendering now correctly handles multiple source references in a single bracket, parsing formats like [1,2] and [1, 2] into separate clickable citation links. [#18120](https://github.com/open-webui/open-webui/pull/18120)
|
||||
- 🔍 Web search now handles individual source failures gracefully, continuing to process remaining sources instead of failing entirely when a single URL is unreachable or returns an error. [Commit](https://github.com/open-webui/open-webui/commit/e000494e488090c5f66989a2b3f89d3eaeb7946b), [Commit](https://github.com/open-webui/open-webui/commit/53e98620bff38ab9280aee5165af0a704bdd99b9)
|
||||
- 🔍 Hybrid search with reranking now handles empty result sets gracefully instead of crashing with ValueError when all results are filtered out due to relevance thresholds. [#18096](https://github.com/open-webui/open-webui/issues/18096)
|
||||
- 🔍 Reranking models without defined padding tokens now work correctly by automatically falling back to eos_token_id as pad_token_id, fixing "Cannot handle batch sizes > 1" errors for models like Qwen3-Reranker. [#18108](https://github.com/open-webui/open-webui/pull/18108), [#16027](https://github.com/open-webui/open-webui/discussions/16027)
|
||||
- 🔍 Model selector search now correctly returns results for non-admin users by dynamically updating the search index when the model list changes, fixing a race condition that caused empty search results. [#17996](https://github.com/open-webui/open-webui/pull/17996), [#17960](https://github.com/open-webui/open-webui/pull/17960)
|
||||
- ⚡ Task model function calling performance is improved by excluding base64 image data from payloads, significantly reducing token count and memory usage when images are present in conversations. [#17897](https://github.com/open-webui/open-webui/pull/17897)
|
||||
- 🤖 Text selection "Ask" action now correctly recognizes and uses local models configured via direct connections instead of only showing external provider models. [#17896](https://github.com/open-webui/open-webui/issues/17896)
|
||||
- 🛑 Task cancellation API now returns accurate response status, correctly reporting successful cancellations instead of incorrectly indicating failures. [#17920](https://github.com/open-webui/open-webui/issues/17920)
|
||||
- 💬 Follow-up query suggestions are now generated and displayed in temporary chats, matching the behavior of saved chats. [#14987](https://github.com/open-webui/open-webui/issues/14987)
|
||||
- 🔊 Azure Text-to-Speech now properly escapes special characters like ampersands in SSML, preventing HTTP 400 errors and ensuring audio generation succeeds for all text content. [#17962](https://github.com/open-webui/open-webui/issues/17962)
|
||||
- 🛠️ OpenAPI tool server calls with optional parameters now execute successfully even when no arguments are provided, removing the incorrect requirement for a request body. [#18036](https://github.com/open-webui/open-webui/issues/18036)
|
||||
- 🛠️ MCP mode tool server connections no longer incorrectly validate the OpenAPI path field, allowing seamless switching between OpenAPI and MCP connection types. [#17989](https://github.com/open-webui/open-webui/pull/17989), [#17988](https://github.com/open-webui/open-webui/issues/17988)
|
||||
- 🛠️ Third-party tool responses containing non-UTF8 or invalid byte sequences are now handled gracefully without causing request failures. [#17882](https://github.com/open-webui/open-webui/pull/17882)
|
||||
- 🎨 Workspace filter dropdown now correctly renders model tags as strings instead of displaying individual characters, fixing broken filtering interface when models have multiple tags. [#18034](https://github.com/open-webui/open-webui/issues/18034)
|
||||
- ⌨️ Ctrl+Enter keyboard shortcut now correctly sends messages in mobile and narrow browser views on Chrome instead of inserting newlines. [#17975](https://github.com/open-webui/open-webui/issues/17975)
|
||||
- ⌨️ Tab characters are now preserved when pasting code or formatted text into the chat input box in plain text mode. [#17958](https://github.com/open-webui/open-webui/issues/17958)
|
||||
- 📋 Text selection copying from the chat input box now correctly copies only the selected text instead of the entire textbox content. [#17911](https://github.com/open-webui/open-webui/issues/17911)
|
||||
- 🔍 Web search query logging now uses debug level instead of info level, preventing user search queries from appearing in production logs. [#17888](https://github.com/open-webui/open-webui/pull/17888)
|
||||
- 📝 Debug print statements in middleware were removed to prevent excessive log pollution and respect configured logging levels. [#17943](https://github.com/open-webui/open-webui/issues/17943)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗄️ Milvus vector database dependency is updated from pymilvus 2.5.0 to 2.6.2, ensuring compatibility with newer Milvus versions but requiring users on older Milvus instances to either upgrade their database or manually downgrade the pymilvus package. [#18066](https://github.com/open-webui/open-webui/pull/18066)
|
||||
|
||||
## [0.6.32] - 2025-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡ JSON model import moved to backend processing for significant performance improvements when importing large model files. [#17871](https://github.com/open-webui/open-webui/pull/17871)
|
||||
- ⚠️ Visual warnings for group permissions that display when a permission is disabled in a group but remains enabled in the default user role, clarifying inheritance behavior for administrators. [#17848](https://github.com/open-webui/open-webui/pull/17848)
|
||||
- 🗄️ Milvus multi-tenancy mode using shared collections with resource ID filtering for improved scalability, mirroring the existing Qdrant implementation and configurable via ENABLE_MILVUS_MULTITENANCY_MODE environment variable. [#17837](https://github.com/open-webui/open-webui/pull/17837)
|
||||
- 🛠️ Enhanced tool result processing with improved error handling, better MCP tool result handling, and performance improvements for embedded UI components. [Commit](https://github.com/open-webui/open-webui/commit/4f06f29348b2c9d71c87d1bbe5b748a368f5101f)
|
||||
- 👥 New user groups now automatically inherit default group permissions, streamlining the admin setup process by eliminating manual permission configuration. [#17843](https://github.com/open-webui/open-webui/pull/17843)
|
||||
- 🗂️ Bulk unarchive functionality for all chats, providing a single backend endpoint to efficiently restore all archived chats at once. [#17857](https://github.com/open-webui/open-webui/pull/17857)
|
||||
- 🏷️ Browser tab title toggle setting allows users to control whether chat titles appear in the browser tab or display only "Open WebUI". [#17851](https://github.com/open-webui/open-webui/pull/17851)
|
||||
- 💬 Reply-to-message functionality in channels, allowing users to reply directly to specific messages with visual threading and context display. [Commit](https://github.com/open-webui/open-webui/commit/1a18928c94903ad1f1f0391b8ade042c3e60205b)
|
||||
- 🔧 Tool server import and export functionality, allowing direct upload of openapi.json and openapi.yaml files as an alternative to URL-based configuration. [#14446](https://github.com/open-webui/open-webui/issues/14446)
|
||||
- 🔧 User valve configuration for Functions is now available in the integration menu, providing consistent management alongside Tools. [#17784](https://github.com/open-webui/open-webui/issues/17784)
|
||||
- 🔐 Admin permission toggle for controlling public sharing of notes, configurable via USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING environment variable. [#17801](https://github.com/open-webui/open-webui/pull/17801), [Docs:#715](https://github.com/open-webui/docs/pull/715)
|
||||
- 🗄️ DISKANN index type support for Milvus vector database with configurable maximum degree and search list size parameters. [#17770](https://github.com/open-webui/open-webui/pull/17770), [Docs:Commit](https://github.com/open-webui/docs/commit/cec50ab4d4b659558ca1ccd4b5e6fc024f05fb83)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Chinese (Simplified & Traditional) and Bosnian (Latin) were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ MCP tool calls are now correctly routed to the appropriate server when multiple streamable-http MCP servers are enabled, preventing "Tool not found" errors. [#17817](https://github.com/open-webui/open-webui/issues/17817)
|
||||
- 🛠️ External tool servers (OpenAPI/MCP) now properly process and return tool results to the model, restoring functionality that was broken in v0.6.31. [#17764](https://github.com/open-webui/open-webui/issues/17764)
|
||||
- 🔧 User valve detection now correctly identifies valves in imported tool code, ensuring gear icons appear in the integrations menu for all tools with user valves. [#17765](https://github.com/open-webui/open-webui/issues/17765)
|
||||
- 🔐 MCP OAuth discovery now correctly handles multi-tenant configurations by including subpaths in metadata URL discovery. [#17768](https://github.com/open-webui/open-webui/issues/17768)
|
||||
- 🗄️ Milvus query operations now correctly use -1 instead of None for unlimited queries, preventing TypeError exceptions. [#17769](https://github.com/open-webui/open-webui/pull/17769), [#17088](https://github.com/open-webui/open-webui/issues/17088)
|
||||
- 📁 File upload error messages are now displayed when files are modified during upload, preventing user confusion on Android and Windows devices. [#17777](https://github.com/open-webui/open-webui/pull/17777)
|
||||
- 🎨 MessageInput Integrations button hover effect now displays correctly with proper visual feedback. [#17767](https://github.com/open-webui/open-webui/pull/17767)
|
||||
- 🎯 "Set as default" label positioning is fixed to ensure it remains clickable in all scenarios, including multi-model configurations. [#17779](https://github.com/open-webui/open-webui/pull/17779)
|
||||
- 🎛️ Floating buttons now correctly retrieve message context by using the proper messageId parameter in createMessagesList calls. [#17823](https://github.com/open-webui/open-webui/pull/17823)
|
||||
- 📌 Pinned chats are now properly cleared from the sidebar after archiving all chats, ensuring UI consistency without requiring a page refresh. [#17832](https://github.com/open-webui/open-webui/pull/17832)
|
||||
- 🗑️ Delete confirmation modals now properly truncate long names for Notes, Prompts, Tools, and Functions to prevent modal overflow. [#17812](https://github.com/open-webui/open-webui/pull/17812)
|
||||
- 🌐 Internationalization function calls now use proper Svelte store subscription syntax, preventing "i18n.t is not a function" errors on the model creation page. [#17819](https://github.com/open-webui/open-webui/pull/17819)
|
||||
- 🎨 Playground chat interface button layout is corrected to prevent vertical text rendering for Assistant/User role buttons. [#17819](https://github.com/open-webui/open-webui/pull/17819)
|
||||
- 🏷️ UI text truncation is improved across multiple components including usernames in admin panels, arena model names, model tags, and filter tags to prevent layout overflow issues. [#17805](https://github.com/open-webui/open-webui/pull/17805), [#17803](https://github.com/open-webui/open-webui/pull/17803), [#17791](https://github.com/open-webui/open-webui/pull/17791), [#17796](https://github.com/open-webui/open-webui/pull/17796)
|
||||
|
||||
## [0.6.31] - 2025-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
|
||||
- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
|
||||
- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
|
||||
- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
|
||||
- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
|
||||
- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
|
||||
- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
|
||||
- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
|
||||
- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
|
||||
- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
|
||||
- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
|
||||
- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
|
||||
- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
|
||||
- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
|
||||
- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
|
||||
- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
|
||||
- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
|
||||
- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
|
||||
- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
|
||||
- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
|
||||
- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
|
||||
- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
|
||||
- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
|
||||
- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
|
||||
- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
|
||||
- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
|
||||
- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
|
||||
- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
|
||||
- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
|
||||
- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
|
||||
- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
|
||||
- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
|
||||
- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
|
||||
- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
|
||||
- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
|
||||
- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
|
||||
- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
|
||||
- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
|
||||
|
||||
### Changed
|
||||
|
||||
- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
|
||||
- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
|
||||
|
||||
## [0.6.30] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access.
|
||||
|
||||
## [0.6.29] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Open WebUI Multi-License Notice
|
||||
|
||||
This repository contains code governed by multiple licenses based on the date and origin of contribution:
|
||||
|
||||
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
|
||||
|
||||
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
|
||||
|
||||
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
|
||||
|
||||
For details on which commits are covered by which license, refer to LICENSE_HISTORY.
|
||||
@@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
|
||||
|
||||
## License 📜
|
||||
|
||||
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
|
||||
This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
|
||||
|
||||
## Support 💬
|
||||
|
||||
|
||||
+111
-20
@@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
|
||||
|
||||
|
||||
class AppConfig:
|
||||
_state: dict[str, PersistentConfig]
|
||||
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
|
||||
_redis_key_prefix: str
|
||||
|
||||
_state: dict[str, PersistentConfig]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: Optional[str] = None,
|
||||
@@ -233,9 +234,8 @@ class AppConfig:
|
||||
redis_cluster: Optional[bool] = False,
|
||||
redis_key_prefix: str = "open-webui",
|
||||
):
|
||||
super().__setattr__("_state", {})
|
||||
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||
if redis_url:
|
||||
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||
super().__setattr__(
|
||||
"_redis",
|
||||
get_redis_connection(
|
||||
@@ -246,6 +246,8 @@ class AppConfig:
|
||||
),
|
||||
)
|
||||
|
||||
super().__setattr__("_state", {})
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if isinstance(value, PersistentConfig):
|
||||
self._state[key] = value
|
||||
@@ -305,9 +307,15 @@ API_KEY_ALLOWED_ENDPOINTS = PersistentConfig(
|
||||
|
||||
|
||||
JWT_EXPIRES_IN = PersistentConfig(
|
||||
"JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")
|
||||
"JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "4w")
|
||||
)
|
||||
|
||||
if JWT_EXPIRES_IN.value == "-1":
|
||||
log.warning(
|
||||
"⚠️ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n"
|
||||
" See: https://docs.openwebui.com/getting-started/env-configuration\n"
|
||||
)
|
||||
|
||||
####################################
|
||||
# OAuth config
|
||||
####################################
|
||||
@@ -603,8 +611,8 @@ def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
|
||||
|
||||
def google_oauth_register(client: OAuth):
|
||||
client.register(
|
||||
def google_oauth_register(oauth: OAuth):
|
||||
client = oauth.register(
|
||||
name="google",
|
||||
client_id=GOOGLE_CLIENT_ID.value,
|
||||
client_secret=GOOGLE_CLIENT_SECRET.value,
|
||||
@@ -619,6 +627,7 @@ def load_oauth_providers():
|
||||
},
|
||||
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
||||
)
|
||||
return client
|
||||
|
||||
OAUTH_PROVIDERS["google"] = {
|
||||
"redirect_uri": GOOGLE_REDIRECT_URI.value,
|
||||
@@ -631,8 +640,8 @@ def load_oauth_providers():
|
||||
and MICROSOFT_CLIENT_TENANT_ID.value
|
||||
):
|
||||
|
||||
def microsoft_oauth_register(client: OAuth):
|
||||
client.register(
|
||||
def microsoft_oauth_register(oauth: OAuth):
|
||||
client = oauth.register(
|
||||
name="microsoft",
|
||||
client_id=MICROSOFT_CLIENT_ID.value,
|
||||
client_secret=MICROSOFT_CLIENT_SECRET.value,
|
||||
@@ -647,6 +656,7 @@ def load_oauth_providers():
|
||||
},
|
||||
redirect_uri=MICROSOFT_REDIRECT_URI.value,
|
||||
)
|
||||
return client
|
||||
|
||||
OAUTH_PROVIDERS["microsoft"] = {
|
||||
"redirect_uri": MICROSOFT_REDIRECT_URI.value,
|
||||
@@ -656,8 +666,8 @@ def load_oauth_providers():
|
||||
|
||||
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
|
||||
|
||||
def github_oauth_register(client: OAuth):
|
||||
client.register(
|
||||
def github_oauth_register(oauth: OAuth):
|
||||
client = oauth.register(
|
||||
name="github",
|
||||
client_id=GITHUB_CLIENT_ID.value,
|
||||
client_secret=GITHUB_CLIENT_SECRET.value,
|
||||
@@ -675,6 +685,7 @@ def load_oauth_providers():
|
||||
},
|
||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
|
||||
)
|
||||
return client
|
||||
|
||||
OAUTH_PROVIDERS["github"] = {
|
||||
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
|
||||
@@ -688,7 +699,7 @@ def load_oauth_providers():
|
||||
and OPENID_PROVIDER_URL.value
|
||||
):
|
||||
|
||||
def oidc_oauth_register(client: OAuth):
|
||||
def oidc_oauth_register(oauth: OAuth):
|
||||
client_kwargs = {
|
||||
"scope": OAUTH_SCOPES.value,
|
||||
**(
|
||||
@@ -714,7 +725,7 @@ def load_oauth_providers():
|
||||
% ("S256", OAUTH_CODE_CHALLENGE_METHOD.value)
|
||||
)
|
||||
|
||||
client.register(
|
||||
client = oauth.register(
|
||||
name="oidc",
|
||||
client_id=OAUTH_CLIENT_ID.value,
|
||||
client_secret=OAUTH_CLIENT_SECRET.value,
|
||||
@@ -722,6 +733,7 @@ def load_oauth_providers():
|
||||
client_kwargs=client_kwargs,
|
||||
redirect_uri=OPENID_REDIRECT_URI.value,
|
||||
)
|
||||
return client
|
||||
|
||||
OAUTH_PROVIDERS["oidc"] = {
|
||||
"name": OAUTH_PROVIDER_NAME.value,
|
||||
@@ -731,8 +743,8 @@ def load_oauth_providers():
|
||||
|
||||
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
|
||||
|
||||
def feishu_oauth_register(client: OAuth):
|
||||
client.register(
|
||||
def feishu_oauth_register(oauth: OAuth):
|
||||
client = oauth.register(
|
||||
name="feishu",
|
||||
client_id=FEISHU_CLIENT_ID.value,
|
||||
client_secret=FEISHU_CLIENT_SECRET.value,
|
||||
@@ -750,6 +762,7 @@ def load_oauth_providers():
|
||||
},
|
||||
redirect_uri=FEISHU_REDIRECT_URI.value,
|
||||
)
|
||||
return client
|
||||
|
||||
OAUTH_PROVIDERS["feishu"] = {
|
||||
"register": feishu_oauth_register,
|
||||
@@ -1215,6 +1228,11 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get(
|
||||
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
|
||||
@@ -1352,6 +1370,7 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
|
||||
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
|
||||
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
|
||||
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
"chat": {
|
||||
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
@@ -1997,16 +2016,23 @@ if VECTOR_DB == "chroma":
|
||||
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
|
||||
|
||||
# Milvus
|
||||
|
||||
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
|
||||
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
|
||||
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
|
||||
|
||||
MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW")
|
||||
MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
|
||||
MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16"))
|
||||
MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100"))
|
||||
MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
|
||||
MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
|
||||
MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
|
||||
os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
|
||||
)
|
||||
ENABLE_MILVUS_MULTITENANCY_MODE = (
|
||||
os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "false").lower() == "true"
|
||||
)
|
||||
# Hyphens not allowed, need to use underscores in collection names
|
||||
MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
|
||||
|
||||
# Qdrant
|
||||
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
||||
@@ -2168,6 +2194,8 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
|
||||
"onedrive.enable",
|
||||
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
ENABLE_ONEDRIVE_PERSONAL = (
|
||||
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
|
||||
)
|
||||
@@ -2175,10 +2203,12 @@ ENABLE_ONEDRIVE_BUSINESS = (
|
||||
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
ONEDRIVE_CLIENT_ID = PersistentConfig(
|
||||
"ONEDRIVE_CLIENT_ID",
|
||||
"onedrive.client_id",
|
||||
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
|
||||
ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
|
||||
"ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
|
||||
)
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
|
||||
"ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
|
||||
)
|
||||
|
||||
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
|
||||
@@ -2267,6 +2297,36 @@ DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig(
|
||||
os.environ.get("DATALAB_MARKER_OUTPUT_FORMAT", "markdown"),
|
||||
)
|
||||
|
||||
MINERU_API_MODE = PersistentConfig(
|
||||
"MINERU_API_MODE",
|
||||
"rag.mineru_api_mode",
|
||||
os.environ.get("MINERU_API_MODE", "local"), # "local" or "cloud"
|
||||
)
|
||||
|
||||
MINERU_API_URL = PersistentConfig(
|
||||
"MINERU_API_URL",
|
||||
"rag.mineru_api_url",
|
||||
os.environ.get("MINERU_API_URL", "http://localhost:8000"),
|
||||
)
|
||||
|
||||
MINERU_API_KEY = PersistentConfig(
|
||||
"MINERU_API_KEY",
|
||||
"rag.mineru_api_key",
|
||||
os.environ.get("MINERU_API_KEY", ""),
|
||||
)
|
||||
|
||||
mineru_params = os.getenv("MINERU_PARAMS", "")
|
||||
try:
|
||||
mineru_params = json.loads(mineru_params)
|
||||
except json.JSONDecodeError:
|
||||
mineru_params = {}
|
||||
|
||||
MINERU_PARAMS = PersistentConfig(
|
||||
"MINERU_PARAMS",
|
||||
"rag.mineru_params",
|
||||
mineru_params,
|
||||
)
|
||||
|
||||
EXTERNAL_DOCUMENT_LOADER_URL = PersistentConfig(
|
||||
"EXTERNAL_DOCUMENT_LOADER_URL",
|
||||
"rag.external_document_loader_url",
|
||||
@@ -2291,6 +2351,18 @@ DOCLING_SERVER_URL = PersistentConfig(
|
||||
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
|
||||
)
|
||||
|
||||
docling_params = os.getenv("DOCLING_PARAMS", "")
|
||||
try:
|
||||
docling_params = json.loads(docling_params)
|
||||
except json.JSONDecodeError:
|
||||
docling_params = {}
|
||||
|
||||
DOCLING_PARAMS = PersistentConfig(
|
||||
"DOCLING_PARAMS",
|
||||
"rag.docling_params",
|
||||
docling_params,
|
||||
)
|
||||
|
||||
DOCLING_DO_OCR = PersistentConfig(
|
||||
"DOCLING_DO_OCR",
|
||||
"rag.docling_do_ocr",
|
||||
@@ -2761,6 +2833,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
|
||||
)
|
||||
|
||||
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.ollama_cloud_api_key",
|
||||
os.getenv("OLLAMA_CLOUD_API_KEY", ""),
|
||||
)
|
||||
|
||||
SEARXNG_QUERY_URL = PersistentConfig(
|
||||
"SEARXNG_QUERY_URL",
|
||||
"rag.web.search.searxng_query_url",
|
||||
@@ -3336,6 +3414,19 @@ AUDIO_TTS_OPENAI_API_KEY = PersistentConfig(
|
||||
os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY),
|
||||
)
|
||||
|
||||
audio_tts_openai_params = os.getenv("AUDIO_TTS_OPENAI_PARAMS", "")
|
||||
try:
|
||||
audio_tts_openai_params = json.loads(audio_tts_openai_params)
|
||||
except json.JSONDecodeError:
|
||||
audio_tts_openai_params = {}
|
||||
|
||||
AUDIO_TTS_OPENAI_PARAMS = PersistentConfig(
|
||||
"AUDIO_TTS_OPENAI_PARAMS",
|
||||
"audio.tts.openai.params",
|
||||
audio_tts_openai_params,
|
||||
)
|
||||
|
||||
|
||||
AUDIO_TTS_API_KEY = PersistentConfig(
|
||||
"AUDIO_TTS_API_KEY",
|
||||
"audio.tts.api_key",
|
||||
|
||||
@@ -38,6 +38,7 @@ class ERROR_MESSAGES(str, Enum):
|
||||
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."
|
||||
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 = (
|
||||
"Your session has expired or the token is invalid. Please sign in again."
|
||||
|
||||
@@ -212,6 +212,11 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
|
||||
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
# Experimental feature, may be removed in future
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
||||
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
|
||||
)
|
||||
|
||||
####################################
|
||||
# WEBUI_BUILD_HASH
|
||||
####################################
|
||||
@@ -468,17 +473,22 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
|
||||
####################################
|
||||
# OAUTH Configuration
|
||||
####################################
|
||||
|
||||
ENABLE_OAUTH_EMAIL_FALLBACK = (
|
||||
os.environ.get("ENABLE_OAUTH_EMAIL_FALLBACK", "False").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE = (
|
||||
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
|
||||
####################################
|
||||
# SCIM Configuration
|
||||
####################################
|
||||
@@ -547,16 +557,16 @@ else:
|
||||
|
||||
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
|
||||
)
|
||||
|
||||
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||
else:
|
||||
try:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
|
||||
except Exception:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||
|
||||
|
||||
####################################
|
||||
|
||||
@@ -86,6 +86,10 @@ async def get_function_models(request):
|
||||
try:
|
||||
function_module = get_function_module_by_id(request, pipe.id)
|
||||
|
||||
has_user_valves = False
|
||||
if hasattr(function_module, "UserValves"):
|
||||
has_user_valves = True
|
||||
|
||||
# Check if function is a manifold
|
||||
if hasattr(function_module, "pipes"):
|
||||
sub_pipes = []
|
||||
@@ -124,6 +128,7 @@ async def get_function_models(request):
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
"has_user_valves": has_user_valves,
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -141,6 +146,7 @@ async def get_function_models(request):
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
"has_user_valves": has_user_valves,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -239,7 +245,7 @@ async def generate_function_chat_completion(
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
|
||||
+174
-36
@@ -8,6 +8,7 @@ import shutil
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@@ -50,6 +51,11 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from starsessions import (
|
||||
SessionMiddleware as StarSessionsMiddleware,
|
||||
SessionAutoloadMiddleware,
|
||||
)
|
||||
from starsessions.stores.redis import RedisStore
|
||||
|
||||
from open_webui.utils import logger
|
||||
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
|
||||
@@ -110,9 +116,6 @@ from open_webui.config import (
|
||||
OLLAMA_API_CONFIGS,
|
||||
# OpenAI
|
||||
ENABLE_OPENAI_API,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
OPENAI_API_BASE_URLS,
|
||||
OPENAI_API_KEYS,
|
||||
OPENAI_API_CONFIGS,
|
||||
@@ -172,13 +175,14 @@ from open_webui.config import (
|
||||
AUDIO_STT_AZURE_LOCALES,
|
||||
AUDIO_STT_AZURE_BASE_URL,
|
||||
AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||
AUDIO_TTS_API_KEY,
|
||||
AUDIO_TTS_ENGINE,
|
||||
AUDIO_TTS_MODEL,
|
||||
AUDIO_TTS_VOICE,
|
||||
AUDIO_TTS_OPENAI_API_BASE_URL,
|
||||
AUDIO_TTS_OPENAI_API_KEY,
|
||||
AUDIO_TTS_OPENAI_PARAMS,
|
||||
AUDIO_TTS_API_KEY,
|
||||
AUDIO_TTS_SPLIT_ON,
|
||||
AUDIO_TTS_VOICE,
|
||||
AUDIO_TTS_AZURE_SPEECH_REGION,
|
||||
AUDIO_TTS_AZURE_SPEECH_BASE_URL,
|
||||
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
@@ -239,11 +243,16 @@ from open_webui.config import (
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
MINERU_API_MODE,
|
||||
MINERU_API_URL,
|
||||
MINERU_API_KEY,
|
||||
MINERU_PARAMS,
|
||||
DATALAB_MARKER_USE_LLM,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS,
|
||||
DOCLING_DO_OCR,
|
||||
DOCLING_FORCE_OCR,
|
||||
DOCLING_OCR_ENGINE,
|
||||
@@ -272,6 +281,7 @@ from open_webui.config import (
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
WEB_SEARCH_TRUST_ENV,
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
JINA_API_KEY,
|
||||
SEARCHAPI_API_KEY,
|
||||
SEARCHAPI_ENGINE,
|
||||
@@ -303,14 +313,17 @@ from open_webui.config import (
|
||||
GOOGLE_PSE_ENGINE_ID,
|
||||
GOOGLE_DRIVE_CLIENT_ID,
|
||||
GOOGLE_DRIVE_API_KEY,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL,
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
ENABLE_ONEDRIVE_PERSONAL,
|
||||
ENABLE_ONEDRIVE_BUSINESS,
|
||||
ENABLE_RAG_HYBRID_SEARCH,
|
||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||
ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
UPLOAD_DIR,
|
||||
EXTERNAL_WEB_SEARCH_URL,
|
||||
EXTERNAL_WEB_SEARCH_API_KEY,
|
||||
@@ -441,6 +454,7 @@ from open_webui.env import (
|
||||
ENABLE_OTEL,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE,
|
||||
)
|
||||
|
||||
|
||||
@@ -467,7 +481,12 @@ from open_webui.utils.auth import (
|
||||
get_verified_user,
|
||||
)
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.oauth import OAuthManager
|
||||
from open_webui.utils.oauth import (
|
||||
OAuthManager,
|
||||
OAuthClientManager,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
@@ -597,9 +616,14 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# For Open WebUI OIDC/OAuth2
|
||||
oauth_manager = OAuthManager(app)
|
||||
app.state.oauth_manager = oauth_manager
|
||||
|
||||
# For Integrations
|
||||
oauth_client_manager = OAuthClientManager(app)
|
||||
app.state.oauth_client_manager = oauth_client_manager
|
||||
|
||||
app.state.instance_id = None
|
||||
app.state.config = AppConfig(
|
||||
redis_url=REDIS_URL,
|
||||
@@ -818,6 +842,7 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
|
||||
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
|
||||
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
|
||||
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
|
||||
app.state.config.DOCLING_PARAMS = DOCLING_PARAMS
|
||||
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
|
||||
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
|
||||
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
|
||||
@@ -832,6 +857,10 @@ app.state.config.DOCLING_PICTURE_DESCRIPTION_API = DOCLING_PICTURE_DESCRIPTION_A
|
||||
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
|
||||
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
|
||||
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
|
||||
app.state.config.MINERU_API_MODE = MINERU_API_MODE
|
||||
app.state.config.MINERU_API_URL = MINERU_API_URL
|
||||
app.state.config.MINERU_API_KEY = MINERU_API_KEY
|
||||
app.state.config.MINERU_PARAMS = MINERU_PARAMS
|
||||
|
||||
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
|
||||
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
|
||||
@@ -883,6 +912,8 @@ app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
|
||||
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
|
||||
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
|
||||
|
||||
app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
|
||||
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
|
||||
app.state.config.YACY_USERNAME = YACY_USERNAME
|
||||
@@ -1077,11 +1108,15 @@ app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES
|
||||
app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL
|
||||
app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS
|
||||
|
||||
app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
|
||||
app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
|
||||
app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE
|
||||
|
||||
app.state.config.TTS_MODEL = AUDIO_TTS_MODEL
|
||||
app.state.config.TTS_VOICE = AUDIO_TTS_VOICE
|
||||
|
||||
app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
|
||||
app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
|
||||
app.state.config.TTS_OPENAI_PARAMS = AUDIO_TTS_OPENAI_PARAMS
|
||||
|
||||
app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY
|
||||
app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON
|
||||
|
||||
@@ -1152,12 +1187,32 @@ class RedirectMiddleware(BaseHTTPMiddleware):
|
||||
path = request.url.path
|
||||
query_params = dict(parse_qs(urlparse(str(request.url)).query))
|
||||
|
||||
redirect_params = {}
|
||||
|
||||
# Check for the specific watch path and the presence of 'v' parameter
|
||||
if path.endswith("/watch") and "v" in query_params:
|
||||
# Extract the first 'v' parameter
|
||||
video_id = query_params["v"][0]
|
||||
encoded_video_id = urlencode({"youtube": video_id})
|
||||
redirect_url = f"/?{encoded_video_id}"
|
||||
youtube_video_id = query_params["v"][0]
|
||||
redirect_params["youtube"] = youtube_video_id
|
||||
|
||||
if "shared" in query_params and len(query_params["shared"]) > 0:
|
||||
# PWA share_target support
|
||||
|
||||
text = query_params["shared"][0]
|
||||
if text:
|
||||
urls = re.match(r"https://\S+", text)
|
||||
if urls:
|
||||
from open_webui.retrieval.loaders.youtube import _parse_video_id
|
||||
|
||||
if youtube_video_id := _parse_video_id(urls[0]):
|
||||
redirect_params["youtube"] = youtube_video_id
|
||||
else:
|
||||
redirect_params["load-url"] = urls[0]
|
||||
else:
|
||||
redirect_params["q"] = text
|
||||
|
||||
if redirect_params:
|
||||
redirect_url = f"/?{urlencode(redirect_params)}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
# Proceed with the normal flow of other requests
|
||||
@@ -1456,7 +1511,7 @@ async def chat_completion(
|
||||
}
|
||||
|
||||
if metadata.get("chat_id") and (user and user.role != "admin"):
|
||||
if metadata["chat_id"] != "local":
|
||||
if not metadata["chat_id"].startswith("local:"):
|
||||
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
|
||||
if chat is None:
|
||||
raise HTTPException(
|
||||
@@ -1483,13 +1538,14 @@ async def chat_completion(
|
||||
response = await chat_completion_handler(request, form_data, user)
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
try:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"model": model_id,
|
||||
},
|
||||
)
|
||||
if not metadata["chat_id"].startswith("local:"):
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"model": model_id,
|
||||
},
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
@@ -1510,13 +1566,14 @@ async def chat_completion(
|
||||
if metadata.get("chat_id") and metadata.get("message_id"):
|
||||
# Update the chat message with the error
|
||||
try:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"error": {"content": str(e)},
|
||||
},
|
||||
)
|
||||
if not metadata["chat_id"].startswith("local:"):
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"error": {"content": str(e)},
|
||||
},
|
||||
)
|
||||
|
||||
event_emitter = get_event_emitter(metadata)
|
||||
await event_emitter(
|
||||
@@ -1531,6 +1588,14 @@ async def chat_completion(
|
||||
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if mcp_clients := metadata.get("mcp_clients"):
|
||||
for client in mcp_clients.values():
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
log.debug(f"Error cleaning up: {e}")
|
||||
pass
|
||||
|
||||
if (
|
||||
metadata.get("session_id")
|
||||
@@ -1701,8 +1766,8 @@ async def get_app_config(request: Request):
|
||||
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
|
||||
**(
|
||||
{
|
||||
"enable_onedrive_personal": app.state.config.ENABLE_ONEDRIVE_PERSONAL,
|
||||
"enable_onedrive_business": app.state.config.ENABLE_ONEDRIVE_BUSINESS,
|
||||
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
|
||||
"enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
|
||||
}
|
||||
if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
|
||||
else {}
|
||||
@@ -1744,7 +1809,8 @@ async def get_app_config(request: Request):
|
||||
"api_key": GOOGLE_DRIVE_API_KEY.value,
|
||||
},
|
||||
"onedrive": {
|
||||
"client_id": ONEDRIVE_CLIENT_ID.value,
|
||||
"client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
|
||||
"client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
|
||||
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
|
||||
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
|
||||
},
|
||||
@@ -1864,17 +1930,83 @@ async def get_current_usage(user=Depends(get_verified_user)):
|
||||
# OAuth Login & Callback
|
||||
############################
|
||||
|
||||
# SessionMiddleware is used by authlib for oauth
|
||||
if len(OAUTH_PROVIDERS) > 0:
|
||||
|
||||
# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
|
||||
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
||||
for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if tool_server_connection.get("type", "openapi") == "mcp":
|
||||
server_id = tool_server_connection.get("info", {}).get("id")
|
||||
auth_type = tool_server_connection.get("auth_type", "none")
|
||||
if server_id and auth_type == "oauth_2.1":
|
||||
oauth_client_info = tool_server_connection.get("info", {}).get(
|
||||
"oauth_client_info", ""
|
||||
)
|
||||
|
||||
try:
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
app.state.oauth_client_manager.add_client(
|
||||
f"mcp:{server_id}",
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error adding OAuth client for MCP tool server {server_id}: {e}"
|
||||
)
|
||||
pass
|
||||
|
||||
try:
|
||||
if ENABLE_STAR_SESSIONS_MIDDLEWARE:
|
||||
redis_session_store = RedisStore(
|
||||
url=REDIS_URL,
|
||||
prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
|
||||
)
|
||||
|
||||
app.add_middleware(SessionAutoloadMiddleware)
|
||||
app.add_middleware(
|
||||
StarSessionsMiddleware,
|
||||
store=redis_session_store,
|
||||
cookie_name="owui-session",
|
||||
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
log.info("Using Redis for session")
|
||||
else:
|
||||
raise ValueError("No Redis URL provided")
|
||||
except Exception as e:
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=WEBUI_SECRET_KEY,
|
||||
session_cookie="oui-session",
|
||||
session_cookie="owui-session",
|
||||
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/oauth/clients/{client_id}/authorize")
|
||||
async def oauth_client_authorize(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_authorize(request, client_id=client_id)
|
||||
|
||||
|
||||
@app.get("/oauth/clients/{client_id}/callback")
|
||||
async def oauth_client_callback(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_callback(
|
||||
request,
|
||||
client_id=client_id,
|
||||
user_id=user.id if user else None,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/oauth/{provider}/login")
|
||||
async def oauth_login(provider: str, request: Request):
|
||||
return await oauth_manager.handle_login(request, provider)
|
||||
@@ -1886,8 +2018,9 @@ async def oauth_login(provider: str, request: Request):
|
||||
# - This is considered insecure in general, as OAuth providers do not always verify email addresses
|
||||
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
|
||||
# - Email addresses are considered unique, so we fail registration if the email address is already taken
|
||||
@app.get("/oauth/{provider}/callback")
|
||||
async def oauth_callback(provider: str, request: Request, response: Response):
|
||||
@app.get("/oauth/{provider}/login/callback")
|
||||
@app.get("/oauth/{provider}/callback") # Legacy endpoint
|
||||
async def oauth_login_callback(provider: str, request: Request, response: Response):
|
||||
return await oauth_manager.handle_callback(request, provider, response)
|
||||
|
||||
|
||||
@@ -1917,6 +2050,11 @@ async def get_manifest_json():
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/",
|
||||
"method": "GET",
|
||||
"params": {"text": "shared"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""Add reply_to_id column to message
|
||||
|
||||
Revision ID: a5c220713937
|
||||
Revises: 38d63c18f30f
|
||||
Create Date: 2025-09-27 02:24:18.058455
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a5c220713937"
|
||||
down_revision: Union[str, None] = "38d63c18f30f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add 'reply_to_id' column to the 'message' table for replying to messages
|
||||
op.add_column(
|
||||
"message",
|
||||
sa.Column("reply_to_id", sa.Text(), nullable=True),
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove 'reply_to_id' column from the 'message' table
|
||||
op.drop_column("message", "reply_to_id")
|
||||
|
||||
pass
|
||||
@@ -57,6 +57,10 @@ class ChannelModel(BaseModel):
|
||||
####################
|
||||
|
||||
|
||||
class ChannelResponse(ChannelModel):
|
||||
write_access: bool = False
|
||||
|
||||
|
||||
class ChannelForm(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
@@ -366,6 +366,15 @@ class ChatTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
|
||||
try:
|
||||
with get_db() as db:
|
||||
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def update_chat_share_id_by_id(
|
||||
self, id: str, share_id: Optional[str]
|
||||
) -> Optional[ChatModel]:
|
||||
@@ -492,12 +501,19 @@ class ChatTable:
|
||||
self,
|
||||
user_id: str,
|
||||
include_archived: bool = False,
|
||||
include_folders: bool = False,
|
||||
include_pinned: bool = False,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
|
||||
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
|
||||
if not include_folders:
|
||||
query = query.filter_by(folder_id=None)
|
||||
|
||||
if not include_pinned:
|
||||
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
|
||||
if not include_archived:
|
||||
query = query.filter_by(archived=False)
|
||||
@@ -805,7 +821,7 @@ class ChatTable:
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_chats_by_folder_id_and_user_id(
|
||||
self, folder_id: str, user_id: str
|
||||
self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
|
||||
) -> list[ChatModel]:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
|
||||
@@ -814,6 +830,11 @@ class ChatTable:
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
@@ -943,6 +964,16 @@ class ChatTable:
|
||||
|
||||
return count
|
||||
|
||||
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
|
||||
query = query.filter_by(folder_id=folder_id)
|
||||
count = query.count()
|
||||
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
return count
|
||||
|
||||
def delete_tag_by_id_and_user_id_and_tag_name(
|
||||
self, id: str, user_id: str, tag_name: str
|
||||
) -> bool:
|
||||
|
||||
@@ -82,6 +82,7 @@ class FileModelResponse(BaseModel):
|
||||
|
||||
class FileMetadataResponse(BaseModel):
|
||||
id: str
|
||||
hash: Optional[str] = None
|
||||
meta: dict
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
@@ -130,12 +131,24 @@ class FilesTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
file = db.query(File).filter_by(id=id, user_id=user_id).first()
|
||||
if file:
|
||||
return FileModel.model_validate(file)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
file = db.get(File, id)
|
||||
return FileMetadataResponse(
|
||||
id=file.id,
|
||||
hash=file.hash,
|
||||
meta=file.meta,
|
||||
created_at=file.created_at,
|
||||
updated_at=file.updated_at,
|
||||
@@ -171,11 +184,14 @@ class FilesTable:
|
||||
return [
|
||||
FileMetadataResponse(
|
||||
id=file.id,
|
||||
hash=file.hash,
|
||||
meta=file.meta,
|
||||
created_at=file.created_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))
|
||||
.order_by(File.updated_at.desc())
|
||||
.all()
|
||||
|
||||
@@ -50,6 +50,20 @@ class FolderModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FolderMetadataResponse(BaseModel):
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class FolderNameIdResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
meta: Optional[FolderMetadataResponse] = None
|
||||
parent_id: Optional[str] = None
|
||||
is_expanded: bool = False
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_db
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
|
||||
@@ -76,6 +76,10 @@ class FunctionWithValvesModel(BaseModel):
|
||||
####################
|
||||
|
||||
|
||||
class FunctionUserResponse(FunctionModel):
|
||||
user: Optional[UserModel] = None
|
||||
|
||||
|
||||
class FunctionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
@@ -203,6 +207,28 @@ class FunctionsTable:
|
||||
FunctionModel.model_validate(function) for function in functions
|
||||
]
|
||||
|
||||
def get_function_list(self) -> list[FunctionUserResponse]:
|
||||
with get_db() as db:
|
||||
functions = db.query(Function).order_by(Function.updated_at.desc()).all()
|
||||
user_ids = list(set(func.user_id for func in functions))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
return [
|
||||
FunctionUserResponse.model_validate(
|
||||
{
|
||||
**FunctionModel.model_validate(func).model_dump(),
|
||||
"user": (
|
||||
users_dict.get(func.user_id).model_dump()
|
||||
if func.user_id in users_dict
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
for func in functions
|
||||
]
|
||||
|
||||
def get_functions_by_type(
|
||||
self, type: str, active_only=False
|
||||
) -> list[FunctionModel]:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.tags import TagModel, Tag, Tags
|
||||
from open_webui.models.users import Users, UserNameResponse
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -43,6 +44,7 @@ class Message(Base):
|
||||
user_id = Column(Text)
|
||||
channel_id = Column(Text, nullable=True)
|
||||
|
||||
reply_to_id = Column(Text, nullable=True)
|
||||
parent_id = Column(Text, nullable=True)
|
||||
|
||||
content = Column(Text)
|
||||
@@ -60,6 +62,7 @@ class MessageModel(BaseModel):
|
||||
user_id: str
|
||||
channel_id: Optional[str] = None
|
||||
|
||||
reply_to_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
content: str
|
||||
@@ -77,6 +80,7 @@ class MessageModel(BaseModel):
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str
|
||||
reply_to_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
@@ -88,7 +92,15 @@ class Reactions(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class MessageResponse(MessageModel):
|
||||
class MessageUserResponse(MessageModel):
|
||||
user: Optional[UserNameResponse] = None
|
||||
|
||||
|
||||
class MessageReplyToResponse(MessageUserResponse):
|
||||
reply_to_message: Optional[MessageUserResponse] = None
|
||||
|
||||
|
||||
class MessageResponse(MessageReplyToResponse):
|
||||
latest_reply_at: Optional[int]
|
||||
reply_count: int
|
||||
reactions: list[Reactions]
|
||||
@@ -107,6 +119,7 @@ class MessageTable:
|
||||
"id": id,
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
"reply_to_id": form_data.reply_to_id,
|
||||
"parent_id": form_data.parent_id,
|
||||
"content": form_data.content,
|
||||
"data": form_data.data,
|
||||
@@ -128,19 +141,32 @@ class MessageTable:
|
||||
if not message:
|
||||
return None
|
||||
|
||||
reactions = self.get_reactions_by_message_id(id)
|
||||
replies = self.get_replies_by_message_id(id)
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
**{
|
||||
reactions = self.get_reactions_by_message_id(id)
|
||||
thread_replies = self.get_thread_replies_by_message_id(id)
|
||||
|
||||
user = Users.get_user_by_id(message.user_id)
|
||||
return MessageResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"latest_reply_at": replies[0].created_at if replies else None,
|
||||
"reply_count": len(replies),
|
||||
"user": user.model_dump() if user else None,
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump() if reply_to_message else None
|
||||
),
|
||||
"latest_reply_at": (
|
||||
thread_replies[0].created_at if thread_replies else None
|
||||
),
|
||||
"reply_count": len(thread_replies),
|
||||
"reactions": reactions,
|
||||
}
|
||||
)
|
||||
|
||||
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
|
||||
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
@@ -148,7 +174,27 @@ class MessageTable:
|
||||
.order_by(Message.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
messages = []
|
||||
for message in all_messages:
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
messages.append(
|
||||
MessageReplyToResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump()
|
||||
if reply_to_message
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
|
||||
with get_db() as db:
|
||||
@@ -159,7 +205,7 @@ class MessageTable:
|
||||
|
||||
def get_messages_by_channel_id(
|
||||
self, channel_id: str, skip: int = 0, limit: int = 50
|
||||
) -> list[MessageModel]:
|
||||
) -> list[MessageReplyToResponse]:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
@@ -169,11 +215,31 @@ class MessageTable:
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
messages = []
|
||||
for message in all_messages:
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
messages.append(
|
||||
MessageReplyToResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump()
|
||||
if reply_to_message
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
def get_messages_by_parent_id(
|
||||
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
|
||||
) -> list[MessageModel]:
|
||||
) -> list[MessageReplyToResponse]:
|
||||
with get_db() as db:
|
||||
message = db.get(Message, parent_id)
|
||||
|
||||
@@ -193,7 +259,26 @@ class MessageTable:
|
||||
if len(all_messages) < limit:
|
||||
all_messages.append(message)
|
||||
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
messages = []
|
||||
for message in all_messages:
|
||||
reply_to_message = (
|
||||
self.get_message_by_id(message.reply_to_id)
|
||||
if message.reply_to_id
|
||||
else None
|
||||
)
|
||||
messages.append(
|
||||
MessageReplyToResponse.model_validate(
|
||||
{
|
||||
**MessageModel.model_validate(message).model_dump(),
|
||||
"reply_to_message": (
|
||||
reply_to_message.model_dump()
|
||||
if reply_to_message
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
def update_message_by_id(
|
||||
self, id: str, form_data: MessageForm
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.groups import Groups
|
||||
@@ -110,20 +111,72 @@ class NoteTable:
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
query = db.query(Note).filter(Note.user_id == user_id)
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_permission(
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = "write",
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
notes = self.get_notes(skip=skip, limit=limit)
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
return [
|
||||
note
|
||||
for note in notes
|
||||
if note.user_id == user_id
|
||||
or has_access(user_id, permission, note.access_control, user_group_ids)
|
||||
]
|
||||
with get_db() as db:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
# Order newest-first. We stream to keep memory usage low.
|
||||
query = (
|
||||
db.query(Note)
|
||||
.order_by(Note.updated_at.desc())
|
||||
.execution_options(stream_results=True)
|
||||
.yield_per(256)
|
||||
)
|
||||
|
||||
results: list[NoteModel] = []
|
||||
n_skipped = 0
|
||||
|
||||
for note in query:
|
||||
# Fast-pass #1: owner
|
||||
if note.user_id == user_id:
|
||||
permitted = True
|
||||
# Fast-pass #2: public/open
|
||||
elif note.access_control is None:
|
||||
# Technically this should mean public access for both read and write, but we'll only do read for now
|
||||
# We might want to change this behavior later
|
||||
permitted = permission == "read"
|
||||
else:
|
||||
permitted = has_access(
|
||||
user_id, permission, note.access_control, user_group_ids
|
||||
)
|
||||
|
||||
if not permitted:
|
||||
continue
|
||||
|
||||
# Apply skip AFTER permission filtering so it counts only accessible notes
|
||||
if skip and n_skipped < skip:
|
||||
n_skipped += 1
|
||||
continue
|
||||
|
||||
results.append(NoteModel.model_validate(note))
|
||||
if limit is not None and len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
|
||||
@@ -176,6 +176,26 @@ class OAuthSessionTable:
|
||||
log.error(f"Error getting OAuth session by ID: {e}")
|
||||
return None
|
||||
|
||||
def get_session_by_provider_and_user_id(
|
||||
self, provider: str, user_id: str
|
||||
) -> Optional[OAuthSessionModel]:
|
||||
"""Get OAuth session by provider and user ID"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
session = (
|
||||
db.query(OAuthSession)
|
||||
.filter_by(provider=provider, user_id=user_id)
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth session by provider and user ID: {e}")
|
||||
return None
|
||||
|
||||
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
|
||||
"""Get all OAuth sessions for a user"""
|
||||
try:
|
||||
|
||||
@@ -95,6 +95,8 @@ class ToolResponse(BaseModel):
|
||||
class ToolUserResponse(ToolResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolForm(BaseModel):
|
||||
id: str
|
||||
|
||||
@@ -27,6 +27,7 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade
|
||||
|
||||
from open_webui.retrieval.loaders.mistral import MistralLoader
|
||||
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
|
||||
from open_webui.retrieval.loaders.mineru import MinerULoader
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
@@ -346,11 +347,9 @@ class Loader:
|
||||
self.engine == "document_intelligence"
|
||||
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
|
||||
and (
|
||||
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
|
||||
file_ext in ["pdf", "docx", "ppt", "pptx"]
|
||||
or file_content_type
|
||||
in [
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
@@ -369,6 +368,22 @@ class Loader:
|
||||
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
|
||||
azure_credential=DefaultAzureCredential(),
|
||||
)
|
||||
elif self.engine == "mineru" and file_ext in [
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
]:
|
||||
loader = MinerULoader(
|
||||
file_path=file_path,
|
||||
api_mode=self.kwargs.get("MINERU_API_MODE", "local"),
|
||||
api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"),
|
||||
api_key=self.kwargs.get("MINERU_API_KEY", ""),
|
||||
params=self.kwargs.get("MINERU_PARAMS", {}),
|
||||
)
|
||||
elif (
|
||||
self.engine == "mistral_ocr"
|
||||
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import logging
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import List, Optional
|
||||
from langchain_core.documents import Document
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MinerULoader:
|
||||
"""
|
||||
MinerU document parser loader supporting both Cloud API and Local API modes.
|
||||
|
||||
Cloud API: Uses MinerU managed service with async task-based processing
|
||||
Local API: Uses self-hosted MinerU API with synchronous processing
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: str,
|
||||
api_mode: str = "local",
|
||||
api_url: str = "http://localhost:8000",
|
||||
api_key: str = "",
|
||||
params: dict = None,
|
||||
):
|
||||
self.file_path = file_path
|
||||
self.api_mode = api_mode.lower()
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
|
||||
# Parse params dict with defaults
|
||||
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 = params.get("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 = {
|
||||
"return_md": "true",
|
||||
"formula_enable": str(self.enable_formula).lower(),
|
||||
"table_enable": str(self.enable_table).lower(),
|
||||
}
|
||||
|
||||
# Parse method based on OCR setting
|
||||
if self.enable_ocr:
|
||||
form_data["parse_method"] = "ocr"
|
||||
else:
|
||||
form_data["parse_method"] = "auto"
|
||||
|
||||
# Language configuration (Local API uses lang_list array)
|
||||
if self.language:
|
||||
form_data["lang_list"] = self.language
|
||||
|
||||
# Backend/model version (Local API uses "backend" parameter)
|
||||
if self.model_version == "vlm":
|
||||
form_data["backend"] = "vlm-vllm-engine"
|
||||
else:
|
||||
form_data["backend"] = "pipeline"
|
||||
|
||||
# Page ranges (Local API uses start_page_id and end_page_id)
|
||||
if self.page_ranges:
|
||||
# For simplicity, if page_ranges is specified, log a warning
|
||||
# Full page range parsing would require parsing the string
|
||||
log.warning(
|
||||
f"Page ranges '{self.page_ranges}' specified but Local API uses different format. "
|
||||
"Consider using start_page_id/end_page_id parameters if needed."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
files = {"files": (filename, f, "application/octet-stream")}
|
||||
|
||||
log.info(f"Sending file to MinerU Local API: {filename}")
|
||||
log.debug(f"Local API parameters: {form_data}")
|
||||
|
||||
response = requests.post(
|
||||
f"{self.api_url}/file_parse",
|
||||
data=form_data,
|
||||
files=files,
|
||||
timeout=300, # 5 minute timeout for large documents
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
|
||||
)
|
||||
except requests.Timeout:
|
||||
raise HTTPException(
|
||||
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail="MinerU Local API request timed out",
|
||||
)
|
||||
except requests.HTTPError as e:
|
||||
error_detail = f"MinerU Local API request failed: {e}"
|
||||
if e.response is not None:
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_detail += f" - {error_data}"
|
||||
except:
|
||||
error_detail += f" - {e.response.text}"
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error calling MinerU Local API: {str(e)}",
|
||||
)
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Invalid JSON response from MinerU Local API: {e}",
|
||||
)
|
||||
|
||||
# Extract markdown content from response
|
||||
if "results" not in result:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail="MinerU Local API response missing 'results' field",
|
||||
)
|
||||
|
||||
results = result["results"]
|
||||
if not results:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="MinerU returned empty results",
|
||||
)
|
||||
|
||||
# Get the first (and typically only) result
|
||||
file_result = list(results.values())[0]
|
||||
markdown_content = file_result.get("md_content", "")
|
||||
|
||||
if not markdown_content:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="MinerU returned empty markdown content",
|
||||
)
|
||||
|
||||
log.info(f"Successfully parsed document with MinerU Local API: {filename}")
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
"source": filename,
|
||||
"api_mode": "local",
|
||||
"backend": result.get("backend", "unknown"),
|
||||
"version": result.get("version", "unknown"),
|
||||
}
|
||||
|
||||
return [Document(page_content=markdown_content, metadata=metadata)]
|
||||
|
||||
def _load_cloud_api(self) -> List[Document]:
|
||||
"""
|
||||
Load document using Cloud API (asynchronous).
|
||||
Uses batch upload endpoint to avoid need for public file URLs.
|
||||
"""
|
||||
log.info(f"Using MinerU Cloud API at {self.api_url}")
|
||||
|
||||
filename = os.path.basename(self.file_path)
|
||||
|
||||
# Step 1: Request presigned upload URL
|
||||
batch_id, upload_url = self._request_upload_url(filename)
|
||||
|
||||
# Step 2: Upload file to presigned URL
|
||||
self._upload_to_presigned_url(upload_url)
|
||||
|
||||
# Step 3: Poll for results
|
||||
result = self._poll_batch_status(batch_id, filename)
|
||||
|
||||
# Step 4: Download and extract markdown from ZIP
|
||||
markdown_content = self._download_and_extract_zip(
|
||||
result["full_zip_url"], filename
|
||||
)
|
||||
|
||||
log.info(f"Successfully parsed document with MinerU Cloud API: {filename}")
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
"source": filename,
|
||||
"api_mode": "cloud",
|
||||
"batch_id": batch_id,
|
||||
}
|
||||
|
||||
return [Document(page_content=markdown_content, metadata=metadata)]
|
||||
|
||||
def _request_upload_url(self, filename: str) -> tuple:
|
||||
"""
|
||||
Request presigned upload URL from Cloud API.
|
||||
Returns (batch_id, upload_url).
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Build request body
|
||||
request_body = {
|
||||
"enable_formula": self.enable_formula,
|
||||
"enable_table": self.enable_table,
|
||||
"language": self.language,
|
||||
"model_version": self.model_version,
|
||||
"files": [
|
||||
{
|
||||
"name": filename,
|
||||
"is_ocr": self.enable_ocr,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Add page ranges if specified
|
||||
if self.page_ranges:
|
||||
request_body["files"][0]["page_ranges"] = self.page_ranges
|
||||
|
||||
log.info(f"Requesting upload URL for: {filename}")
|
||||
log.debug(f"Cloud API request body: {request_body}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_url}/file-urls/batch",
|
||||
headers=headers,
|
||||
json=request_body,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
error_detail = f"Failed to request upload URL: {e}"
|
||||
if e.response is not None:
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_detail += f" - {error_data.get('msg', error_data)}"
|
||||
except:
|
||||
error_detail += f" - {e.response.text}"
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error requesting upload URL: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Invalid JSON response: {e}",
|
||||
)
|
||||
|
||||
# Check for API error response
|
||||
if result.get("code") != 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}",
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
batch_id = data.get("batch_id")
|
||||
file_urls = data.get("file_urls", [])
|
||||
|
||||
if not batch_id or not file_urls:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail="MinerU Cloud API response missing batch_id or file_urls",
|
||||
)
|
||||
|
||||
upload_url = file_urls[0]
|
||||
log.info(f"Received upload URL for batch: {batch_id}")
|
||||
|
||||
return batch_id, upload_url
|
||||
|
||||
def _upload_to_presigned_url(self, upload_url: str) -> None:
|
||||
"""
|
||||
Upload file to presigned URL (no authentication needed).
|
||||
"""
|
||||
log.info(f"Uploading file to presigned URL")
|
||||
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
response = requests.put(
|
||||
upload_url,
|
||||
data=f,
|
||||
timeout=300, # 5 minute timeout for large files
|
||||
)
|
||||
response.raise_for_status()
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
|
||||
)
|
||||
except requests.Timeout:
|
||||
raise HTTPException(
|
||||
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail="File upload to presigned URL timed out",
|
||||
)
|
||||
except requests.HTTPError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to upload file to presigned URL: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error uploading file: {str(e)}",
|
||||
)
|
||||
|
||||
log.info("File uploaded successfully")
|
||||
|
||||
def _poll_batch_status(self, batch_id: str, filename: str) -> dict:
|
||||
"""
|
||||
Poll batch status until completion.
|
||||
Returns the result dict for the file.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
max_iterations = 300 # 10 minutes max (2 seconds per iteration)
|
||||
poll_interval = 2 # seconds
|
||||
|
||||
log.info(f"Polling batch status: {batch_id}")
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.api_url}/extract-results/batch/{batch_id}",
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
error_detail = f"Failed to poll batch status: {e}"
|
||||
if e.response is not None:
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_detail += f" - {error_data.get('msg', error_data)}"
|
||||
except:
|
||||
error_detail += f" - {e.response.text}"
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error polling batch status: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Invalid JSON response while polling: {e}",
|
||||
)
|
||||
|
||||
# Check for API error response
|
||||
if result.get("code") != 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}",
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
extract_result = data.get("extract_result", [])
|
||||
|
||||
# Find our file in the batch results
|
||||
file_result = None
|
||||
for item in extract_result:
|
||||
if item.get("file_name") == filename:
|
||||
file_result = item
|
||||
break
|
||||
|
||||
if not file_result:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"File {filename} not found in batch results",
|
||||
)
|
||||
|
||||
state = file_result.get("state")
|
||||
|
||||
if state == "done":
|
||||
log.info(f"Processing complete for {filename}")
|
||||
return file_result
|
||||
elif state == "failed":
|
||||
error_msg = file_result.get("err_msg", "Unknown error")
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"MinerU processing failed: {error_msg}",
|
||||
)
|
||||
elif state in ["waiting-file", "pending", "running", "converting"]:
|
||||
# Still processing
|
||||
if iteration % 10 == 0: # Log every 20 seconds
|
||||
log.info(
|
||||
f"Processing status: {state} (iteration {iteration + 1}/{max_iterations})"
|
||||
)
|
||||
time.sleep(poll_interval)
|
||||
else:
|
||||
log.warning(f"Unknown state: {state}")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
# Timeout
|
||||
raise HTTPException(
|
||||
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail="MinerU processing timed out after 10 minutes",
|
||||
)
|
||||
|
||||
def _download_and_extract_zip(self, zip_url: str, filename: str) -> str:
|
||||
"""
|
||||
Download ZIP file from CDN and extract markdown content.
|
||||
Returns the markdown content as a string.
|
||||
"""
|
||||
log.info(f"Downloading results from: {zip_url}")
|
||||
|
||||
try:
|
||||
response = requests.get(zip_url, timeout=60)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to download results ZIP: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error downloading results: {str(e)}",
|
||||
)
|
||||
|
||||
# Save ZIP to temporary file and extract
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
||||
tmp_zip.write(response.content)
|
||||
tmp_zip_path = tmp_zip.name
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Extract ZIP
|
||||
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
|
||||
# Find markdown file - search recursively for any .md file
|
||||
markdown_content = None
|
||||
found_md_path = None
|
||||
|
||||
# First, list all files in the ZIP for debugging
|
||||
all_files = []
|
||||
for root, dirs, files in os.walk(tmp_dir):
|
||||
for file in files:
|
||||
full_path = os.path.join(root, file)
|
||||
all_files.append(full_path)
|
||||
# Look for any .md file
|
||||
if file.endswith(".md"):
|
||||
found_md_path = full_path
|
||||
log.info(f"Found markdown file at: {full_path}")
|
||||
try:
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
markdown_content = f.read()
|
||||
if (
|
||||
markdown_content
|
||||
): # Use the first non-empty markdown file
|
||||
break
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to read {full_path}: {e}")
|
||||
if markdown_content:
|
||||
break
|
||||
|
||||
if markdown_content is None:
|
||||
log.error(f"Available files in ZIP: {all_files}")
|
||||
# Try to provide more helpful error message
|
||||
md_files = [f for f in all_files if f.endswith(".md")]
|
||||
if md_files:
|
||||
error_msg = (
|
||||
f"Found .md files but couldn't read them: {md_files}"
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
f"No .md files found in ZIP. Available files: {all_files}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=error_msg,
|
||||
)
|
||||
|
||||
# Clean up temporary ZIP file
|
||||
os.unlink(tmp_zip_path)
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Invalid ZIP file received: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error extracting ZIP: {str(e)}",
|
||||
)
|
||||
|
||||
if not markdown_content:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="Extracted markdown content is empty",
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Successfully extracted markdown content ({len(markdown_content)} characters)"
|
||||
)
|
||||
return markdown_content
|
||||
@@ -157,3 +157,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."
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import requests
|
||||
import hashlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import time
|
||||
import re
|
||||
|
||||
from urllib.parse import quote
|
||||
from huggingface_hub import snapshot_download
|
||||
@@ -16,6 +17,7 @@ from langchain_core.documents import Document
|
||||
from open_webui.config import VECTOR_DB
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
@@ -27,6 +29,9 @@ from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.retrieval.loaders.youtube import YoutubeLoader
|
||||
|
||||
|
||||
from open_webui.env import (
|
||||
SRC_LOG_LEVELS,
|
||||
@@ -49,6 +54,33 @@ from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
||||
from langchain_core.retrievers import BaseRetriever
|
||||
|
||||
|
||||
def is_youtube_url(url: str) -> bool:
|
||||
youtube_regex = r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$"
|
||||
return re.match(youtube_regex, url) is not None
|
||||
|
||||
|
||||
def get_loader(request, url: str):
|
||||
if is_youtube_url(url):
|
||||
return YoutubeLoader(
|
||||
url,
|
||||
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
)
|
||||
else:
|
||||
return get_web_loader(
|
||||
url,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
)
|
||||
|
||||
|
||||
def get_content_from_url(request, url: str) -> str:
|
||||
loader = get_loader(request, url)
|
||||
docs = loader.load()
|
||||
content = " ".join([doc.page_content for doc in docs])
|
||||
return content, docs
|
||||
|
||||
|
||||
class VectorSearchRetriever(BaseRetriever):
|
||||
collection_name: Any
|
||||
embedding_function: Any
|
||||
@@ -127,7 +159,13 @@ def query_doc_with_hybrid_search(
|
||||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
try:
|
||||
if not collection_result.documents[0]:
|
||||
if (
|
||||
not collection_result
|
||||
or not hasattr(collection_result, "documents")
|
||||
or not collection_result.documents
|
||||
or len(collection_result.documents) == 0
|
||||
or not collection_result.documents[0]
|
||||
):
|
||||
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
|
||||
return {"documents": [], "metadatas": [], "distances": []}
|
||||
|
||||
@@ -182,7 +220,11 @@ def query_doc_with_hybrid_search(
|
||||
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
|
||||
)
|
||||
sorted_items = sorted_items[:k]
|
||||
distances, documents, metadatas = map(list, zip(*sorted_items))
|
||||
|
||||
if sorted_items:
|
||||
distances, documents, metadatas = map(list, zip(*sorted_items))
|
||||
else:
|
||||
distances, documents, metadatas = [], [], []
|
||||
|
||||
result = {
|
||||
"distances": [distances],
|
||||
@@ -226,6 +268,13 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
|
||||
combined = dict() # To store documents with unique document hashes
|
||||
|
||||
for data in query_results:
|
||||
if (
|
||||
len(data.get("distances", [])) == 0
|
||||
or len(data.get("documents", [])) == 0
|
||||
or len(data.get("metadatas", [])) == 0
|
||||
):
|
||||
continue
|
||||
|
||||
distances = data["distances"][0]
|
||||
documents = data["documents"][0]
|
||||
metadatas = data["metadatas"][0]
|
||||
@@ -565,6 +614,13 @@ def get_sources_from_items(
|
||||
"metadatas": [[{"file_id": chat.id, "name": chat.title}]],
|
||||
}
|
||||
|
||||
elif item.get("type") == "url":
|
||||
content, docs = get_content_from_url(request, item.get("url"))
|
||||
if docs:
|
||||
query_result = {
|
||||
"documents": [[content]],
|
||||
"metadatas": [[{"url": item.get("url"), "name": item.get("url")}]],
|
||||
}
|
||||
elif item.get("type") == "file":
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
@@ -730,7 +786,6 @@ def get_sources_from_items(
|
||||
sources.append(source)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from open_webui.retrieval.vector.main import (
|
||||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
|
||||
from open_webui.config import (
|
||||
CHROMA_DATA_PATH,
|
||||
@@ -146,7 +146,7 @@ class ChromaClient(VectorDBBase):
|
||||
ids = [item["id"] for item in items]
|
||||
documents = [item["text"] for item in items]
|
||||
embeddings = [item["vector"] for item in items]
|
||||
metadatas = [stringify_metadata(item["metadata"]) for item in items]
|
||||
metadatas = [process_metadata(item["metadata"]) for item in items]
|
||||
|
||||
for batch in create_batches(
|
||||
api=self.client,
|
||||
@@ -166,7 +166,7 @@ class ChromaClient(VectorDBBase):
|
||||
ids = [item["id"] for item in items]
|
||||
documents = [item["text"] for item in items]
|
||||
embeddings = [item["vector"] for item in items]
|
||||
metadatas = [stringify_metadata(item["metadata"]) for item in items]
|
||||
metadatas = [process_metadata(item["metadata"]) for item in items]
|
||||
|
||||
collection.upsert(
|
||||
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
import ssl
|
||||
from elasticsearch.helpers import bulk, scan
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -245,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
@@ -266,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
|
||||
"collection": collection_name,
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -22,6 +22,8 @@ from open_webui.config import (
|
||||
MILVUS_HNSW_M,
|
||||
MILVUS_HNSW_EFCONSTRUCTION,
|
||||
MILVUS_IVF_FLAT_NLIST,
|
||||
MILVUS_DISKANN_MAX_DEGREE,
|
||||
MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
@@ -131,12 +133,18 @@ class MilvusClient(VectorDBBase):
|
||||
elif index_type == "IVF_FLAT":
|
||||
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
||||
log.info(f"IVF_FLAT params: {index_creation_params}")
|
||||
elif index_type == "DISKANN":
|
||||
index_creation_params = {
|
||||
"max_degree": MILVUS_DISKANN_MAX_DEGREE,
|
||||
"search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
|
||||
}
|
||||
log.info(f"DISKANN params: {index_creation_params}")
|
||||
elif index_type in ["FLAT", "AUTOINDEX"]:
|
||||
log.info(f"Using {index_type} index with no specific build-time params.")
|
||||
else:
|
||||
log.warning(
|
||||
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
|
||||
f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. "
|
||||
f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
|
||||
f"Milvus will use its default for the collection if this type is not directly supported for index creation."
|
||||
)
|
||||
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
|
||||
@@ -189,7 +197,7 @@ class MilvusClient(VectorDBBase):
|
||||
)
|
||||
return self._result_to_search_result(result)
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
||||
def query(self, collection_name: str, filter: dict, limit: int = -1):
|
||||
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
# Construct the filter string for querying
|
||||
@@ -222,7 +230,7 @@ class MilvusClient(VectorDBBase):
|
||||
"data",
|
||||
"metadata",
|
||||
],
|
||||
limit=limit, # Pass the limit directly; None means no limit.
|
||||
limit=limit, # Pass the limit directly; -1 means no limit.
|
||||
)
|
||||
|
||||
while True:
|
||||
@@ -249,7 +257,7 @@ class MilvusClient(VectorDBBase):
|
||||
)
|
||||
# Using query with a trivial filter to get all items.
|
||||
# This will use the paginated query logic.
|
||||
return self.query(collection_name=collection_name, filter={}, limit=None)
|
||||
return self.query(collection_name=collection_name, filter={}, limit=-1)
|
||||
|
||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
||||
# Insert the items into the collection, if the collection does not exist, it will be created.
|
||||
@@ -281,7 +289,7 @@ class MilvusClient(VectorDBBase):
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"data": {"text": item["text"]},
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
@@ -317,7 +325,7 @@ class MilvusClient(VectorDBBase):
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"data": {"text": item["text"]},
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
|
||||
from open_webui.config import (
|
||||
MILVUS_URI,
|
||||
MILVUS_TOKEN,
|
||||
MILVUS_DB,
|
||||
MILVUS_COLLECTION_PREFIX,
|
||||
MILVUS_INDEX_TYPE,
|
||||
MILVUS_METRIC_TYPE,
|
||||
MILVUS_HNSW_M,
|
||||
MILVUS_HNSW_EFCONSTRUCTION,
|
||||
MILVUS_IVF_FLAT_NLIST,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from pymilvus import (
|
||||
connections,
|
||||
utility,
|
||||
Collection,
|
||||
CollectionSchema,
|
||||
FieldSchema,
|
||||
DataType,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
RESOURCE_ID_FIELD = "resource_id"
|
||||
|
||||
|
||||
class MilvusClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
# Milvus collection names can only contain numbers, letters, and underscores.
|
||||
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
|
||||
connections.connect(
|
||||
alias="default",
|
||||
uri=MILVUS_URI,
|
||||
token=MILVUS_TOKEN,
|
||||
db_name=MILVUS_DB,
|
||||
)
|
||||
|
||||
# Main collection types for multi-tenancy
|
||||
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
||||
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
|
||||
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
|
||||
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
|
||||
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
|
||||
self.shared_collections = [
|
||||
self.MEMORY_COLLECTION,
|
||||
self.KNOWLEDGE_COLLECTION,
|
||||
self.FILE_COLLECTION,
|
||||
self.WEB_SEARCH_COLLECTION,
|
||||
self.HASH_BASED_COLLECTION,
|
||||
]
|
||||
|
||||
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Maps the traditional collection name to multi-tenant collection and resource ID.
|
||||
|
||||
WARNING: This mapping relies on current Open WebUI naming conventions for
|
||||
collection names. If Open WebUI changes how it generates collection names
|
||||
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
|
||||
formats), this mapping will break and route data to incorrect collections.
|
||||
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
|
||||
DATA MAPPING INSIDE THE DATABASE.
|
||||
"""
|
||||
resource_id = collection_name
|
||||
|
||||
if collection_name.startswith("user-memory-"):
|
||||
return self.MEMORY_COLLECTION, resource_id
|
||||
elif collection_name.startswith("file-"):
|
||||
return self.FILE_COLLECTION, resource_id
|
||||
elif collection_name.startswith("web-search-"):
|
||||
return self.WEB_SEARCH_COLLECTION, resource_id
|
||||
elif len(collection_name) == 63 and all(
|
||||
c in "0123456789abcdef" for c in collection_name
|
||||
):
|
||||
return self.HASH_BASED_COLLECTION, resource_id
|
||||
else:
|
||||
return self.KNOWLEDGE_COLLECTION, resource_id
|
||||
|
||||
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
|
||||
fields = [
|
||||
FieldSchema(
|
||||
name="id",
|
||||
dtype=DataType.VARCHAR,
|
||||
is_primary=True,
|
||||
auto_id=False,
|
||||
max_length=36,
|
||||
),
|
||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="metadata", dtype=DataType.JSON),
|
||||
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
|
||||
]
|
||||
schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
|
||||
collection = Collection(mt_collection_name, schema)
|
||||
|
||||
index_params = {
|
||||
"metric_type": MILVUS_METRIC_TYPE,
|
||||
"index_type": MILVUS_INDEX_TYPE,
|
||||
"params": {},
|
||||
}
|
||||
if MILVUS_INDEX_TYPE == "HNSW":
|
||||
index_params["params"] = {
|
||||
"M": MILVUS_HNSW_M,
|
||||
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
|
||||
}
|
||||
elif MILVUS_INDEX_TYPE == "IVF_FLAT":
|
||||
index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
|
||||
|
||||
collection.create_index("vector", index_params)
|
||||
collection.create_index(RESOURCE_ID_FIELD)
|
||||
log.info(f"Created shared collection: {mt_collection_name}")
|
||||
return collection
|
||||
|
||||
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||
if not utility.has_collection(mt_collection_name):
|
||||
self._create_shared_collection(mt_collection_name, dimension)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return False
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
|
||||
return len(res) > 0
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||
if not items:
|
||||
return
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
dimension = len(items[0]["vector"])
|
||||
self._ensure_collection(mt_collection, dimension)
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
entities = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": item["metadata"],
|
||||
RESOURCE_ID_FIELD: resource_id,
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
collection.insert(entities)
|
||||
collection.flush()
|
||||
|
||||
def search(
|
||||
self, collection_name: str, vectors: List[List[float]], limit: int
|
||||
) -> Optional[SearchResult]:
|
||||
if not vectors:
|
||||
return None
|
||||
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
|
||||
search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
|
||||
results = collection.search(
|
||||
data=vectors,
|
||||
anns_field="vector",
|
||||
param=search_params,
|
||||
limit=limit,
|
||||
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
output_fields=["id", "text", "metadata"],
|
||||
)
|
||||
|
||||
ids, documents, metadatas, distances = [], [], [], []
|
||||
for hits in results:
|
||||
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
|
||||
for hit in hits:
|
||||
batch_ids.append(hit.entity.get("id"))
|
||||
batch_docs.append(hit.entity.get("text"))
|
||||
batch_metadatas.append(hit.entity.get("metadata"))
|
||||
batch_dists.append(hit.distance)
|
||||
ids.append(batch_ids)
|
||||
documents.append(batch_docs)
|
||||
metadatas.append(batch_metadatas)
|
||||
distances.append(batch_dists)
|
||||
|
||||
return SearchResult(
|
||||
ids=ids, documents=documents, metadatas=metadatas, distances=distances
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
# Build expression
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if ids:
|
||||
# Milvus expects a string list for 'in' operator
|
||||
id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
|
||||
expr.append(f"id in [{id_list_str}]")
|
||||
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
expr.append(f"metadata['{key}'] == '{value}'")
|
||||
|
||||
collection.delete(" and ".join(expr))
|
||||
|
||||
def reset(self):
|
||||
for collection_name in self.shared_collections:
|
||||
if utility.has_collection(collection_name):
|
||||
utility.drop_collection(collection_name)
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
|
||||
|
||||
def query(
|
||||
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||
) -> Optional[GetResult]:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(
|
||||
collection_name
|
||||
)
|
||||
if not utility.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if filter:
|
||||
for key, value in filter.items():
|
||||
if isinstance(value, str):
|
||||
expr.append(f"metadata['{key}'] == '{value}'")
|
||||
else:
|
||||
expr.append(f"metadata['{key}'] == {value}")
|
||||
|
||||
results = collection.query(
|
||||
expr=" and ".join(expr),
|
||||
output_fields=["id", "text", "metadata"],
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
ids = [res["id"] for res in results]
|
||||
documents = [res["text"] for res in results]
|
||||
metadatas = [res["metadata"] for res in results]
|
||||
|
||||
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||
|
||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
return self.query(collection_name, filter={}, limit=None)
|
||||
|
||||
def insert(self, collection_name: str, items: List[VectorItem]):
|
||||
return self.upsert(collection_name, items)
|
||||
@@ -2,7 +2,7 @@ from opensearchpy import OpenSearch
|
||||
from opensearchpy.helpers import bulk
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -201,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
"_source": {
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
}
|
||||
for item in batch
|
||||
@@ -223,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
"doc": {
|
||||
"vector": item["vector"],
|
||||
"text": item["text"],
|
||||
"metadata": stringify_metadata(item["metadata"]),
|
||||
"metadata": process_metadata(item["metadata"]),
|
||||
},
|
||||
"doc_as_upsert": True,
|
||||
}
|
||||
|
||||
@@ -717,7 +717,7 @@ class Oracle23aiClient(VectorDBBase):
|
||||
)
|
||||
|
||||
try:
|
||||
limit = limit or 1000
|
||||
limit = 1000 # Hardcoded limit for get operation
|
||||
|
||||
with self.get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
|
||||
@@ -27,7 +27,7 @@ from sqlalchemy.ext.mutable import MutableDict
|
||||
from sqlalchemy.exc import NoSuchTableError
|
||||
|
||||
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -265,7 +265,7 @@ class PgvectorClient(VectorDBBase):
|
||||
vector=vector,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=stringify_metadata(item["metadata"]),
|
||||
vmetadata=process_metadata(item["metadata"]),
|
||||
)
|
||||
new_items.append(new_chunk)
|
||||
self.session.bulk_save_objects(new_items)
|
||||
@@ -323,7 +323,7 @@ class PgvectorClient(VectorDBBase):
|
||||
if existing:
|
||||
existing.vector = vector
|
||||
existing.text = item["text"]
|
||||
existing.vmetadata = stringify_metadata(item["metadata"])
|
||||
existing.vmetadata = process_metadata(item["metadata"])
|
||||
existing.collection_name = (
|
||||
collection_name # Update collection_name if necessary
|
||||
)
|
||||
@@ -333,7 +333,7 @@ class PgvectorClient(VectorDBBase):
|
||||
vector=vector,
|
||||
collection_name=collection_name,
|
||||
text=item["text"],
|
||||
vmetadata=stringify_metadata(item["metadata"]),
|
||||
vmetadata=process_metadata(item["metadata"]),
|
||||
)
|
||||
self.session.add(new_chunk)
|
||||
self.session.commit()
|
||||
|
||||
@@ -32,7 +32,7 @@ from open_webui.config import (
|
||||
PINECONE_CLOUD,
|
||||
)
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
|
||||
|
||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||
@@ -185,7 +185,7 @@ class PineconeClient(VectorDBBase):
|
||||
point = {
|
||||
"id": item["id"],
|
||||
"values": item["vector"],
|
||||
"metadata": stringify_metadata(metadata),
|
||||
"metadata": process_metadata(metadata),
|
||||
}
|
||||
points.append(point)
|
||||
return points
|
||||
|
||||
@@ -105,6 +105,13 @@ class QdrantClient(VectorDBBase):
|
||||
|
||||
Returns:
|
||||
tuple: (collection_name, tenant_id)
|
||||
|
||||
WARNING: This mapping relies on current Open WebUI naming conventions for
|
||||
collection names. If Open WebUI changes how it generates collection names
|
||||
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
|
||||
formats), this mapping will break and route data to incorrect collections.
|
||||
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
|
||||
DATA MAPPING INSIDE THE DATABASE.
|
||||
"""
|
||||
# Check for user memory collections
|
||||
tenant_id = collection_name
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from open_webui.retrieval.vector.utils import stringify_metadata
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
@@ -185,7 +185,7 @@ class S3VectorClient(VectorDBBase):
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Convert metadata to string format for consistency
|
||||
metadata = stringify_metadata(metadata)
|
||||
metadata = process_metadata(metadata)
|
||||
|
||||
# Filter metadata to comply with S3 Vector API limit of 10 keys
|
||||
metadata = self._filter_metadata(metadata, item["id"])
|
||||
@@ -256,7 +256,7 @@ class S3VectorClient(VectorDBBase):
|
||||
metadata["text"] = item["text"]
|
||||
|
||||
# Convert metadata to string format for consistency
|
||||
metadata = stringify_metadata(metadata)
|
||||
metadata = process_metadata(metadata)
|
||||
|
||||
# Filter metadata to comply with S3 Vector API limit of 10 keys
|
||||
metadata = self._filter_metadata(metadata, item["id"])
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from open_webui.retrieval.vector.main import VectorDBBase
|
||||
from open_webui.retrieval.vector.type import VectorType
|
||||
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
|
||||
from open_webui.config import (
|
||||
VECTOR_DB,
|
||||
ENABLE_QDRANT_MULTITENANCY_MODE,
|
||||
ENABLE_MILVUS_MULTITENANCY_MODE,
|
||||
)
|
||||
|
||||
|
||||
class Vector:
|
||||
@@ -12,9 +16,16 @@ class Vector:
|
||||
"""
|
||||
match vector_type:
|
||||
case VectorType.MILVUS:
|
||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||
if ENABLE_MILVUS_MULTITENANCY_MODE:
|
||||
from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
|
||||
MilvusClient,
|
||||
)
|
||||
|
||||
return MilvusClient()
|
||||
return MilvusClient()
|
||||
else:
|
||||
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
|
||||
|
||||
return MilvusClient()
|
||||
case VectorType.QDRANT:
|
||||
if ENABLE_QDRANT_MULTITENANCY_MODE:
|
||||
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
|
||||
|
||||
def stringify_metadata(
|
||||
|
||||
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def process_metadata(
|
||||
metadata: dict[str, any],
|
||||
) -> dict[str, any]:
|
||||
for key, value in metadata.items():
|
||||
# Remove large fields
|
||||
if key in KEYS_TO_EXCLUDE:
|
||||
del metadata[key]
|
||||
|
||||
# Convert non-serializable fields to strings
|
||||
if (
|
||||
isinstance(value, datetime)
|
||||
or isinstance(value, list)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_ollama_cloud(
|
||||
url: str,
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Ollama Search API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Ollama Search API key
|
||||
query (str): The query to search for
|
||||
count (int): Number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results by
|
||||
"""
|
||||
log.info(f"Searching with Ollama for query: {query}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"query": query, "max_results": count}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
log.info(f"Found {len(results)} results")
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result.get("url", ""),
|
||||
title=result.get("title", ""),
|
||||
snippet=result.get("content", ""),
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
except Exception as e:
|
||||
log.error(f"Error searching Ollama: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
from typing import Optional, Literal
|
||||
import requests
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_perplexity_search(
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Perplexity API key
|
||||
query (str): The query to search for
|
||||
count (int): Maximum number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results
|
||||
|
||||
"""
|
||||
|
||||
# Handle PersistentConfig object
|
||||
if hasattr(api_key, "__str__"):
|
||||
api_key = str(api_key)
|
||||
|
||||
try:
|
||||
url = "https://api.perplexity.ai/search"
|
||||
|
||||
# Create payload for the API call
|
||||
payload = {
|
||||
"query": query,
|
||||
"max_results": count,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Make the API request
|
||||
response = requests.request("POST", url, json=payload, headers=headers)
|
||||
# Parse the JSON response
|
||||
json_response = response.json()
|
||||
|
||||
# Extract citations from the response
|
||||
results = json_response.get("results", [])
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["url"], title=result["title"], snippet=result["snippet"]
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error searching with Perplexity Search API: {e}")
|
||||
return []
|
||||
@@ -75,7 +75,8 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]:
|
||||
try:
|
||||
if validate_url(u):
|
||||
valid_urls.append(u)
|
||||
except ValueError:
|
||||
except Exception as e:
|
||||
log.debug(f"Invalid URL {u}: {str(e)}")
|
||||
continue
|
||||
return valid_urls
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import html
|
||||
from functools import lru_cache
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
@@ -153,6 +154,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
|
||||
class TTSConfigForm(BaseModel):
|
||||
OPENAI_API_BASE_URL: str
|
||||
OPENAI_API_KEY: str
|
||||
OPENAI_PARAMS: Optional[dict] = None
|
||||
API_KEY: str
|
||||
ENGINE: str
|
||||
MODEL: str
|
||||
@@ -189,6 +191,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
||||
"tts": {
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
||||
"OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
|
||||
"API_KEY": request.app.state.config.TTS_API_KEY,
|
||||
"ENGINE": request.app.state.config.TTS_ENGINE,
|
||||
"MODEL": request.app.state.config.TTS_MODEL,
|
||||
@@ -221,6 +224,7 @@ async def update_audio_config(
|
||||
):
|
||||
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
|
||||
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
|
||||
request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
|
||||
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
|
||||
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
|
||||
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
|
||||
@@ -261,12 +265,13 @@ async def update_audio_config(
|
||||
|
||||
return {
|
||||
"tts": {
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
||||
"API_KEY": request.app.state.config.TTS_API_KEY,
|
||||
"ENGINE": request.app.state.config.TTS_ENGINE,
|
||||
"MODEL": request.app.state.config.TTS_MODEL,
|
||||
"VOICE": request.app.state.config.TTS_VOICE,
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
|
||||
"OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
|
||||
"API_KEY": request.app.state.config.TTS_API_KEY,
|
||||
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
|
||||
"AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION,
|
||||
"AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
|
||||
@@ -336,6 +341,11 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
payload = {
|
||||
**payload,
|
||||
**(request.app.state.config.TTS_OPENAI_PARAMS or {}),
|
||||
}
|
||||
|
||||
r = await session.post(
|
||||
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
||||
json=payload,
|
||||
@@ -458,7 +468,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
try:
|
||||
data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">
|
||||
<voice name="{language}">{payload["input"]}</voice>
|
||||
<voice name="{language}">{html.escape(payload["input"])}</voice>
|
||||
</speak>"""
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
async with aiohttp.ClientSession(
|
||||
|
||||
@@ -10,7 +10,13 @@ from pydantic import BaseModel
|
||||
from open_webui.socket.main import sio, get_user_ids_from_room
|
||||
from open_webui.models.users import Users, UserNameResponse
|
||||
|
||||
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.channels import (
|
||||
Channels,
|
||||
ChannelModel,
|
||||
ChannelForm,
|
||||
ChannelResponse,
|
||||
)
|
||||
from open_webui.models.messages import (
|
||||
Messages,
|
||||
MessageModel,
|
||||
@@ -80,7 +86,7 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[ChannelModel])
|
||||
@router.get("/{id}", response_model=Optional[ChannelResponse])
|
||||
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
|
||||
channel = Channels.get_channel_by_id(id)
|
||||
if not channel:
|
||||
@@ -95,7 +101,16 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
return ChannelModel(**channel.model_dump())
|
||||
write_access = has_access(
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
)
|
||||
|
||||
return ChannelResponse(
|
||||
**{
|
||||
**channel.model_dump(),
|
||||
"write_access": write_access or user.role == "admin",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -152,7 +167,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
|
||||
|
||||
|
||||
class MessageUserResponse(MessageResponse):
|
||||
user: UserNameResponse
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
|
||||
@@ -181,15 +196,17 @@ async def get_channel_messages(
|
||||
user = Users.get_user_by_id(message.user_id)
|
||||
users[message.user_id] = user
|
||||
|
||||
replies = Messages.get_replies_by_message_id(message.id)
|
||||
latest_reply_at = replies[0].created_at if replies else None
|
||||
thread_replies = Messages.get_thread_replies_by_message_id(message.id)
|
||||
latest_thread_reply_at = (
|
||||
thread_replies[0].created_at if thread_replies else None
|
||||
)
|
||||
|
||||
messages.append(
|
||||
MessageUserResponse(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"reply_count": len(replies),
|
||||
"latest_reply_at": latest_reply_at,
|
||||
"reply_count": len(thread_replies),
|
||||
"latest_reply_at": latest_thread_reply_at,
|
||||
"reactions": Messages.get_reactions_by_message_id(message.id),
|
||||
"user": UserNameResponse(**users[message.user_id].model_dump()),
|
||||
}
|
||||
@@ -238,12 +255,26 @@ async def model_response_handler(request, channel, message, user):
|
||||
mentions = extract_mentions(message.content)
|
||||
message_content = replace_mentions(message.content)
|
||||
|
||||
model_mentions = {}
|
||||
|
||||
# check if the message is a reply to a message sent by a model
|
||||
if (
|
||||
message.reply_to_message
|
||||
and message.reply_to_message.meta
|
||||
and message.reply_to_message.meta.get("model_id", None)
|
||||
):
|
||||
model_id = message.reply_to_message.meta.get("model_id", None)
|
||||
model_mentions[model_id] = {"id": model_id, "id_type": "M"}
|
||||
|
||||
# check if any of the mentions are models
|
||||
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
|
||||
for mention in mentions:
|
||||
if mention["id_type"] == "M" and mention["id"] not in model_mentions:
|
||||
model_mentions[mention["id"]] = mention
|
||||
|
||||
if not model_mentions:
|
||||
return False
|
||||
|
||||
for mention in model_mentions:
|
||||
for mention in model_mentions.values():
|
||||
model_id = mention["id"]
|
||||
model = MODELS.get(model_id, None)
|
||||
|
||||
@@ -275,6 +306,7 @@ async def model_response_handler(request, channel, message, user):
|
||||
)
|
||||
|
||||
thread_history = []
|
||||
images = []
|
||||
message_users = {}
|
||||
|
||||
for thread_message in thread_messages:
|
||||
@@ -303,24 +335,45 @@ async def model_response_handler(request, channel, message, user):
|
||||
f"{username}: {replace_mentions(thread_message.content)}"
|
||||
)
|
||||
|
||||
thread_message_files = thread_message.data.get("files", [])
|
||||
for file in thread_message_files:
|
||||
if file.get("type", "") == "image":
|
||||
images.append(file.get("url", ""))
|
||||
|
||||
thread_history_string = "\n\n".join(thread_history)
|
||||
system_message = {
|
||||
"role": "system",
|
||||
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
|
||||
"content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational."
|
||||
+ (
|
||||
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
|
||||
f"Here's the thread history:\n\n\n{thread_history_string}\n\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context."
|
||||
if thread_history
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
content = f"{user.name if user else 'User'}: {message_content}"
|
||||
if images:
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
},
|
||||
*[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image,
|
||||
},
|
||||
}
|
||||
for image in images
|
||||
],
|
||||
]
|
||||
|
||||
form_data = {
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
system_message,
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{user.name if user else 'User'}: {message_content}",
|
||||
},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
@@ -332,19 +385,34 @@ async def model_response_handler(request, channel, message, user):
|
||||
)
|
||||
|
||||
if res:
|
||||
await update_message_by_id(
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"content": res["choices"][0]["message"]["content"],
|
||||
"meta": {
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
if res.get("choices", []) and len(res["choices"]) > 0:
|
||||
await update_message_by_id(
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"content": res["choices"][0]["message"]["content"],
|
||||
"meta": {
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
elif res.get("error", None):
|
||||
await update_message_by_id(
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"content": f"Error: {res['error']}",
|
||||
"meta": {
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.info(e)
|
||||
pass
|
||||
@@ -362,7 +430,7 @@ async def new_message_handler(
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -370,31 +438,21 @@ async def new_message_handler(
|
||||
|
||||
try:
|
||||
message = Messages.insert_new_message(form_data, channel.id, user.id)
|
||||
|
||||
if message:
|
||||
message = Messages.get_message_by_id(message.id)
|
||||
event_data = {
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"reply_count": 0,
|
||||
"latest_reply_at": None,
|
||||
"reactions": Messages.get_reactions_by_message_id(
|
||||
message.id
|
||||
),
|
||||
"user": UserNameResponse(**user.model_dump()),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
}
|
||||
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
event_data,
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
@@ -405,29 +463,22 @@ async def new_message_handler(
|
||||
|
||||
if parent_message:
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": parent_message.id,
|
||||
"data": {
|
||||
"type": "message:reply",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**parent_message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(
|
||||
parent_message.user_id
|
||||
).model_dump()
|
||||
),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": parent_message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
return MessageModel(**message.model_dump()), channel
|
||||
return message, channel
|
||||
else:
|
||||
raise Exception("Error creating message")
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
@@ -609,20 +660,13 @@ async def update_message_by_id(
|
||||
|
||||
if message:
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message:update",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**user.model_dump()
|
||||
).model_dump(),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
@@ -658,7 +702,7 @@ async def add_reaction_to_message(
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -680,7 +724,7 @@ async def add_reaction_to_message(
|
||||
message = Messages.get_message_by_id(message_id)
|
||||
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
@@ -688,9 +732,6 @@ async def add_reaction_to_message(
|
||||
"type": "message:reaction:add",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(message.user_id).model_dump()
|
||||
).model_dump(),
|
||||
"name": form_data.name,
|
||||
},
|
||||
},
|
||||
@@ -724,7 +765,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -749,7 +790,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||
message = Messages.get_message_by_id(message_id)
|
||||
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
@@ -757,9 +798,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||
"type": "message:reaction:remove",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(message.user_id).model_dump()
|
||||
).model_dump(),
|
||||
"name": form_data.name,
|
||||
},
|
||||
},
|
||||
@@ -806,7 +844,9 @@ async def delete_message_by_id(
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(user.id, type="read", access_control=channel.access_control)
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -815,7 +855,7 @@ async def delete_message_by_id(
|
||||
try:
|
||||
Messages.delete_message_by_id(message_id)
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": message.id,
|
||||
@@ -838,22 +878,13 @@ async def delete_message_by_id(
|
||||
|
||||
if parent_message:
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"message_id": parent_message.id,
|
||||
"data": {
|
||||
"type": "message:reply",
|
||||
"data": MessageUserResponse(
|
||||
**{
|
||||
**parent_message.model_dump(),
|
||||
"user": UserNameResponse(
|
||||
**Users.get_user_by_id(
|
||||
parent_message.user_id
|
||||
).model_dump()
|
||||
),
|
||||
}
|
||||
).model_dump(),
|
||||
"data": parent_message.model_dump(),
|
||||
},
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
"channel": channel.model_dump(),
|
||||
|
||||
@@ -37,7 +37,10 @@ router = APIRouter()
|
||||
@router.get("/", response_model=list[ChatTitleIdResponse])
|
||||
@router.get("/list", response_model=list[ChatTitleIdResponse])
|
||||
def get_session_user_chat_list(
|
||||
user=Depends(get_verified_user), page: Optional[int] = None
|
||||
user=Depends(get_verified_user),
|
||||
page: Optional[int] = None,
|
||||
include_pinned: Optional[bool] = False,
|
||||
include_folders: Optional[bool] = False,
|
||||
):
|
||||
try:
|
||||
if page is not None:
|
||||
@@ -45,10 +48,16 @@ def get_session_user_chat_list(
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, skip=skip, limit=limit
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
else:
|
||||
return Chats.get_chat_title_id_list_by_user_id(user.id)
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, include_folders=include_folders, include_pinned=include_pinned
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
@@ -214,6 +223,28 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/folder/{folder_id}/list")
|
||||
async def get_chat_list_by_folder_id(
|
||||
folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user)
|
||||
):
|
||||
try:
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return [
|
||||
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
|
||||
for chat in Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder_id, user.id, skip=skip, limit=limit
|
||||
)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetPinnedChats
|
||||
############################
|
||||
@@ -335,6 +366,16 @@ async def archive_all_chats(user=Depends(get_verified_user)):
|
||||
return Chats.archive_all_chats_by_user_id(user.id)
|
||||
|
||||
|
||||
############################
|
||||
# UnarchiveAllChats
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/unarchive/all", response_model=bool)
|
||||
async def unarchive_all_chats(user=Depends(get_verified_user)):
|
||||
return Chats.unarchive_all_chats_by_user_id(user.id)
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChatById
|
||||
############################
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
import aiohttp
|
||||
|
||||
from typing import Optional
|
||||
|
||||
@@ -12,10 +14,24 @@ from open_webui.utils.tools import (
|
||||
get_tool_server_url,
|
||||
set_tool_servers,
|
||||
)
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.utils.oauth import (
|
||||
get_discovery_urls,
|
||||
get_oauth_client_info_with_dynamic_client_registration,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
############################
|
||||
# ImportConfig
|
||||
@@ -79,6 +95,43 @@ async def set_connections_config(
|
||||
}
|
||||
|
||||
|
||||
class OAuthClientRegistrationForm(BaseModel):
|
||||
url: str
|
||||
client_id: str
|
||||
client_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/oauth/clients/register")
|
||||
async def register_oauth_client(
|
||||
request: Request,
|
||||
form_data: OAuthClientRegistrationForm,
|
||||
type: Optional[str] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
try:
|
||||
oauth_client_id = form_data.client_id
|
||||
if type:
|
||||
oauth_client_id = f"{type}:{form_data.client_id}"
|
||||
|
||||
oauth_client_info = (
|
||||
await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request, oauth_client_id, form_data.url
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": True,
|
||||
"oauth_client_info": encrypt_data(
|
||||
oauth_client_info.model_dump(mode="json")
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to register OAuth client: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to register OAuth client",
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ToolServers Config
|
||||
############################
|
||||
@@ -87,6 +140,7 @@ async def set_connections_config(
|
||||
class ToolServerConnection(BaseModel):
|
||||
url: str
|
||||
path: str
|
||||
type: Optional[str] = "openapi" # openapi, mcp
|
||||
auth_type: Optional[str]
|
||||
key: Optional[str]
|
||||
config: Optional[dict]
|
||||
@@ -114,8 +168,29 @@ async def set_tool_servers_config(
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
|
||||
]
|
||||
|
||||
await set_tool_servers(request)
|
||||
|
||||
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
server_type = connection.get("type", "openapi")
|
||||
if server_type == "mcp":
|
||||
server_id = connection.get("info", {}).get("id")
|
||||
auth_type = connection.get("auth_type", "none")
|
||||
if auth_type == "oauth_2.1" and server_id:
|
||||
try:
|
||||
oauth_client_info = connection.get("info", {}).get(
|
||||
"oauth_client_info", ""
|
||||
)
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
|
||||
request.app.state.oauth_client_manager.add_client(
|
||||
f"{server_type}:{server_id}",
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to add OAuth client for MCP tool server: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
@@ -129,19 +204,106 @@ async def verify_tool_servers_config(
|
||||
Verify the connection to the tool server.
|
||||
"""
|
||||
try:
|
||||
if form_data.type == "mcp":
|
||||
if form_data.auth_type == "oauth_2.1":
|
||||
discovery_urls = get_discovery_urls(form_data.url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(
|
||||
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
|
||||
)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
discovery_url
|
||||
) as oauth_server_metadata_response:
|
||||
if oauth_server_metadata_response.status == 200:
|
||||
try:
|
||||
oauth_server_metadata = (
|
||||
OAuthMetadata.model_validate(
|
||||
await oauth_server_metadata_response.json()
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": True,
|
||||
"oauth_server_metadata": oauth_server_metadata.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
log.info(
|
||||
f"Failed to parse OAuth 2.1 discovery document: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_url}",
|
||||
)
|
||||
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
client = MCPClient()
|
||||
headers = None
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
elif form_data.auth_type == "system_oauth":
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if token:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
await client.connect(form_data.url, headers=headers)
|
||||
specs = await client.list_tool_specs()
|
||||
return {
|
||||
"status": True,
|
||||
"specs": specs,
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to create MCP client: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to create MCP client",
|
||||
)
|
||||
finally:
|
||||
if client:
|
||||
await client.disconnect()
|
||||
else: # openapi
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
elif form_data.auth_type == "system_oauth":
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to connect to the tool server: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to connect to the tool server: {str(e)}",
|
||||
detail=f"Failed to connect to the tool server",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from open_webui.models.folders import (
|
||||
FolderForm,
|
||||
FolderUpdateForm,
|
||||
FolderModel,
|
||||
FolderNameIdResponse,
|
||||
Folders,
|
||||
)
|
||||
from open_webui.models.chats import Chats
|
||||
@@ -44,12 +45,20 @@ router = APIRouter()
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FolderModel])
|
||||
@router.get("/", response_model=list[FolderNameIdResponse])
|
||||
async def get_folders(user=Depends(get_verified_user)):
|
||||
folders = Folders.get_folders_by_user_id(user.id)
|
||||
|
||||
# Verify folder data integrity
|
||||
folder_list = []
|
||||
for folder in folders:
|
||||
if folder.parent_id and not Folders.get_folder_by_id_and_user_id(
|
||||
folder.parent_id, user.id
|
||||
):
|
||||
folder = Folders.update_folder_parent_id_by_id_and_user_id(
|
||||
folder.id, user.id, None
|
||||
)
|
||||
|
||||
if folder.data:
|
||||
if "files" in folder.data:
|
||||
valid_files = []
|
||||
@@ -73,20 +82,9 @@ async def get_folders(user=Depends(get_verified_user)):
|
||||
folder.id, user.id, FolderUpdateForm(data=folder.data)
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
**folder.model_dump(),
|
||||
"items": {
|
||||
"chats": [
|
||||
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
|
||||
for chat in Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder.id, user.id
|
||||
)
|
||||
]
|
||||
},
|
||||
}
|
||||
for folder in folders
|
||||
]
|
||||
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
|
||||
|
||||
return folder_list
|
||||
|
||||
|
||||
############################
|
||||
@@ -262,31 +260,41 @@ async def update_folder_is_expanded_by_id(
|
||||
async def delete_folder_by_id(
|
||||
request: Request, id: str, user=Depends(get_verified_user)
|
||||
):
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
|
||||
if folder:
|
||||
try:
|
||||
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
|
||||
for folder_id in folder_ids:
|
||||
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error(f"Error deleting folder: {id}")
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
folders = []
|
||||
folders.append(Folders.get_folder_by_id_and_user_id(id, user.id))
|
||||
while folders:
|
||||
folder = folders.pop()
|
||||
if folder:
|
||||
try:
|
||||
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
|
||||
for folder_id in folder_ids:
|
||||
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error(f"Error deleting folder: {id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
|
||||
)
|
||||
finally:
|
||||
# Get all subfolders
|
||||
subfolders = Folders.get_folders_by_parent_id_and_user_id(
|
||||
folder.id, user.id
|
||||
)
|
||||
folders.extend(subfolders)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -10,6 +10,7 @@ from open_webui.models.functions import (
|
||||
FunctionForm,
|
||||
FunctionModel,
|
||||
FunctionResponse,
|
||||
FunctionUserResponse,
|
||||
FunctionWithValvesModel,
|
||||
Functions,
|
||||
)
|
||||
@@ -42,6 +43,11 @@ async def get_functions(user=Depends(get_verified_user)):
|
||||
return Functions.get_functions()
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[FunctionUserResponse])
|
||||
async def get_function_list(user=Depends(get_admin_user)):
|
||||
return Functions.get_function_list()
|
||||
|
||||
|
||||
############################
|
||||
# ExportFunctions
|
||||
############################
|
||||
@@ -431,8 +437,10 @@ async def update_function_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
valves = Valves(**form_data)
|
||||
Functions.update_function_valves_by_id(id, valves.model_dump())
|
||||
return valves.model_dump()
|
||||
|
||||
valves_dict = valves.model_dump(exclude_unset=True)
|
||||
Functions.update_function_valves_by_id(id, valves_dict)
|
||||
return valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function values by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
@@ -514,10 +522,11 @@ async def update_function_user_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Functions.update_user_valves_by_id_and_user_id(
|
||||
id, user.id, user_valves.model_dump()
|
||||
id, user.id, user_valves_dict
|
||||
)
|
||||
return user_valves.model_dump()
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function user valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -514,6 +514,7 @@ async def image_generations(
|
||||
size = form_data.size
|
||||
|
||||
width, height = tuple(map(int, size.split("x")))
|
||||
model = get_image_model(request)
|
||||
|
||||
r = None
|
||||
try:
|
||||
@@ -531,11 +532,7 @@ async def image_generations(
|
||||
headers["X-OpenWebUI-User-Role"] = user.role
|
||||
|
||||
data = {
|
||||
"model": (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
if request.app.state.config.IMAGE_GENERATION_MODEL != ""
|
||||
else "dall-e-2"
|
||||
),
|
||||
"model": model,
|
||||
"prompt": form_data.prompt,
|
||||
"n": form_data.n,
|
||||
"size": (
|
||||
@@ -584,7 +581,6 @@ async def image_generations(
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
|
||||
|
||||
model = get_image_model(request)
|
||||
data = {
|
||||
"instances": {"prompt": form_data.prompt},
|
||||
"parameters": {
|
||||
@@ -640,7 +636,7 @@ async def image_generations(
|
||||
}
|
||||
)
|
||||
res = await comfyui_generate_image(
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
model,
|
||||
form_data,
|
||||
user.id,
|
||||
request.app.state.config.COMFYUI_BASE_URL,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from typing import Optional
|
||||
import io
|
||||
import base64
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from open_webui.models.models import (
|
||||
ModelForm,
|
||||
@@ -12,7 +15,14 @@ from open_webui.models.models import (
|
||||
|
||||
from pydantic import BaseModel
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
@@ -20,9 +30,15 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def validate_model_id(model_id: str) -> bool:
|
||||
return model_id and len(model_id) <= 256
|
||||
|
||||
|
||||
###########################
|
||||
# GetModels
|
||||
###########################
|
||||
@@ -72,6 +88,12 @@ async def create_new_model(
|
||||
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
|
||||
)
|
||||
|
||||
if not validate_model_id(form_data.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG,
|
||||
)
|
||||
|
||||
else:
|
||||
model = Models.insert_new_model(form_data, user.id)
|
||||
if model:
|
||||
@@ -93,6 +115,51 @@ async def export_models(user=Depends(get_admin_user)):
|
||||
return Models.get_models()
|
||||
|
||||
|
||||
############################
|
||||
# ImportModels
|
||||
############################
|
||||
|
||||
|
||||
class ModelsImportForm(BaseModel):
|
||||
models: list[dict]
|
||||
|
||||
|
||||
@router.post("/import", response_model=bool)
|
||||
async def import_models(
|
||||
user: str = Depends(get_admin_user), form_data: ModelsImportForm = (...)
|
||||
):
|
||||
try:
|
||||
data = form_data.models
|
||||
if isinstance(data, list):
|
||||
for model_data in data:
|
||||
# Here, you can add logic to validate model_data if needed
|
||||
model_id = model_data.get("id")
|
||||
|
||||
if model_id and validate_model_id(model_id):
|
||||
existing_model = Models.get_model_by_id(model_id)
|
||||
if existing_model:
|
||||
# Update existing model
|
||||
model_data["meta"] = model_data.get("meta", {})
|
||||
model_data["params"] = model_data.get("params", {})
|
||||
|
||||
updated_model = ModelForm(
|
||||
**{**existing_model.model_dump(), **model_data}
|
||||
)
|
||||
Models.update_model_by_id(model_id, updated_model)
|
||||
else:
|
||||
# Insert new model
|
||||
model_data["meta"] = model_data.get("meta", {})
|
||||
model_data["params"] = model_data.get("params", {})
|
||||
new_model = ModelForm(**model_data)
|
||||
Models.insert_new_model(user_id=user.id, form_data=new_model)
|
||||
return True
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON format")
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
############################
|
||||
# SyncModels
|
||||
############################
|
||||
|
||||
@@ -48,7 +48,7 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
|
||||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
||||
}
|
||||
)
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
for note in Notes.get_notes_by_permission(user.id, "write")
|
||||
]
|
||||
|
||||
return notes
|
||||
@@ -81,7 +81,9 @@ async def get_note_list(
|
||||
|
||||
notes = [
|
||||
NoteTitleIdResponse(**note.model_dump())
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit)
|
||||
for note in Notes.get_notes_by_permission(
|
||||
user.id, "write", skip=skip, limit=limit
|
||||
)
|
||||
]
|
||||
|
||||
return notes
|
||||
@@ -178,6 +180,18 @@ async def update_note_by_id(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
|
||||
try:
|
||||
note = Notes.update_note_by_id(id, form_data)
|
||||
await sio.emit(
|
||||
|
||||
@@ -1020,6 +1020,10 @@ class GenerateEmbedForm(BaseModel):
|
||||
options: Optional[dict] = None
|
||||
keep_alive: Optional[Union[int, str]] = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/embed")
|
||||
@router.post("/api/embed/{url_idx}")
|
||||
@@ -1694,25 +1698,27 @@ async def download_file_stream(
|
||||
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
|
||||
|
||||
if done:
|
||||
file.seek(0)
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
file.seek(0)
|
||||
file.close()
|
||||
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
response = requests.post(url, data=file)
|
||||
with open(file_path, "rb") as file:
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
|
||||
if response.ok:
|
||||
res = {
|
||||
"done": done,
|
||||
"blob": f"sha256:{hashed}",
|
||||
"name": file_name,
|
||||
}
|
||||
os.remove(file_path)
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
with requests.Session() as session:
|
||||
response = session.post(url, data=file, timeout=30)
|
||||
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
else:
|
||||
raise "Ollama: Could not create blob, Please try again."
|
||||
if response.ok:
|
||||
res = {
|
||||
"done": done,
|
||||
"blob": f"sha256:{hashed}",
|
||||
"name": file_name,
|
||||
}
|
||||
os.remove(file_path)
|
||||
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
else:
|
||||
raise "Ollama: Could not create blob, Please try again."
|
||||
|
||||
|
||||
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
|
||||
|
||||
@@ -121,7 +121,7 @@ def openai_reasoning_model_handler(payload):
|
||||
return payload
|
||||
|
||||
|
||||
def get_headers_and_cookies(
|
||||
async def get_headers_and_cookies(
|
||||
request: Request,
|
||||
url,
|
||||
key=None,
|
||||
@@ -174,7 +174,7 @@ def get_headers_and_cookies(
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
@@ -190,6 +190,9 @@ def get_headers_and_cookies(
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
if config.get("headers") and isinstance(config.get("headers"), dict):
|
||||
headers = {**headers, **config.get("headers")}
|
||||
|
||||
return headers, cookies
|
||||
|
||||
|
||||
@@ -305,7 +308,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
@@ -570,7 +573,7 @@ async def get_models(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
@@ -656,7 +659,7 @@ async def verify_connection(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
@@ -901,7 +904,7 @@ async def generate_chat_completion(
|
||||
convert_logit_bias_input_to_json(payload["logit_bias"])
|
||||
)
|
||||
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, metadata, user=user
|
||||
)
|
||||
|
||||
@@ -1010,7 +1013,9 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
session = None
|
||||
streaming = False
|
||||
|
||||
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
try:
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
r = await session.request(
|
||||
@@ -1080,7 +1085,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import shutil
|
||||
import asyncio
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -45,6 +46,8 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
|
||||
# Web search engines
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.retrieval.web.ollama import search_ollama_cloud
|
||||
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
|
||||
from open_webui.retrieval.web.brave import search_brave
|
||||
from open_webui.retrieval.web.kagi import search_kagi
|
||||
from open_webui.retrieval.web.mojeek import search_mojeek
|
||||
@@ -68,6 +71,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl
|
||||
from open_webui.retrieval.web.external import search_external
|
||||
|
||||
from open_webui.retrieval.utils import (
|
||||
get_content_from_url,
|
||||
get_embedding_function,
|
||||
get_reranking_function,
|
||||
get_model_path,
|
||||
@@ -76,6 +80,7 @@ from open_webui.retrieval.utils import (
|
||||
query_doc,
|
||||
query_doc_with_hybrid_search,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import filter_metadata
|
||||
from open_webui.utils.misc import (
|
||||
calculate_sha256_string,
|
||||
)
|
||||
@@ -186,6 +191,26 @@ def get_rf(
|
||||
log.error(f"CrossEncoder: {e}")
|
||||
raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error"))
|
||||
|
||||
# Safely adjust pad_token_id if missing as some models do not have this in config
|
||||
try:
|
||||
model_cfg = getattr(rf, "model", None)
|
||||
if model_cfg and hasattr(model_cfg, "config"):
|
||||
cfg = model_cfg.config
|
||||
if getattr(cfg, "pad_token_id", None) is None:
|
||||
# Fallback to eos_token_id when available
|
||||
eos = getattr(cfg, "eos_token_id", None)
|
||||
if eos is not None:
|
||||
cfg.pad_token_id = eos
|
||||
log.debug(
|
||||
f"Missing pad_token_id detected; set to eos_token_id={eos}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"Neither pad_token_id nor eos_token_id present in model config"
|
||||
)
|
||||
except Exception as e2:
|
||||
log.warning(f"Failed to adjust pad_token_id on CrossEncoder: {e2}")
|
||||
|
||||
return rf
|
||||
|
||||
|
||||
@@ -426,6 +451,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
|
||||
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
|
||||
"DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS,
|
||||
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
|
||||
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
@@ -440,6 +466,11 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
"DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
"MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
# MinerU settings
|
||||
"MINERU_API_MODE": request.app.state.config.MINERU_API_MODE,
|
||||
"MINERU_API_URL": request.app.state.config.MINERU_API_URL,
|
||||
"MINERU_API_KEY": request.app.state.config.MINERU_API_KEY,
|
||||
"MINERU_PARAMS": request.app.state.config.MINERU_PARAMS,
|
||||
# Reranking settings
|
||||
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
@@ -469,6 +500,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
@@ -525,6 +557,7 @@ class WebConfig(BaseModel):
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
|
||||
SEARXNG_QUERY_URL: Optional[str] = None
|
||||
YACY_QUERY_URL: Optional[str] = None
|
||||
YACY_USERNAME: Optional[str] = None
|
||||
@@ -585,6 +618,7 @@ class ConfigForm(BaseModel):
|
||||
# Content extraction settings
|
||||
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
|
||||
PDF_EXTRACT_IMAGES: Optional[bool] = None
|
||||
|
||||
DATALAB_MARKER_API_KEY: Optional[str] = None
|
||||
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG: Optional[str] = None
|
||||
@@ -596,11 +630,13 @@ class ConfigForm(BaseModel):
|
||||
DATALAB_MARKER_FORMAT_LINES: Optional[bool] = None
|
||||
DATALAB_MARKER_USE_LLM: Optional[bool] = None
|
||||
DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None
|
||||
|
||||
EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY: Optional[str] = None
|
||||
|
||||
TIKA_SERVER_URL: Optional[str] = None
|
||||
DOCLING_SERVER_URL: Optional[str] = None
|
||||
DOCLING_PARAMS: Optional[dict] = None
|
||||
DOCLING_DO_OCR: Optional[bool] = None
|
||||
DOCLING_FORCE_OCR: Optional[bool] = None
|
||||
DOCLING_OCR_ENGINE: Optional[str] = None
|
||||
@@ -616,6 +652,12 @@ class ConfigForm(BaseModel):
|
||||
DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None
|
||||
MISTRAL_OCR_API_KEY: Optional[str] = None
|
||||
|
||||
# MinerU settings
|
||||
MINERU_API_MODE: Optional[str] = None
|
||||
MINERU_API_URL: Optional[str] = None
|
||||
MINERU_API_KEY: Optional[str] = None
|
||||
MINERU_PARAMS: Optional[dict] = None
|
||||
|
||||
# Reranking settings
|
||||
RAG_RERANKING_MODEL: Optional[str] = None
|
||||
RAG_RERANKING_ENGINE: Optional[str] = None
|
||||
@@ -777,6 +819,11 @@ async def update_rag_config(
|
||||
if form_data.DOCLING_SERVER_URL is not None
|
||||
else request.app.state.config.DOCLING_SERVER_URL
|
||||
)
|
||||
request.app.state.config.DOCLING_PARAMS = (
|
||||
form_data.DOCLING_PARAMS
|
||||
if form_data.DOCLING_PARAMS is not None
|
||||
else request.app.state.config.DOCLING_PARAMS
|
||||
)
|
||||
request.app.state.config.DOCLING_DO_OCR = (
|
||||
form_data.DOCLING_DO_OCR
|
||||
if form_data.DOCLING_DO_OCR is not None
|
||||
@@ -850,6 +897,28 @@ async def update_rag_config(
|
||||
else request.app.state.config.MISTRAL_OCR_API_KEY
|
||||
)
|
||||
|
||||
# MinerU settings
|
||||
request.app.state.config.MINERU_API_MODE = (
|
||||
form_data.MINERU_API_MODE
|
||||
if form_data.MINERU_API_MODE is not None
|
||||
else request.app.state.config.MINERU_API_MODE
|
||||
)
|
||||
request.app.state.config.MINERU_API_URL = (
|
||||
form_data.MINERU_API_URL
|
||||
if form_data.MINERU_API_URL is not None
|
||||
else request.app.state.config.MINERU_API_URL
|
||||
)
|
||||
request.app.state.config.MINERU_API_KEY = (
|
||||
form_data.MINERU_API_KEY
|
||||
if form_data.MINERU_API_KEY is not None
|
||||
else request.app.state.config.MINERU_API_KEY
|
||||
)
|
||||
request.app.state.config.MINERU_PARAMS = (
|
||||
form_data.MINERU_PARAMS
|
||||
if form_data.MINERU_PARAMS is not None
|
||||
else request.app.state.config.MINERU_PARAMS
|
||||
)
|
||||
|
||||
# Reranking settings
|
||||
if request.app.state.config.RAG_RERANKING_ENGINE == "":
|
||||
# Unloading the internal reranker and clear VRAM memory
|
||||
@@ -988,6 +1057,9 @@ async def update_rag_config(
|
||||
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
|
||||
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
)
|
||||
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = (
|
||||
form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
)
|
||||
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
|
||||
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
|
||||
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
|
||||
@@ -1096,6 +1168,7 @@ async def update_rag_config(
|
||||
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
|
||||
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
|
||||
"DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS,
|
||||
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
|
||||
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
@@ -1110,6 +1183,11 @@ async def update_rag_config(
|
||||
"DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
"DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
"MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
# MinerU settings
|
||||
"MINERU_API_MODE": request.app.state.config.MINERU_API_MODE,
|
||||
"MINERU_API_URL": request.app.state.config.MINERU_API_URL,
|
||||
"MINERU_API_KEY": request.app.state.config.MINERU_API_KEY,
|
||||
"MINERU_PARAMS": request.app.state.config.MINERU_PARAMS,
|
||||
# Reranking settings
|
||||
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
@@ -1139,6 +1217,7 @@ async def update_rag_config(
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
@@ -1407,59 +1486,35 @@ def process_file(
|
||||
form_data: ProcessFileForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
try:
|
||||
if user.role == "admin":
|
||||
file = Files.get_file_by_id(form_data.file_id)
|
||||
else:
|
||||
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
|
||||
|
||||
collection_name = form_data.collection_name
|
||||
if file:
|
||||
try:
|
||||
|
||||
if collection_name is None:
|
||||
collection_name = f"file-{file.id}"
|
||||
collection_name = form_data.collection_name
|
||||
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
|
||||
if collection_name is None:
|
||||
collection_name = f"file-{file.id}"
|
||||
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
|
||||
except:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=form_data.content.replace("<br/>", "\n"),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
text_content = form_data.content
|
||||
elif form_data.collection_name:
|
||||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=f"file-{file.id}", filter={"file_id": file.id}
|
||||
)
|
||||
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.documents[0][idx],
|
||||
metadata=result.metadatas[0][idx],
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(
|
||||
collection_name=f"file-{file.id}"
|
||||
)
|
||||
for idx, id in enumerate(result.ids[0])
|
||||
]
|
||||
else:
|
||||
except:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
page_content=form_data.content.replace("<br/>", "\n"),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
@@ -1470,149 +1525,195 @@ def process_file(
|
||||
)
|
||||
]
|
||||
|
||||
text_content = file.data.get("content", "")
|
||||
else:
|
||||
# Process the file and save the content
|
||||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
|
||||
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"pipeline": request.app.state.config.DOCLING_PIPELINE,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
},
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
)
|
||||
docs = loader.load(
|
||||
file.filename, file.meta.get("content_type"), file_path
|
||||
text_content = form_data.content
|
||||
elif form_data.collection_name:
|
||||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=f"file-{file.id}", filter={"file_id": file.id}
|
||||
)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=doc.page_content,
|
||||
metadata={
|
||||
**doc.metadata,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
text_content = " ".join([doc.page_content for doc in docs])
|
||||
|
||||
log.debug(f"text_content: {text_content}")
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"content": text_content},
|
||||
)
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
|
||||
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
Files.update_file_data_by_id(file.id, {"status": "completed"})
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
metadata={
|
||||
"file_id": file.id,
|
||||
"name": file.filename,
|
||||
"hash": hash,
|
||||
},
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.documents[0][idx],
|
||||
metadata=result.metadatas[0][idx],
|
||||
)
|
||||
for idx, id in enumerate(result.ids[0])
|
||||
]
|
||||
else:
|
||||
raise Exception("Error saving document to vector database")
|
||||
except Exception as e:
|
||||
raise e
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
|
||||
text_content = file.data.get("content", "")
|
||||
else:
|
||||
# Process the file and save the content
|
||||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
|
||||
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"pipeline": request.app.state.config.DOCLING_PIPELINE,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
**request.app.state.config.DOCLING_PARAMS,
|
||||
},
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
MINERU_API_MODE=request.app.state.config.MINERU_API_MODE,
|
||||
MINERU_API_URL=request.app.state.config.MINERU_API_URL,
|
||||
MINERU_API_KEY=request.app.state.config.MINERU_API_KEY,
|
||||
MINERU_PARAMS=request.app.state.config.MINERU_PARAMS,
|
||||
)
|
||||
docs = loader.load(
|
||||
file.filename, file.meta.get("content_type"), file_path
|
||||
)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=doc.page_content,
|
||||
metadata={
|
||||
**filter_metadata(doc.metadata),
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
text_content = " ".join([doc.page_content for doc in docs])
|
||||
|
||||
log.debug(f"text_content: {text_content}")
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"content": text_content},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
|
||||
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
Files.update_file_data_by_id(file.id, {"status": "completed"})
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
metadata={
|
||||
"file_id": file.id,
|
||||
"name": file.filename,
|
||||
"hash": hash,
|
||||
},
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
raise Exception("Error saving document to vector database")
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
)
|
||||
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class ProcessTextForm(BaseModel):
|
||||
name: str
|
||||
@@ -1654,49 +1755,6 @@ def process_text(
|
||||
|
||||
|
||||
@router.post("/process/youtube")
|
||||
def process_youtube_video(
|
||||
request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user)
|
||||
):
|
||||
try:
|
||||
collection_name = form_data.collection_name
|
||||
if not collection_name:
|
||||
collection_name = calculate_sha256_string(form_data.url)[:63]
|
||||
|
||||
loader = YoutubeLoader(
|
||||
form_data.url,
|
||||
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
)
|
||||
|
||||
docs = loader.load()
|
||||
content = " ".join([doc.page_content for doc in docs])
|
||||
log.debug(f"text_content: {content}")
|
||||
|
||||
save_docs_to_vector_db(
|
||||
request, docs, collection_name, overwrite=True, user=user
|
||||
)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": form_data.url,
|
||||
"file": {
|
||||
"data": {
|
||||
"content": content,
|
||||
},
|
||||
"meta": {
|
||||
"name": form_data.url,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/process/web")
|
||||
def process_web(
|
||||
request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user)
|
||||
@@ -1706,19 +1764,16 @@ def process_web(
|
||||
if not collection_name:
|
||||
collection_name = calculate_sha256_string(form_data.url)[:63]
|
||||
|
||||
loader = get_web_loader(
|
||||
form_data.url,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
)
|
||||
docs = loader.load()
|
||||
content = " ".join([doc.page_content for doc in docs])
|
||||
|
||||
content, docs = get_content_from_url(request, form_data.url)
|
||||
log.debug(f"text_content: {content}")
|
||||
|
||||
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
|
||||
save_docs_to_vector_db(
|
||||
request, docs, collection_name, overwrite=True, user=user
|
||||
request,
|
||||
docs,
|
||||
collection_name,
|
||||
overwrite=True,
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
collection_name = None
|
||||
@@ -1769,7 +1824,25 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||
"""
|
||||
|
||||
# TODO: add playwright to search the web
|
||||
if engine == "searxng":
|
||||
if engine == "ollama_cloud":
|
||||
return search_ollama_cloud(
|
||||
"https://ollama.com",
|
||||
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
elif engine == "perplexity_search":
|
||||
if request.app.state.config.PERPLEXITY_API_KEY:
|
||||
return search_perplexity_search(
|
||||
request.app.state.config.PERPLEXITY_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception("No PERPLEXITY_API_KEY found in environment variables")
|
||||
elif engine == "searxng":
|
||||
if request.app.state.config.SEARXNG_QUERY_URL:
|
||||
return search_searxng(
|
||||
request.app.state.config.SEARXNG_QUERY_URL,
|
||||
@@ -2003,7 +2076,7 @@ async def process_web_search(
|
||||
result_items = []
|
||||
|
||||
try:
|
||||
logging.info(
|
||||
logging.debug(
|
||||
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
|
||||
)
|
||||
|
||||
@@ -2037,6 +2110,12 @@ async def process_web_search(
|
||||
detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e),
|
||||
)
|
||||
|
||||
if len(urls) == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT("No results found from web search"),
|
||||
)
|
||||
|
||||
try:
|
||||
if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER:
|
||||
search_results = [
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, HttpUrl
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.tools import (
|
||||
ToolForm,
|
||||
ToolModel,
|
||||
@@ -16,7 +17,11 @@ from open_webui.models.tools import (
|
||||
ToolUserResponse,
|
||||
Tools,
|
||||
)
|
||||
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
|
||||
from open_webui.utils.plugin import (
|
||||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
get_tool_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.tools import get_tool_specs
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
@@ -34,6 +39,14 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tool_module(request, tool_id, load_from_db=True):
|
||||
"""
|
||||
Get the tool module by its ID.
|
||||
"""
|
||||
tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
|
||||
return tool_module
|
||||
|
||||
|
||||
############################
|
||||
# GetTools
|
||||
############################
|
||||
@@ -41,8 +54,21 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=list[ToolUserResponse])
|
||||
async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
tools = Tools.get_tools()
|
||||
tools = []
|
||||
|
||||
# Local Tools
|
||||
for tool in Tools.get_tools():
|
||||
tool_module = get_tool_module(request, tool.id)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
"has_user_valves": hasattr(tool_module, "UserValves"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
for server in await get_tool_servers(request):
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
@@ -68,6 +94,50 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
)
|
||||
)
|
||||
|
||||
# MCP Tool Servers
|
||||
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if server.get("type", "openapi") == "mcp":
|
||||
server_id = server.get("info", {}).get("id")
|
||||
auth_type = server.get("auth_type", "none")
|
||||
|
||||
session_token = None
|
||||
if auth_type == "oauth_2.1":
|
||||
splits = server_id.split(":")
|
||||
server_id = splits[-1] if len(splits) > 1 else server_id
|
||||
|
||||
session_token = (
|
||||
await request.app.state.oauth_client_manager.get_oauth_token(
|
||||
user.id, f"mcp:{server_id}"
|
||||
)
|
||||
)
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"name": server.get("info", {}).get("name", "MCP Tool Server"),
|
||||
"meta": {
|
||||
"description": server.get("info", {}).get(
|
||||
"description", ""
|
||||
),
|
||||
},
|
||||
"access_control": server.get("config", {}).get(
|
||||
"access_control", None
|
||||
),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
**(
|
||||
{
|
||||
"authenticated": session_token is not None,
|
||||
}
|
||||
if auth_type == "oauth_2.1"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
# Admin can see all tools
|
||||
return tools
|
||||
@@ -462,8 +532,9 @@ async def update_tools_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
valves = Valves(**form_data)
|
||||
Tools.update_tool_valves_by_id(id, valves.model_dump())
|
||||
return valves.model_dump()
|
||||
valves_dict = valves.model_dump(exclude_unset=True)
|
||||
Tools.update_tool_valves_by_id(id, valves_dict)
|
||||
return valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update tool valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
@@ -538,10 +609,11 @@ async def update_tools_user_valves_by_id(
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Tools.update_user_valves_by_id_and_user_id(
|
||||
id, user.id, user_valves.model_dump()
|
||||
id, user.id, user_valves_dict
|
||||
)
|
||||
return user_valves.model_dump()
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update user valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -157,6 +157,7 @@ class SharingPermissions(BaseModel):
|
||||
public_knowledge: bool = True
|
||||
public_prompts: bool = True
|
||||
public_tools: bool = True
|
||||
public_notes: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
|
||||
@@ -356,7 +356,7 @@ async def join_note(sid, data):
|
||||
await sio.enter_room(sid, f"note:{note.id}")
|
||||
|
||||
|
||||
@sio.on("channel-events")
|
||||
@sio.on("events:channel")
|
||||
async def channel_events(sid, data):
|
||||
room = f"channel:{data['channel_id']}"
|
||||
participants = sio.manager.get_participants(
|
||||
@@ -373,7 +373,7 @@ async def channel_events(sid, data):
|
||||
|
||||
if event_type == "typing":
|
||||
await sio.emit(
|
||||
"channel-events",
|
||||
"events:channel",
|
||||
{
|
||||
"channel_id": data["channel_id"],
|
||||
"message_id": data.get("message_id", None),
|
||||
@@ -653,12 +653,15 @@ def get_event_emitter(request_info, update_db=True):
|
||||
)
|
||||
)
|
||||
|
||||
chat_id = request_info.get("chat_id", None)
|
||||
message_id = request_info.get("message_id", None)
|
||||
|
||||
emit_tasks = [
|
||||
sio.emit(
|
||||
"chat-events",
|
||||
"events",
|
||||
{
|
||||
"chat_id": request_info.get("chat_id", None),
|
||||
"message_id": request_info.get("message_id", None),
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"data": event_data,
|
||||
},
|
||||
to=session_id,
|
||||
@@ -667,8 +670,11 @@ def get_event_emitter(request_info, update_db=True):
|
||||
]
|
||||
|
||||
await asyncio.gather(*emit_tasks)
|
||||
|
||||
if update_db:
|
||||
if (
|
||||
update_db
|
||||
and message_id
|
||||
and not request_info.get("chat_id", "").startswith("local:")
|
||||
):
|
||||
if "type" in event_data and event_data["type"] == "status":
|
||||
Chats.add_message_status_to_chat_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
@@ -705,6 +711,23 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "embeds":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
|
||||
embeds = event_data.get("data", {}).get("embeds", [])
|
||||
embeds.extend(message.get("embeds", []))
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
"embeds": embeds,
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "files":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
request_info["chat_id"],
|
||||
@@ -747,7 +770,7 @@ def get_event_emitter(request_info, update_db=True):
|
||||
def get_event_call(request_info):
|
||||
async def __event_caller__(event_data):
|
||||
response = await sio.call(
|
||||
"chat-events",
|
||||
"events",
|
||||
{
|
||||
"chat_id": request_info.get("chat_id", None),
|
||||
"message_id": request_info.get("message_id", None),
|
||||
|
||||
@@ -164,7 +164,10 @@ async def stop_task(redis, task_id: str):
|
||||
# Task successfully canceled
|
||||
return {"status": True, "message": f"Task {task_id} successfully stopped."}
|
||||
|
||||
return {"status": False, "message": f"Failed to stop task {task_id}."}
|
||||
if task.cancelled() or task.done():
|
||||
return {"status": True, "message": f"Task {task_id} successfully cancelled."}
|
||||
|
||||
return {"status": True, "message": f"Cancellation requested for {task_id}."}
|
||||
|
||||
|
||||
async def stop_item_tasks(redis: Redis, item_id: str):
|
||||
|
||||
@@ -110,9 +110,13 @@ def has_access(
|
||||
type: str = "write",
|
||||
access_control: Optional[dict] = None,
|
||||
user_group_ids: Optional[Set[str]] = None,
|
||||
strict: bool = True,
|
||||
) -> bool:
|
||||
if access_control is None:
|
||||
return type == "read"
|
||||
if strict:
|
||||
return type == "read"
|
||||
else:
|
||||
return True
|
||||
|
||||
if user_group_ids is None:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
|
||||
@@ -6,7 +6,7 @@ import hmac
|
||||
import hashlib
|
||||
import requests
|
||||
import os
|
||||
|
||||
import bcrypt
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
@@ -38,11 +38,8 @@ from open_webui.env import (
|
||||
|
||||
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from passlib.context import CryptContext
|
||||
|
||||
|
||||
logging.getLogger("passlib").setLevel(logging.ERROR)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["OAUTH"])
|
||||
|
||||
@@ -155,19 +152,25 @@ def get_license_data(app, key):
|
||||
|
||||
|
||||
bearer_security = HTTPBearer(auto_error=False)
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password using bcrypt"""
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return (
|
||||
pwd_context.verify(plain_password, hashed_password) if hashed_password else None
|
||||
bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
if hashed_password
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
|
||||
payload = data.copy()
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ async def generate_direct_chat_completion(
|
||||
event_caller = get_event_call(metadata)
|
||||
|
||||
channel = f"{user_id}:{session_id}:{request_id}"
|
||||
logging.info(f"WebSocket channel: {channel}")
|
||||
|
||||
if form_data.get("stream"):
|
||||
q = asyncio.Queue()
|
||||
@@ -121,7 +122,10 @@ async def generate_direct_chat_completion(
|
||||
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
elif isinstance(data, str):
|
||||
yield data
|
||||
if "data:" in data:
|
||||
yield f"{data}\n\n"
|
||||
else:
|
||||
yield f"data: {data}\n\n"
|
||||
except Exception as e:
|
||||
log.debug(f"Error in event generator: {e}")
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from open_webui.routers.images import (
|
||||
load_b64_image_data,
|
||||
upload_image,
|
||||
)
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
|
||||
from open_webui.routers.files import upload_file_handler
|
||||
|
||||
import mimetypes
|
||||
import base64
|
||||
import io
|
||||
|
||||
|
||||
def get_image_url_from_base64(request, base64_image_string, metadata, user):
|
||||
if "data:image/png;base64" in base64_image_string:
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = load_b64_image_data(base64_image_string)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
return image_url
|
||||
return None
|
||||
|
||||
|
||||
def load_b64_audio_data(b64_str):
|
||||
try:
|
||||
if "," in b64_str:
|
||||
header, b64_data = b64_str.split(",", 1)
|
||||
else:
|
||||
b64_data = b64_str
|
||||
header = "data:audio/wav;base64"
|
||||
audio_data = base64.b64decode(b64_data)
|
||||
content_type = (
|
||||
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
|
||||
)
|
||||
return audio_data, content_type
|
||||
except Exception as e:
|
||||
print(f"Error decoding base64 audio data: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def upload_audio(request, audio_data, content_type, metadata, user):
|
||||
audio_format = mimetypes.guess_extension(content_type)
|
||||
file = UploadFile(
|
||||
file=io.BytesIO(audio_data),
|
||||
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
|
||||
headers={
|
||||
"content-type": content_type,
|
||||
},
|
||||
)
|
||||
file_item = upload_file_handler(
|
||||
request,
|
||||
file=file,
|
||||
metadata=metadata,
|
||||
process=False,
|
||||
user=user,
|
||||
)
|
||||
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
|
||||
return url
|
||||
|
||||
|
||||
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
|
||||
if "data:audio/wav;base64" in base64_audio_string:
|
||||
audio_url = ""
|
||||
# Extract base64 audio data from the line
|
||||
audio_data, content_type = load_b64_audio_data(base64_audio_string)
|
||||
if audio_data is not None:
|
||||
audio_url = upload_audio(
|
||||
request,
|
||||
audio_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
return audio_url
|
||||
return None
|
||||
|
||||
|
||||
def get_file_url_from_base64(request, base64_file_string, metadata, user):
|
||||
if "data:image/png;base64" in base64_file_string:
|
||||
return get_image_url_from_base64(request, base64_file_string, metadata, user)
|
||||
elif "data:audio/wav;base64" in base64_file_string:
|
||||
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
||||
return None
|
||||
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self):
|
||||
self.session: Optional[ClientSession] = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
async def connect(self, url: str, headers: Optional[dict] = None):
|
||||
try:
|
||||
self._streams_context = streamablehttp_client(url, headers=headers)
|
||||
|
||||
transport = await self.exit_stack.enter_async_context(self._streams_context)
|
||||
read_stream, write_stream, _ = transport
|
||||
|
||||
self._session_context = ClientSession(
|
||||
read_stream, write_stream
|
||||
) # pylint: disable=W0201
|
||||
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
self._session_context
|
||||
)
|
||||
await self.session.initialize()
|
||||
except Exception as e:
|
||||
await self.disconnect()
|
||||
raise e
|
||||
|
||||
async def list_tool_specs(self) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.list_tools()
|
||||
tools = result.tools
|
||||
|
||||
tool_specs = []
|
||||
for tool in tools:
|
||||
name = tool.name
|
||||
description = tool.description
|
||||
|
||||
inputSchema = tool.inputSchema
|
||||
|
||||
# TODO: handle outputSchema if needed
|
||||
outputSchema = getattr(tool, "outputSchema", None)
|
||||
|
||||
tool_specs.append(
|
||||
{"name": name, "description": description, "parameters": inputSchema}
|
||||
)
|
||||
|
||||
return tool_specs
|
||||
|
||||
async def call_tool(
|
||||
self, function_name: str, function_args: dict
|
||||
) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.call_tool(function_name, function_args)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP tool call.")
|
||||
|
||||
result_dict = result.model_dump(mode="json")
|
||||
result_content = result_dict.get("content", {})
|
||||
|
||||
if result.isError:
|
||||
raise Exception(result_content)
|
||||
else:
|
||||
return result_content
|
||||
|
||||
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.list_resources(cursor=cursor)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP list_resources call.")
|
||||
|
||||
result_dict = result.model_dump()
|
||||
resources = result_dict.get("resources", [])
|
||||
|
||||
return resources
|
||||
|
||||
async def read_resource(self, uri: str) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.read_resource(uri)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP read_resource call.")
|
||||
result_dict = result.model_dump()
|
||||
|
||||
return result_dict
|
||||
|
||||
async def disconnect(self):
|
||||
# Clean up and close the session
|
||||
await self.exit_stack.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.exit_stack.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
|
||||
await self.disconnect()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,17 +120,26 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
|
||||
return get_system_message(messages), remove_system_message(messages)
|
||||
|
||||
|
||||
def prepend_to_first_user_message_content(
|
||||
content: str, messages: list[dict]
|
||||
) -> list[dict]:
|
||||
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
|
||||
if isinstance(message["content"], list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
if append:
|
||||
item["text"] = f"{item['text']}\n{content}"
|
||||
else:
|
||||
item["text"] = f"{content}\n{item['text']}"
|
||||
else:
|
||||
if append:
|
||||
message["content"] = f"{message['content']}\n{content}"
|
||||
else:
|
||||
message["content"] = f"{content}\n{message['content']}"
|
||||
return message
|
||||
|
||||
|
||||
def replace_system_message_content(content: str, messages: list[dict]) -> dict:
|
||||
for message in messages:
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
item["text"] = f"{content}\n{item['text']}"
|
||||
else:
|
||||
message["content"] = f"{content}\n{message['content']}"
|
||||
if message["role"] == "system":
|
||||
message["content"] = content
|
||||
break
|
||||
return messages
|
||||
|
||||
@@ -148,10 +157,7 @@ def add_or_update_system_message(
|
||||
"""
|
||||
|
||||
if messages and messages[0].get("role") == "system":
|
||||
if append:
|
||||
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
|
||||
else:
|
||||
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
|
||||
messages[0] = update_message_content(messages[0], content, append)
|
||||
else:
|
||||
# Insert at the beginning
|
||||
messages.insert(0, {"role": "system", "content": content})
|
||||
@@ -159,7 +165,7 @@ def add_or_update_system_message(
|
||||
return messages
|
||||
|
||||
|
||||
def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
def add_or_update_user_message(content: str, messages: list[dict], append: bool = True):
|
||||
"""
|
||||
Adds a new user message at the end of the messages list
|
||||
or updates the existing user message at the end.
|
||||
@@ -170,7 +176,7 @@ def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
"""
|
||||
|
||||
if messages and messages[-1].get("role") == "user":
|
||||
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
|
||||
messages[-1] = update_message_content(messages[-1], content, append)
|
||||
else:
|
||||
# Insert at the end
|
||||
messages.append({"role": "user", "content": content})
|
||||
@@ -178,6 +184,16 @@ def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
return messages
|
||||
|
||||
|
||||
def prepend_to_first_user_message_content(
|
||||
content: str, messages: list[dict]
|
||||
) -> list[dict]:
|
||||
for message in messages:
|
||||
if message["role"] == "user":
|
||||
message = update_message_content(message, content, append=False)
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
def append_or_update_assistant_message(content: str, messages: list[dict]):
|
||||
"""
|
||||
Adds a new assistant message at the end of the messages list
|
||||
@@ -383,17 +399,10 @@ def parse_ollama_modelfile(model_text):
|
||||
"top_k": int,
|
||||
"top_p": float,
|
||||
"num_keep": int,
|
||||
"typical_p": float,
|
||||
"presence_penalty": float,
|
||||
"frequency_penalty": float,
|
||||
"penalize_newline": bool,
|
||||
"numa": bool,
|
||||
"num_batch": int,
|
||||
"num_gpu": int,
|
||||
"main_gpu": int,
|
||||
"low_vram": bool,
|
||||
"f16_kv": bool,
|
||||
"vocab_only": bool,
|
||||
"use_mmap": bool,
|
||||
"use_mlock": bool,
|
||||
"num_thread": int,
|
||||
@@ -522,3 +531,11 @@ def throttle(interval: float = 10.0):
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def extract_urls(text: str) -> list[str]:
|
||||
# Regex pattern to match URLs
|
||||
url_pattern = re.compile(
|
||||
r"(https?://[^\s]+)", re.IGNORECASE
|
||||
) # Matches http and https URLs
|
||||
return url_pattern.findall(text)
|
||||
|
||||
@@ -263,6 +263,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
"icon": function.meta.manifest.get("icon_url", None)
|
||||
or getattr(module, "icon_url", None)
|
||||
or getattr(module, "icon", None),
|
||||
"has_user_valves": hasattr(module, "UserValves"),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import sys
|
||||
import urllib
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
@@ -9,6 +11,9 @@ from datetime import datetime, timedelta
|
||||
import re
|
||||
import fnmatch
|
||||
import time
|
||||
import secrets
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
import aiohttp
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
@@ -18,6 +23,7 @@ from fastapi import (
|
||||
status,
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from open_webui.models.auths import Auths
|
||||
@@ -56,11 +62,30 @@ from open_webui.env import (
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE,
|
||||
ENABLE_OAUTH_EMAIL_FALLBACK,
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
|
||||
)
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.auth import get_password_hash, create_token
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
)
|
||||
|
||||
|
||||
class OAuthClientInformationFull(OAuthClientMetadata):
|
||||
issuer: Optional[str] = None # URL of the OAuth server that issued this client
|
||||
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
client_id_issued_at: int | None = None
|
||||
client_secret_expires_at: int | None = None
|
||||
|
||||
server_metadata: Optional[OAuthMetadata] = None # Fetched from the OAuth server
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
@@ -89,6 +114,42 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||
|
||||
|
||||
FERNET = None
|
||||
|
||||
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
|
||||
key_bytes = hashlib.sha256(OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()).digest()
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = base64.urlsafe_b64encode(key_bytes)
|
||||
else:
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()
|
||||
|
||||
try:
|
||||
FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY)
|
||||
except Exception as e:
|
||||
log.error(f"Error initializing Fernet with provided key: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def encrypt_data(data) -> str:
|
||||
"""Encrypt data for storage"""
|
||||
try:
|
||||
data_json = json.dumps(data)
|
||||
encrypted = FERNET.encrypt(data_json.encode()).decode()
|
||||
return encrypted
|
||||
except Exception as e:
|
||||
log.error(f"Error encrypting data: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def decrypt_data(data: str):
|
||||
"""Decrypt data from storage"""
|
||||
try:
|
||||
decrypted = FERNET.decrypt(data.encode()).decode()
|
||||
return json.loads(decrypted)
|
||||
except Exception as e:
|
||||
log.error(f"Error decrypting data: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
|
||||
"""
|
||||
Check if a group name matches any blocked pattern.
|
||||
@@ -133,14 +194,463 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
|
||||
parsed = urllib.parse.urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return parsed, base_url
|
||||
|
||||
|
||||
def get_discovery_urls(server_url) -> list[str]:
|
||||
parsed, base_url = get_parsed_and_base_url(server_url)
|
||||
|
||||
urls = [
|
||||
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server"),
|
||||
urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"),
|
||||
]
|
||||
|
||||
if parsed.path and parsed.path != "/":
|
||||
urls.append(
|
||||
urllib.parse.urljoin(
|
||||
base_url,
|
||||
f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}",
|
||||
)
|
||||
)
|
||||
urls.append(
|
||||
urllib.parse.urljoin(
|
||||
base_url, f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
|
||||
)
|
||||
)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
|
||||
# This is not currently supported.
|
||||
async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
request,
|
||||
client_id: str,
|
||||
oauth_server_url: str,
|
||||
oauth_server_key: Optional[str] = None,
|
||||
) -> OAuthClientInformationFull:
|
||||
try:
|
||||
oauth_server_metadata = None
|
||||
oauth_server_metadata_url = None
|
||||
|
||||
redirect_base_url = (
|
||||
str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
).rstrip("/")
|
||||
|
||||
oauth_client_metadata = OAuthClientMetadata(
|
||||
client_name="Open WebUI",
|
||||
redirect_uris=[f"{redirect_base_url}/oauth/clients/{client_id}/callback"],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
)
|
||||
|
||||
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
|
||||
discovery_urls = get_discovery_urls(oauth_server_url)
|
||||
for url in discovery_urls:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as oauth_server_metadata_response:
|
||||
if oauth_server_metadata_response.status == 200:
|
||||
try:
|
||||
oauth_server_metadata = OAuthMetadata.model_validate(
|
||||
await oauth_server_metadata_response.json()
|
||||
)
|
||||
oauth_server_metadata_url = url
|
||||
if (
|
||||
oauth_client_metadata.scope is None
|
||||
and oauth_server_metadata.scopes_supported is not None
|
||||
):
|
||||
oauth_client_metadata.scope = " ".join(
|
||||
oauth_server_metadata.scopes_supported
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Error parsing OAuth metadata from {url}: {e}")
|
||||
continue
|
||||
|
||||
registration_url = None
|
||||
if oauth_server_metadata and oauth_server_metadata.registration_endpoint:
|
||||
registration_url = str(oauth_server_metadata.registration_endpoint)
|
||||
else:
|
||||
_, base_url = get_parsed_and_base_url(oauth_server_url)
|
||||
registration_url = urllib.parse.urljoin(base_url, "/register")
|
||||
|
||||
registration_data = oauth_client_metadata.model_dump(
|
||||
exclude_none=True,
|
||||
mode="json",
|
||||
by_alias=True,
|
||||
)
|
||||
|
||||
# Perform dynamic client registration and return client info
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as oauth_client_registration_response:
|
||||
try:
|
||||
registration_response_json = (
|
||||
await oauth_client_registration_response.json()
|
||||
)
|
||||
oauth_client_info = OAuthClientInformationFull.model_validate(
|
||||
{
|
||||
**registration_response_json,
|
||||
**{"issuer": oauth_server_metadata_url},
|
||||
**{"server_metadata": oauth_server_metadata},
|
||||
}
|
||||
)
|
||||
log.info(
|
||||
f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}"
|
||||
)
|
||||
return oauth_client_info
|
||||
except Exception as e:
|
||||
error_text = None
|
||||
try:
|
||||
error_text = await oauth_client_registration_response.text()
|
||||
log.error(
|
||||
f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}"
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
log.error(f"Error parsing client registration response: {e}")
|
||||
raise Exception(
|
||||
f"Dynamic client registration failed: {error_text}"
|
||||
if error_text
|
||||
else "Error parsing client registration response"
|
||||
)
|
||||
raise Exception("Dynamic client registration failed")
|
||||
except Exception as e:
|
||||
log.error(f"Exception during dynamic client registration: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
class OAuthClientManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
self.app = app
|
||||
self.clients = {}
|
||||
|
||||
def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull):
|
||||
kwargs = {
|
||||
"name": client_id,
|
||||
"client_id": oauth_client_info.client_id,
|
||||
"client_secret": oauth_client_info.client_secret,
|
||||
"client_kwargs": (
|
||||
{"scope": oauth_client_info.scope} if oauth_client_info.scope else {}
|
||||
),
|
||||
"server_metadata_url": (
|
||||
oauth_client_info.issuer if oauth_client_info.issuer else None
|
||||
),
|
||||
}
|
||||
|
||||
if (
|
||||
oauth_client_info.server_metadata
|
||||
and oauth_client_info.server_metadata.code_challenge_methods_supported
|
||||
):
|
||||
if (
|
||||
isinstance(
|
||||
oauth_client_info.server_metadata.code_challenge_methods_supported,
|
||||
list,
|
||||
)
|
||||
and "S256"
|
||||
in oauth_client_info.server_metadata.code_challenge_methods_supported
|
||||
):
|
||||
kwargs["code_challenge_method"] = "S256"
|
||||
|
||||
self.clients[client_id] = {
|
||||
"client": self.oauth.register(**kwargs),
|
||||
"client_info": oauth_client_info,
|
||||
}
|
||||
return self.clients[client_id]
|
||||
|
||||
def remove_client(self, client_id):
|
||||
if client_id in self.clients:
|
||||
del self.clients[client_id]
|
||||
log.info(f"Removed OAuth client {client_id}")
|
||||
return True
|
||||
|
||||
def get_client(self, client_id):
|
||||
client = self.clients.get(client_id)
|
||||
return client["client"] if client else None
|
||||
|
||||
def get_client_info(self, client_id):
|
||||
client = self.clients.get(client_id)
|
||||
return client["client_info"] if client else None
|
||||
|
||||
def get_server_metadata_url(self, client_id):
|
||||
if client_id in self.clients:
|
||||
client = self.clients[client_id]
|
||||
return (
|
||||
client._server_metadata_url
|
||||
if hasattr(client, "_server_metadata_url")
|
||||
else None
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_oauth_token(
|
||||
self, user_id: str, client_id: str, force_refresh: bool = False
|
||||
):
|
||||
"""
|
||||
Get a valid OAuth token for the user, automatically refreshing if needed.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
client_id: The OAuth client ID (provider)
|
||||
force_refresh: Force token refresh even if current token appears valid
|
||||
|
||||
Returns:
|
||||
dict: OAuth token data with access_token, or None if no valid token available
|
||||
"""
|
||||
try:
|
||||
# Get the OAuth session
|
||||
session = OAuthSessions.get_session_by_provider_and_user_id(
|
||||
client_id, user_id
|
||||
)
|
||||
if not session:
|
||||
log.warning(
|
||||
f"No OAuth session found for user {user_id}, client_id {client_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
if force_refresh or datetime.now() + timedelta(
|
||||
minutes=5
|
||||
) >= datetime.fromtimestamp(session.expires_at):
|
||||
log.debug(
|
||||
f"Token refresh needed for user {user_id}, client_id {session.provider}"
|
||||
)
|
||||
refreshed_token = await self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
return refreshed_token
|
||||
else:
|
||||
log.warning(
|
||||
f"Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}"
|
||||
)
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
return None
|
||||
return session.token
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _refresh_token(self, session) -> dict:
|
||||
"""
|
||||
Refresh an OAuth token if needed, with concurrency protection.
|
||||
|
||||
Args:
|
||||
session: The OAuth session object
|
||||
|
||||
Returns:
|
||||
dict: Refreshed token data, or None if refresh failed
|
||||
"""
|
||||
try:
|
||||
# Perform the actual refresh
|
||||
refreshed_token = await self._perform_token_refresh(session)
|
||||
|
||||
if refreshed_token:
|
||||
# Update the session with new token data
|
||||
session = OAuthSessions.update_session_by_id(
|
||||
session.id, refreshed_token
|
||||
)
|
||||
log.info(f"Successfully refreshed token for session {session.id}")
|
||||
return session.token
|
||||
else:
|
||||
log.error(f"Failed to refresh token for session {session.id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error refreshing token for session {session.id}: {e}")
|
||||
return None
|
||||
|
||||
async def _perform_token_refresh(self, session) -> dict:
|
||||
"""
|
||||
Perform the actual OAuth token refresh.
|
||||
|
||||
Args:
|
||||
session: The OAuth session object
|
||||
|
||||
Returns:
|
||||
dict: New token data, or None if refresh failed
|
||||
"""
|
||||
client_id = session.provider
|
||||
token_data = session.token
|
||||
|
||||
if not token_data.get("refresh_token"):
|
||||
log.warning(f"No refresh token available for session {session.id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
client = self.get_client(client_id)
|
||||
if not client:
|
||||
log.error(f"No OAuth client found for provider {client_id}")
|
||||
return None
|
||||
|
||||
token_endpoint = None
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.get(
|
||||
self.get_server_metadata_url(client_id)
|
||||
) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
token_endpoint = openid_data.get("token_endpoint")
|
||||
else:
|
||||
log.error(
|
||||
f"Failed to fetch OpenID configuration for client_id {client_id}"
|
||||
)
|
||||
if not token_endpoint:
|
||||
log.error(f"No token endpoint found for client_id {client_id}")
|
||||
return None
|
||||
|
||||
# Prepare refresh request
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"client_id": client.client_id,
|
||||
}
|
||||
if hasattr(client, "client_secret") and client.client_secret:
|
||||
refresh_data["client_secret"] = client.client_secret
|
||||
|
||||
# Make refresh request
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.post(
|
||||
token_endpoint,
|
||||
data=refresh_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status == 200:
|
||||
new_token_data = await r.json()
|
||||
|
||||
# Merge with existing token data (preserve refresh_token if not provided)
|
||||
if "refresh_token" not in new_token_data:
|
||||
new_token_data["refresh_token"] = token_data[
|
||||
"refresh_token"
|
||||
]
|
||||
|
||||
# Add timestamp for tracking
|
||||
new_token_data["issued_at"] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if (
|
||||
"expires_in" in new_token_data
|
||||
and "expires_at" not in new_token_data
|
||||
):
|
||||
new_token_data["expires_at"] = int(
|
||||
datetime.now().timestamp()
|
||||
+ new_token_data["expires_in"]
|
||||
)
|
||||
|
||||
log.debug(f"Token refresh successful for client_id {client_id}")
|
||||
return new_token_data
|
||||
else:
|
||||
error_text = await r.text()
|
||||
log.error(
|
||||
f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Exception during token refresh for client_id {client_id}: {e}")
|
||||
return None
|
||||
|
||||
async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
|
||||
client = self.get_client(client_id)
|
||||
if client is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
client_info = self.get_client_info(client_id)
|
||||
if client_info is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
redirect_uri = (
|
||||
client_info.redirect_uris[0] if client_info.redirect_uris else None
|
||||
)
|
||||
return await client.authorize_redirect(request, str(redirect_uri))
|
||||
|
||||
async def handle_callback(self, request, client_id: str, user_id: str, response):
|
||||
client = self.get_client(client_id)
|
||||
if client is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
error_message = None
|
||||
try:
|
||||
client_info = self.get_client_info(client_id)
|
||||
token_params = {}
|
||||
if (
|
||||
client_info
|
||||
and hasattr(client_info, "client_id")
|
||||
and hasattr(client_info, "client_secret")
|
||||
):
|
||||
token_params["client_id"] = client_info.client_id
|
||||
token_params["client_secret"] = client_info.client_secret
|
||||
|
||||
token = await client.authorize_access_token(request, **token_params)
|
||||
if token:
|
||||
try:
|
||||
# Add timestamp for tracking
|
||||
token["issued_at"] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if "expires_in" in token and "expires_at" not in token:
|
||||
token["expires_at"] = (
|
||||
datetime.now().timestamp() + token["expires_in"]
|
||||
)
|
||||
|
||||
# Clean up any existing sessions for this user/client_id first
|
||||
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
|
||||
for session in sessions:
|
||||
if session.provider == client_id:
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
|
||||
session = OAuthSessions.create_session(
|
||||
user_id=user_id,
|
||||
provider=client_id,
|
||||
token=token,
|
||||
)
|
||||
log.info(
|
||||
f"Stored OAuth session server-side for user {user_id}, client_id {client_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = "Failed to store OAuth session server-side"
|
||||
log.error(f"Failed to store OAuth session server-side: {e}")
|
||||
else:
|
||||
error_message = "Failed to obtain OAuth token"
|
||||
log.warning(error_message)
|
||||
except Exception as e:
|
||||
error_message = "OAuth callback error"
|
||||
log.warning(f"OAuth callback error: {e}")
|
||||
|
||||
redirect_url = (
|
||||
str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
).rstrip("/")
|
||||
|
||||
if error_message:
|
||||
log.debug(error_message)
|
||||
redirect_url = f"{redirect_url}/?error={error_message}"
|
||||
return RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
|
||||
response = RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
return response
|
||||
|
||||
|
||||
class OAuthManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
self.app = app
|
||||
|
||||
self._clients = {}
|
||||
for _, provider_config in OAUTH_PROVIDERS.items():
|
||||
provider_config["register"](self.oauth)
|
||||
|
||||
for name, provider_config in OAUTH_PROVIDERS.items():
|
||||
if "register" not in provider_config:
|
||||
log.error(f"OAuth provider {name} missing register function")
|
||||
continue
|
||||
|
||||
client = provider_config["register"](self.oauth)
|
||||
self._clients[name] = client
|
||||
|
||||
def get_client(self, provider_name):
|
||||
if provider_name not in self._clients:
|
||||
@@ -151,13 +661,13 @@ class OAuthManager:
|
||||
if provider_name in self._clients:
|
||||
client = self._clients[provider_name]
|
||||
return (
|
||||
client.server_metadata_url
|
||||
if hasattr(client, "server_metadata_url")
|
||||
client._server_metadata_url
|
||||
if hasattr(client, "_server_metadata_url")
|
||||
else None
|
||||
)
|
||||
return None
|
||||
|
||||
def get_oauth_token(
|
||||
async def get_oauth_token(
|
||||
self, user_id: str, session_id: str, force_refresh: bool = False
|
||||
):
|
||||
"""
|
||||
@@ -186,13 +696,15 @@ class OAuthManager:
|
||||
log.debug(
|
||||
f"Token refresh needed for user {user_id}, provider {session.provider}"
|
||||
)
|
||||
refreshed_token = self._refresh_token(session)
|
||||
refreshed_token = await self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
return refreshed_token
|
||||
else:
|
||||
log.warning(
|
||||
f"Token refresh failed for user {user_id}, provider {session.provider}"
|
||||
f"Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}"
|
||||
)
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
|
||||
return None
|
||||
return session.token
|
||||
|
||||
@@ -252,9 +764,10 @@ class OAuthManager:
|
||||
log.error(f"No OAuth client found for provider {provider}")
|
||||
return None
|
||||
|
||||
server_metadata_url = self.get_server_metadata_url(provider)
|
||||
token_endpoint = None
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.get(client.gserver_metadata_url) as r:
|
||||
async with session_http.get(server_metadata_url) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
token_endpoint = openid_data.get("token_endpoint")
|
||||
@@ -301,7 +814,7 @@ class OAuthManager:
|
||||
"expires_in" in new_token_data
|
||||
and "expires_at" not in new_token_data
|
||||
):
|
||||
new_token_data["expires_at"] = (
|
||||
new_token_data["expires_at"] = int(
|
||||
datetime.now().timestamp()
|
||||
+ new_token_data["expires_in"]
|
||||
)
|
||||
@@ -574,7 +1087,7 @@ class OAuthManager:
|
||||
raise HTTPException(404)
|
||||
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
|
||||
redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for(
|
||||
"oauth_callback", provider=provider
|
||||
"oauth_login_callback", provider=provider
|
||||
)
|
||||
client = self.get_client(provider)
|
||||
if client is None:
|
||||
@@ -668,6 +1181,8 @@ class OAuthManager:
|
||||
except Exception as e:
|
||||
log.warning(f"Error fetching GitHub email: {e}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
elif ENABLE_OAUTH_EMAIL_FALLBACK:
|
||||
email = f"{provider_sub}.local"
|
||||
else:
|
||||
log.warning(f"OAuth callback failed, email is missing: {user_data}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
@@ -791,9 +1306,9 @@ class OAuthManager:
|
||||
else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
|
||||
)
|
||||
|
||||
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
if redirect_base_url.endswith("/"):
|
||||
redirect_base_url = redirect_base_url[:-1]
|
||||
redirect_base_url = (
|
||||
str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
).rstrip("/")
|
||||
redirect_url = f"{redirect_base_url}/auth"
|
||||
|
||||
if error_message:
|
||||
|
||||
@@ -2,6 +2,7 @@ from open_webui.utils.task import prompt_template, prompt_variables_template
|
||||
from open_webui.utils.misc import (
|
||||
deep_update,
|
||||
add_or_update_system_message,
|
||||
replace_system_message_content,
|
||||
)
|
||||
|
||||
from typing import Callable, Optional
|
||||
@@ -10,7 +11,11 @@ import json
|
||||
|
||||
# inplace function: form_data is modified
|
||||
def apply_system_prompt_to_body(
|
||||
system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None
|
||||
system: Optional[str],
|
||||
form_data: dict,
|
||||
metadata: Optional[dict] = None,
|
||||
user=None,
|
||||
replace: bool = False,
|
||||
) -> dict:
|
||||
if not system:
|
||||
return form_data
|
||||
@@ -24,9 +29,15 @@ def apply_system_prompt_to_body(
|
||||
# Legacy (API Usage)
|
||||
system = prompt_template(system, user)
|
||||
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
system, form_data.get("messages", [])
|
||||
)
|
||||
if replace:
|
||||
form_data["messages"] = replace_system_message_content(
|
||||
system, form_data.get("messages", [])
|
||||
)
|
||||
else:
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
system, form_data.get("messages", [])
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
@@ -153,17 +164,11 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
|
||||
"repeat_last_n": int,
|
||||
"top_k": int,
|
||||
"min_p": float,
|
||||
"typical_p": float,
|
||||
"repeat_penalty": float,
|
||||
"presence_penalty": float,
|
||||
"frequency_penalty": float,
|
||||
"penalize_newline": bool,
|
||||
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
|
||||
"numa": bool,
|
||||
"num_gpu": int,
|
||||
"main_gpu": int,
|
||||
"low_vram": bool,
|
||||
"vocab_only": bool,
|
||||
"use_mmap": bool,
|
||||
"use_mlock": bool,
|
||||
"num_thread": int,
|
||||
|
||||
@@ -166,6 +166,48 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
||||
if load_from_db:
|
||||
# Always load from the database by default
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
if not tool:
|
||||
raise Exception(f"Tool not found: {tool_id}")
|
||||
content = tool.content
|
||||
|
||||
new_content = replace_imports(content)
|
||||
if new_content != content:
|
||||
content = new_content
|
||||
# Update the tool content in the database
|
||||
Tools.update_tool_by_id(tool_id, {"content": content})
|
||||
|
||||
if (
|
||||
hasattr(request.app.state, "TOOL_CONTENTS")
|
||||
and tool_id in request.app.state.TOOL_CONTENTS
|
||||
) and (
|
||||
hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
|
||||
):
|
||||
if request.app.state.TOOL_CONTENTS[tool_id] == content:
|
||||
return request.app.state.TOOLS[tool_id], None
|
||||
|
||||
tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
|
||||
else:
|
||||
if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
|
||||
return request.app.state.TOOLS[tool_id], None
|
||||
|
||||
tool_module, frontmatter = load_tool_module_by_id(tool_id)
|
||||
|
||||
if not hasattr(request.app.state, "TOOLS"):
|
||||
request.app.state.TOOLS = {}
|
||||
|
||||
if not hasattr(request.app.state, "TOOL_CONTENTS"):
|
||||
request.app.state.TOOL_CONTENTS = {}
|
||||
|
||||
request.app.state.TOOLS[tool_id] = tool_module
|
||||
request.app.state.TOOL_CONTENTS[tool_id] = content
|
||||
|
||||
return tool_module, frontmatter
|
||||
|
||||
|
||||
def get_function_module_from_cache(request, function_id, load_from_db=True):
|
||||
if load_from_db:
|
||||
# Always load from the database by default
|
||||
|
||||
+156
-109
@@ -96,92 +96,117 @@ async def get_tools(
|
||||
for tool_id in tool_ids:
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
|
||||
if tool_id.startswith("server:"):
|
||||
server_id = tool_id.split(":")[1]
|
||||
splits = tool_id.split(":")
|
||||
|
||||
tool_server_data = None
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
if len(splits) == 2:
|
||||
type = "openapi"
|
||||
server_id = splits[1]
|
||||
elif len(splits) == 3:
|
||||
type = splits[1]
|
||||
server_id = splits[2]
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
server_id_splits = server_id.split("|")
|
||||
if len(server_id_splits) == 2:
|
||||
server_id = server_id_splits[0]
|
||||
function_names = server_id_splits[1].split(",")
|
||||
|
||||
if type == "openapi":
|
||||
|
||||
tool_server_data = None
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
tool_server_idx
|
||||
]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
|
||||
cookies = {}
|
||||
headers = {}
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {tool_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
def make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
url=tool_server_data["url"],
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
tool_function,
|
||||
{},
|
||||
)
|
||||
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
# Misc info
|
||||
"type": "external",
|
||||
}
|
||||
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
|
||||
cookies = {}
|
||||
headers = {}
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {tool_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
def make_tool_function(function_name, tool_server_data, headers):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
url=tool_server_data["url"],
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
tool_function,
|
||||
{},
|
||||
)
|
||||
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
@@ -563,25 +588,20 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
||||
error = str(err)
|
||||
raise Exception(error)
|
||||
|
||||
data = {
|
||||
"openapi": res,
|
||||
"info": res.get("info", {}),
|
||||
"specs": convert_openapi_to_tool_payload(res),
|
||||
}
|
||||
|
||||
log.info(f"Fetched data: {data}")
|
||||
return data
|
||||
log.debug(f"Fetched data: {res}")
|
||||
return res
|
||||
|
||||
|
||||
async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
# Prepare list of enabled servers along with their original index
|
||||
|
||||
tasks = []
|
||||
server_entries = []
|
||||
for idx, server in enumerate(servers):
|
||||
if server.get("config", {}).get("enable"):
|
||||
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
|
||||
openapi_path = server.get("path", "openapi.json")
|
||||
full_url = get_tool_server_url(server.get("url"), openapi_path)
|
||||
|
||||
if (
|
||||
server.get("config", {}).get("enable")
|
||||
and server.get("type", "openapi") == "openapi"
|
||||
):
|
||||
info = server.get("info", {})
|
||||
|
||||
auth_type = server.get("auth_type", "bearer")
|
||||
@@ -597,12 +617,34 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
if not id:
|
||||
id = str(idx)
|
||||
|
||||
server_entries.append((id, idx, server, full_url, info, token))
|
||||
server_url = server.get("url")
|
||||
spec_type = server.get("spec_type", "url")
|
||||
|
||||
# Create async tasks to fetch data
|
||||
tasks = [
|
||||
get_tool_server_data(token, url) for (_, _, _, url, _, token) in server_entries
|
||||
]
|
||||
# Create async tasks to fetch data
|
||||
task = None
|
||||
if spec_type == "url":
|
||||
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
|
||||
openapi_path = server.get("path", "openapi.json")
|
||||
spec_url = get_tool_server_url(server_url, openapi_path)
|
||||
# Fetch from URL
|
||||
task = get_tool_server_data(token, spec_url)
|
||||
elif spec_type == "json" and server.get("spec", ""):
|
||||
# Use provided JSON spec
|
||||
spec_json = None
|
||||
try:
|
||||
spec_json = json.loads(server.get("spec", ""))
|
||||
except Exception as e:
|
||||
log.error(f"Error parsing JSON spec for tool server {id}: {e}")
|
||||
|
||||
if spec_json:
|
||||
task = asyncio.sleep(
|
||||
0,
|
||||
result=spec_json,
|
||||
)
|
||||
|
||||
if task:
|
||||
tasks.append(task)
|
||||
server_entries.append((id, idx, server, server_url, info, token))
|
||||
|
||||
# Execute tasks concurrently
|
||||
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -614,8 +656,13 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
log.error(f"Failed to connect to {url} OpenAPI tool server")
|
||||
continue
|
||||
|
||||
openapi_data = response.get("openapi", {})
|
||||
response = {
|
||||
"openapi": response,
|
||||
"info": response.get("info", {}),
|
||||
"specs": convert_openapi_to_tool_payload(response),
|
||||
}
|
||||
|
||||
openapi_data = response.get("openapi", {})
|
||||
if info and isinstance(openapi_data, dict):
|
||||
openapi_data["info"] = openapi_data.get("info", {})
|
||||
|
||||
@@ -646,7 +693,7 @@ async def execute_tool_server(
|
||||
name: str,
|
||||
params: Dict[str, Any],
|
||||
server_data: Dict[str, Any],
|
||||
) -> Any:
|
||||
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||
error = None
|
||||
try:
|
||||
openapi = server_data.get("openapi", {})
|
||||
@@ -701,23 +748,20 @@ async def execute_tool_server(
|
||||
if operation.get("requestBody", {}).get("content"):
|
||||
if params:
|
||||
body_params = params
|
||||
else:
|
||||
raise Exception(
|
||||
f"Request body expected for operation '{name}' but none found."
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
request_method = getattr(session, http_method.lower())
|
||||
|
||||
if http_method in ["post", "put", "patch"]:
|
||||
if http_method in ["post", "put", "patch", "delete"]:
|
||||
async with request_method(
|
||||
final_url,
|
||||
json=body_params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
@@ -728,13 +772,15 @@ async def execute_tool_server(
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
else:
|
||||
async with request_method(
|
||||
final_url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
@@ -745,12 +791,13 @@ async def execute_tool_server(
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
|
||||
except Exception as err:
|
||||
error = str(err)
|
||||
log.exception(f"API Request Error: {error}")
|
||||
return {"error": error}
|
||||
return ({"error": error}, None)
|
||||
|
||||
|
||||
def get_tool_server_url(url: Optional[str], path: str) -> str:
|
||||
|
||||
+59
-57
@@ -1,73 +1,62 @@
|
||||
fastapi==0.115.7
|
||||
uvicorn[standard]==0.35.0
|
||||
pydantic==2.11.7
|
||||
fastapi==0.118.0
|
||||
uvicorn[standard]==0.37.0
|
||||
pydantic==2.11.9
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.13.0
|
||||
python-jose==3.4.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
cryptography
|
||||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.5
|
||||
|
||||
requests==2.32.4
|
||||
requests==2.32.5
|
||||
aiohttp==3.12.15
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.6.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
sqlalchemy==2.0.38
|
||||
alembic==1.14.0
|
||||
peewee==3.18.1
|
||||
peewee-migrate==1.12.2
|
||||
psycopg2-binary==2.9.10
|
||||
pgvector==0.4.1
|
||||
PyMySQL==1.1.1
|
||||
bcrypt==4.3.0
|
||||
|
||||
pymongo
|
||||
redis
|
||||
boto3==1.40.5
|
||||
|
||||
argon2-cffi==25.1.0
|
||||
APScheduler==3.10.4
|
||||
|
||||
pycrdt==0.12.25
|
||||
redis
|
||||
|
||||
APScheduler==3.10.4
|
||||
RestrictedPython==8.0
|
||||
|
||||
loguru==0.7.3
|
||||
asgiref==3.8.1
|
||||
|
||||
# AI libraries
|
||||
tiktoken
|
||||
mcp==1.14.1
|
||||
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.32.0
|
||||
google-genai==1.38.0
|
||||
google-generativeai==0.8.5
|
||||
tiktoken
|
||||
|
||||
langchain==0.3.26
|
||||
langchain-community==0.3.27
|
||||
langchain==0.3.27
|
||||
langchain-community==0.3.29
|
||||
|
||||
fake-useragent==2.2.0
|
||||
chromadb==1.0.20
|
||||
pymilvus==2.5.0
|
||||
qdrant-client==1.14.3
|
||||
chromadb==1.1.0
|
||||
opensearch-py==2.8.0
|
||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||
elasticsearch==9.1.0
|
||||
pinecone==6.0.2
|
||||
oracledb==3.2.0
|
||||
|
||||
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
||||
transformers
|
||||
sentence-transformers==4.1.0
|
||||
sentence-transformers==5.1.1
|
||||
accelerate
|
||||
colbert-ai==0.2.21
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.1
|
||||
|
||||
|
||||
ftfy==6.2.3
|
||||
pypdf==6.0.0
|
||||
fpdf2==2.8.2
|
||||
@@ -76,7 +65,7 @@ docx2txt==0.8
|
||||
python-pptx==1.0.2
|
||||
unstructured==0.16.17
|
||||
nltk==3.9.1
|
||||
Markdown==3.8.2
|
||||
Markdown==3.9
|
||||
pypandoc==1.15
|
||||
pandas==2.2.3
|
||||
openpyxl==3.1.5
|
||||
@@ -94,13 +83,10 @@ rapidocr-onnxruntime==1.4.4
|
||||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.20.1
|
||||
|
||||
faster-whisper==1.1.1
|
||||
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.3
|
||||
|
||||
black==25.1.0
|
||||
black==25.9.0
|
||||
youtube-transcript-api==1.2.2
|
||||
pytube==15.0.0
|
||||
|
||||
@@ -112,36 +98,52 @@ google-api-python-client
|
||||
google-auth-httplib2
|
||||
google-auth-oauthlib
|
||||
|
||||
googleapis-common-protos==1.70.0
|
||||
google-cloud-storage==2.19.0
|
||||
|
||||
azure-identity==1.25.0
|
||||
azure-storage-blob==12.24.1
|
||||
|
||||
|
||||
pymongo
|
||||
psycopg2-binary==2.9.10
|
||||
pgvector==0.4.1
|
||||
|
||||
PyMySQL==1.1.1
|
||||
boto3==1.40.5
|
||||
|
||||
pymilvus==2.6.2
|
||||
qdrant-client==1.14.3
|
||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||
elasticsearch==9.1.0
|
||||
pinecone==6.0.2
|
||||
oracledb==3.2.0
|
||||
|
||||
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
||||
|
||||
colbert-ai==0.2.21
|
||||
|
||||
|
||||
## Tests
|
||||
docker~=7.1.0
|
||||
pytest~=8.4.1
|
||||
pytest-docker~=3.1.1
|
||||
|
||||
googleapis-common-protos==1.70.0
|
||||
google-cloud-storage==2.19.0
|
||||
|
||||
azure-identity==1.23.0
|
||||
azure-storage-blob==12.24.1
|
||||
|
||||
|
||||
## LDAP
|
||||
ldap3==2.9.1
|
||||
|
||||
## Firecrawl
|
||||
firecrawl-py==1.12.0
|
||||
|
||||
# Sougou API SDK(Tencentcloud SDK)
|
||||
tencentcloud-sdk-python==3.0.1336
|
||||
|
||||
## Trace
|
||||
opentelemetry-api==1.36.0
|
||||
opentelemetry-sdk==1.36.0
|
||||
opentelemetry-exporter-otlp==1.36.0
|
||||
opentelemetry-instrumentation==0.57b0
|
||||
opentelemetry-instrumentation-fastapi==0.57b0
|
||||
opentelemetry-instrumentation-sqlalchemy==0.57b0
|
||||
opentelemetry-instrumentation-redis==0.57b0
|
||||
opentelemetry-instrumentation-requests==0.57b0
|
||||
opentelemetry-instrumentation-logging==0.57b0
|
||||
opentelemetry-instrumentation-httpx==0.57b0
|
||||
opentelemetry-instrumentation-aiohttp-client==0.57b0
|
||||
opentelemetry-api==1.37.0
|
||||
opentelemetry-sdk==1.37.0
|
||||
opentelemetry-exporter-otlp==1.37.0
|
||||
opentelemetry-instrumentation==0.58b0
|
||||
opentelemetry-instrumentation-fastapi==0.58b0
|
||||
opentelemetry-instrumentation-sqlalchemy==0.58b0
|
||||
opentelemetry-instrumentation-redis==0.58b0
|
||||
opentelemetry-instrumentation-requests==0.58b0
|
||||
opentelemetry-instrumentation-logging==0.58b0
|
||||
opentelemetry-instrumentation-httpx==0.58b0
|
||||
opentelemetry-instrumentation-aiohttp-client==0.58b0
|
||||
|
||||
+14
-1
@@ -70,5 +70,18 @@ if [ -n "$SPACE_ID" ]; then
|
||||
fi
|
||||
|
||||
PYTHON_CMD=$(command -v python3 || command -v python)
|
||||
UVICORN_WORKERS="${UVICORN_WORKERS:-1}"
|
||||
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}"
|
||||
# If script is called with arguments, use them; otherwise use default workers
|
||||
if [ "$#" -gt 0 ]; then
|
||||
ARGS=("$@")
|
||||
else
|
||||
ARGS=(--workers "$UVICORN_WORKERS")
|
||||
fi
|
||||
|
||||
# Run uvicorn
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \
|
||||
--host "$HOST" \
|
||||
--port "$PORT" \
|
||||
--forwarded-allow-ips '*' \
|
||||
"${ARGS[@]}"
|
||||
+89
-15
@@ -4,10 +4,11 @@ Our primary goal is to ensure the protection and confidentiality of sensitive da
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| main | :white_check_mark: |
|
||||
| others | :x: |
|
||||
| Version (Branch) | Supported |
|
||||
| ---------------- | ------------------ |
|
||||
| main | :white_check_mark: |
|
||||
| dev | :x: |
|
||||
| others | :x: |
|
||||
|
||||
## Zero Tolerance for External Platforms
|
||||
|
||||
@@ -17,28 +18,101 @@ Any reports or solicitations arriving from sources other than our designated Git
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. To ensure that submissions are constructive and actionable, please adhere to the following guidelines:
|
||||
|
||||
Reports not submitted through our designated GitHub repository will be disregarded, and we will categorically reject invitations to collaborate on external platforms. Our aggressive stance on this matter underscores our commitment to a secure, transparent, and open community where all operations are visible and contributors are accountable.
|
||||
|
||||
1. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
|
||||
We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. Ensure that **submissions are constructive, actionable, reproducible, well documented and adhere to the following guidelines**:
|
||||
|
||||
2. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts.
|
||||
1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**.
|
||||
|
||||
3. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence will be disregarded.
|
||||
2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
|
||||
|
||||
4. **Required Patch Submission**: Along with the PoC, reporters must provide a patch or actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly.
|
||||
3. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts.
|
||||
|
||||
5. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider them for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security.
|
||||
4. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence may be disregarded.
|
||||
|
||||
**Non-compliant submissions will be closed, and repeat violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
|
||||
> [!NOTE]
|
||||
> A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show:
|
||||
>
|
||||
> 1. What security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
|
||||
> 2. How this vulnerability was abused
|
||||
> 3. What actions the attacker can now perform
|
||||
>
|
||||
> **Examples of valid PoCs:**
|
||||
>
|
||||
> - Step-by-step reproduction instructions with exact commands
|
||||
> - Complete exploit code with detailed execution instructions
|
||||
> - Screenshots/videos demonstrating the exploit (supplementary to written steps)
|
||||
>
|
||||
> **Failure to provide a reproducible PoC may lead to closure of the report**
|
||||
> We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC
|
||||
> However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed
|
||||
|
||||
## Product Security
|
||||
5. **Required Patch or Actionable Remediation Plan Submission**: Along with the PoC, reporters must provide a patch or some actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly.
|
||||
|
||||
6. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider provided patches for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security.
|
||||
|
||||
7. **Default Configuration Testing**: All vulnerability reports MUST be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception:
|
||||
|
||||
> [!NOTE]
|
||||
> **Note**: If you believe you have found a security issue that
|
||||
>
|
||||
> 1. affects default configurations **or**
|
||||
> 2. represents a genuine bypass of intended security controls **or**
|
||||
> 3. works only with non-default configurations **but the configuration in question is likely to be used by production deployments** > **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research.
|
||||
|
||||
8. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection.
|
||||
|
||||
9. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection.
|
||||
|
||||
> [!WARNING] > **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report.
|
||||
|
||||
11. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.**
|
||||
|
||||
12. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it.
|
||||
|
||||
> [!NOTE]
|
||||
> AI-aided vulnerability reports **will not be rejected by us by default.** But:
|
||||
>
|
||||
> - If we suspect you used AI (but you did not disclose it to us), we will be asking tough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself.
|
||||
> - If we suspect you used AI (but you did not disclose it to us) **and** your report ends up being invalid/not a vulnerability/not reproducible, then you **may be banned** from reporting future vulnerabilities.
|
||||
>
|
||||
> This measure was necessary due to the extreme rise in clearly AI written vulnerability reports, where the vast majority of them
|
||||
>
|
||||
> - were not a vulnerability
|
||||
> - were faulty configurations rather than a real vulnerability
|
||||
> - did not provide a PoC
|
||||
> - violated any of the rules outlined here
|
||||
> - had a clear lack of understanding of Open WebUI
|
||||
> - wrote comments with conflicting information
|
||||
> - used illogical arguments
|
||||
|
||||
**Non-compliant submissions will be closed, and repeat extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
|
||||
|
||||
If you want to report a vulnerability and can meet the outlined requirements, [open a vulnerability report here](https://github.com/open-webui/open-webui/security/advisories/new).
|
||||
|
||||
## Product Security And For Non-Vulnerability Security Concerns:
|
||||
|
||||
If your concern does not meet the vulnerability requirements outlined above, such as:
|
||||
|
||||
- Suggestions for better default configuration values
|
||||
- Security hardening recommendations
|
||||
- Deployment best practices guidance
|
||||
- Unclear configuration instructions
|
||||
- Need for additional security documentation
|
||||
- Feature requests for optional security enhancements (2FA, audit logging, etc.)
|
||||
- General security questions about production deployment
|
||||
|
||||
**then use one of the following channels instead:**
|
||||
|
||||
- **Documentation issues/improvement ideas:** Open an issue on our [Documentation Repository](https://github.com/open-webui/docs)
|
||||
- **Feature requests:** Create a discussion in [GitHub Discussions - Ideas](https://github.com/open-webui/open-webui/discussions/) to discuss with the community if this feature request is wanted by multiple people
|
||||
- **Configuration help:** Ask the community for help and guidance on our [Discord Server](https://discord.gg/5rJgQTnV4s) or on [Reddit](https://www.reddit.com/r/OpenWebUI/)
|
||||
- **General issues:** Use our [Issue Tracker](https://github.com/open-webui/open-webui/issues)
|
||||
|
||||
We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon.
|
||||
|
||||
For immediate concerns or detailed reports that meet our guidelines, please create an issue in our [issue tracker](/open-webui/open-webui/issues) or contact us on [Discord](https://discord.gg/5rJgQTnV4s).
|
||||
For any other immediate concerns, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s).
|
||||
|
||||
---
|
||||
|
||||
_Last updated on **2024-08-19**._
|
||||
_Last updated on **2025-10-12**._
|
||||
|
||||
Generated
+722
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.29",
|
||||
"version": "0.6.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.6.29",
|
||||
"version": "0.6.34",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.3.0",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -92,6 +93,8 @@
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.3.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vega": "^6.2.0",
|
||||
"vega-lite": "^6.4.1",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"y-prosemirror": "^1.3.7",
|
||||
"yaml": "^2.7.1",
|
||||
@@ -3383,9 +3386,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-collaboration": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
|
||||
"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
|
||||
"integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -3393,8 +3396,8 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5",
|
||||
"@tiptap/y-tiptap": "^3.0.0-beta.3",
|
||||
"yjs": "^13"
|
||||
}
|
||||
@@ -3413,9 +3416,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-drag-handle": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
|
||||
"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
|
||||
"integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13"
|
||||
@@ -3425,10 +3428,10 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-collaboration": "^3.0.7",
|
||||
"@tiptap/extension-node-range": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/extension-collaboration": "^3.4.5",
|
||||
"@tiptap/extension-node-range": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5",
|
||||
"@tiptap/y-tiptap": "^3.0.0-beta.3"
|
||||
}
|
||||
},
|
||||
@@ -3642,9 +3645,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-node-range": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
|
||||
"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
|
||||
"integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -3652,8 +3655,8 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7"
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
@@ -4569,6 +4572,21 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webreflection/fetch": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
|
||||
@@ -4672,6 +4690,15 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
|
||||
"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/amator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
|
||||
@@ -5567,6 +5594,99 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
|
||||
"integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
@@ -6321,6 +6441,36 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo-projection": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
|
||||
"integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"d3-array": "1 - 3",
|
||||
"d3-geo": "1.12.0 - 3"
|
||||
},
|
||||
"bin": {
|
||||
"geo2svg": "bin/geo2svg.js",
|
||||
"geograticule": "bin/geograticule.js",
|
||||
"geoproject": "bin/geoproject.js",
|
||||
"geoquantize": "bin/geoquantize.js",
|
||||
"geostitch": "bin/geostitch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo-projection/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
@@ -7013,6 +7163,15 @@
|
||||
"@esbuild/win32-x64": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -7725,6 +7884,27 @@
|
||||
"resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz",
|
||||
"integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A=="
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-func-name": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||
@@ -8738,6 +8918,12 @@
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
@@ -12745,6 +12931,26 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -13022,6 +13228,417 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vega": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vega/-/vega-6.2.0.tgz",
|
||||
"integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-crossfilter": "~5.1.0",
|
||||
"vega-dataflow": "~6.1.0",
|
||||
"vega-encode": "~5.1.0",
|
||||
"vega-event-selector": "~4.0.0",
|
||||
"vega-expression": "~6.1.0",
|
||||
"vega-force": "~5.1.0",
|
||||
"vega-format": "~2.1.0",
|
||||
"vega-functions": "~6.1.0",
|
||||
"vega-geo": "~5.1.0",
|
||||
"vega-hierarchy": "~5.1.0",
|
||||
"vega-label": "~2.1.0",
|
||||
"vega-loader": "~5.1.0",
|
||||
"vega-parser": "~7.1.0",
|
||||
"vega-projection": "~2.1.0",
|
||||
"vega-regression": "~2.1.0",
|
||||
"vega-runtime": "~7.1.0",
|
||||
"vega-scale": "~8.1.0",
|
||||
"vega-scenegraph": "~5.1.0",
|
||||
"vega-statistics": "~2.0.0",
|
||||
"vega-time": "~3.1.0",
|
||||
"vega-transforms": "~5.1.0",
|
||||
"vega-typings": "~2.1.0",
|
||||
"vega-util": "~2.1.0",
|
||||
"vega-view": "~6.1.0",
|
||||
"vega-view-transforms": "~5.1.0",
|
||||
"vega-voronoi": "~5.1.0",
|
||||
"vega-wordcloud": "~5.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-canvas": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-2.0.0.tgz",
|
||||
"integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-crossfilter": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz",
|
||||
"integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-dataflow": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz",
|
||||
"integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-loader": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-encode": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-encode/-/vega-encode-5.1.0.tgz",
|
||||
"integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-event-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-expression": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-6.1.0.tgz",
|
||||
"integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-expression/node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vega-force": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-force/-/vega-force-5.1.0.tgz",
|
||||
"integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-format/-/vega-format-2.1.0.tgz",
|
||||
"integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-format": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-functions": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.0.tgz",
|
||||
"integrity": "sha512-yooEbWt0FWMBNoohwLsl25lEh08WsWabTXbbS+q0IXZzWSpX4Cyi45+q7IFyy/2L4oaIfGIIV14dgn3srQQcGA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-selections": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-geo": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-geo/-/vega-geo-5.1.0.tgz",
|
||||
"integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-projection": "^2.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-hierarchy": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz",
|
||||
"integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-label": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-label/-/vega-label-2.1.0.tgz",
|
||||
"integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-lite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.1.tgz",
|
||||
"integrity": "sha512-KO3ybHNouRK4A0al/+2fN9UqgTEfxrd/ntGLY933Hg5UOYotDVQdshR3zn7OfXwQ7uj0W96Vfa5R+QxO8am3IQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"json-stringify-pretty-compact": "~4.0.0",
|
||||
"tslib": "~2.8.1",
|
||||
"vega-event-selector": "~4.0.0",
|
||||
"vega-expression": "~6.1.0",
|
||||
"vega-util": "~2.1.0",
|
||||
"yargs": "~18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vl2pdf": "bin/vl2pdf",
|
||||
"vl2png": "bin/vl2png",
|
||||
"vl2svg": "bin/vl2svg",
|
||||
"vl2vg": "bin/vl2vg"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vega": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-loader": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-loader/-/vega-loader-5.1.0.tgz",
|
||||
"integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dsv": "^3.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-parser": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-7.1.0.tgz",
|
||||
"integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-event-selector": "^4.0.0",
|
||||
"vega-functions": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-projection": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-projection/-/vega-projection-2.1.0.tgz",
|
||||
"integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-geo-projection": "^4.0.0",
|
||||
"vega-scale": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-regression": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-regression/-/vega-regression-2.1.0.tgz",
|
||||
"integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-runtime": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-runtime/-/vega-runtime-7.1.0.tgz",
|
||||
"integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-scale": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-8.1.0.tgz",
|
||||
"integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-scenegraph": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz",
|
||||
"integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0",
|
||||
"d3-shape": "^3.2.0",
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-loader": "^5.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-selections": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.0.tgz",
|
||||
"integrity": "sha512-WaHM7D7ghHceEfMsgFeaZnDToWL0mgCFtStVOobNh/OJLh0CL7yNKeKQBqRXJv2Lx74dPNf6nj08+52ytWfW7g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "3.2.4",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-statistics": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-2.0.0.tgz",
|
||||
"integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-time/-/vega-time-3.1.0.tgz",
|
||||
"integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-transforms": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-5.1.0.tgz",
|
||||
"integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-typings": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-2.1.0.tgz",
|
||||
"integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/geojson": "7946.0.16",
|
||||
"vega-event-selector": "^4.0.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-util": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-util/-/vega-util-2.1.0.tgz",
|
||||
"integrity": "sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-view": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-view/-/vega-view-6.1.0.tgz",
|
||||
"integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-timer": "^3.0.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-functions": "^6.1.0",
|
||||
"vega-runtime": "^7.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-view-transforms": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz",
|
||||
"integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-voronoi": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz",
|
||||
"integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-delaunay": "^6.0.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-wordcloud": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz",
|
||||
"integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
@@ -14184,6 +14801,15 @@
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
@@ -14205,6 +14831,82 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
|
||||
"integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.29",
|
||||
"version": "0.6.34",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.3.0",
|
||||
@@ -83,6 +83,7 @@
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -136,6 +137,8 @@
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.3.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vega": "^6.2.0",
|
||||
"vega-lite": "^6.4.1",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"y-prosemirror": "^1.3.7",
|
||||
"yaml": "^2.7.1",
|
||||
|
||||
+23
-25
@@ -6,27 +6,28 @@ authors = [
|
||||
]
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastapi==0.115.7",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"pydantic==2.11.7",
|
||||
"fastapi==0.118.0",
|
||||
"uvicorn[standard]==0.37.0",
|
||||
"pydantic==2.11.9",
|
||||
"python-multipart==0.0.20",
|
||||
"itsdangerous==2.2.0",
|
||||
|
||||
"python-socketio==5.13.0",
|
||||
"python-jose==3.4.0",
|
||||
"passlib[bcrypt]==1.7.4",
|
||||
"cryptography",
|
||||
"bcrypt==4.3.0",
|
||||
"argon2-cffi==23.1.0",
|
||||
"bcrypt==5.0.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.6.3",
|
||||
"authlib==1.6.5",
|
||||
|
||||
"requests==2.32.4",
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.12.15",
|
||||
"async-timeout",
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
"starlette-compress==1.6.0",
|
||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||
"starsessions[redis]==2.2.1",
|
||||
|
||||
"sqlalchemy==2.0.38",
|
||||
"alembic==1.14.0",
|
||||
@@ -46,20 +47,22 @@ dependencies = [
|
||||
"asgiref==3.8.1",
|
||||
|
||||
"tiktoken",
|
||||
"mcp==1.14.1",
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.32.0",
|
||||
"google-genai==1.38.0",
|
||||
"google-generativeai==0.8.5",
|
||||
|
||||
"langchain==0.3.26",
|
||||
"langchain-community==0.3.27",
|
||||
"langchain==0.3.27",
|
||||
"langchain-community==0.3.29",
|
||||
|
||||
"fake-useragent==2.2.0",
|
||||
"chromadb==1.0.20",
|
||||
"opensearch-py==2.8.0",
|
||||
|
||||
"transformers",
|
||||
"sentence-transformers==4.1.0",
|
||||
"sentence-transformers==5.1.1",
|
||||
"accelerate",
|
||||
"pyarrow==20.0.0",
|
||||
"einops==0.8.1",
|
||||
@@ -72,7 +75,7 @@ dependencies = [
|
||||
"python-pptx==1.0.2",
|
||||
"unstructured==0.16.17",
|
||||
"nltk==3.9.1",
|
||||
"Markdown==3.8.2",
|
||||
"Markdown==3.9",
|
||||
"pypandoc==1.15",
|
||||
"pandas==2.2.3",
|
||||
"openpyxl==3.1.5",
|
||||
@@ -92,8 +95,8 @@ dependencies = [
|
||||
"onnxruntime==1.20.1",
|
||||
"faster-whisper==1.1.1",
|
||||
|
||||
"black==25.1.0",
|
||||
"youtube-transcript-api==1.1.0",
|
||||
"black==25.9.0",
|
||||
"youtube-transcript-api==1.2.2",
|
||||
"pytube==15.0.0",
|
||||
|
||||
"pydub",
|
||||
@@ -103,21 +106,13 @@ dependencies = [
|
||||
"google-auth-httplib2",
|
||||
"google-auth-oauthlib",
|
||||
|
||||
|
||||
|
||||
"googleapis-common-protos==1.70.0",
|
||||
"google-cloud-storage==2.19.0",
|
||||
|
||||
"azure-identity==1.20.0",
|
||||
"azure-identity==1.25.0",
|
||||
"azure-storage-blob==12.24.1",
|
||||
|
||||
"ldap3==2.9.1",
|
||||
|
||||
"firecrawl-py==1.12.0",
|
||||
"tencentcloud-sdk-python==3.0.1336",
|
||||
|
||||
"oracledb>=3.2.0",
|
||||
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.11, < 3.13.0a1"
|
||||
@@ -151,11 +146,14 @@ all = [
|
||||
"elasticsearch==9.1.0",
|
||||
|
||||
"qdrant-client==1.14.3",
|
||||
"pymilvus==2.5.0",
|
||||
"pymilvus==2.6.2",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.2.0",
|
||||
|
||||
"colbert-ai==0.2.21",
|
||||
|
||||
"firecrawl-py==1.12.0",
|
||||
"tencentcloud-sdk-python==3.0.1336",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+113
-4
@@ -116,7 +116,7 @@ li p {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
background-color: rgba(215, 215, 215, 0.8);
|
||||
background-color: rgba(215, 215, 215, 0.6);
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
@@ -124,13 +124,13 @@ li p {
|
||||
|
||||
/* Dark theme scrollbar styles */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
|
||||
background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
|
||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.6rem;
|
||||
width: 0.4rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -661,3 +661,112 @@ body {
|
||||
background: #171717;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Position the handle relative to each LI */
|
||||
.pm-li--with-handle {
|
||||
position: relative;
|
||||
margin-left: 12px; /* make space for the handle */
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* The drag handle itself */
|
||||
.pm-list-drag-handle {
|
||||
position: absolute;
|
||||
left: -36px; /* pull into the left gutter */
|
||||
top: 1px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
|
||||
left: -16px; /* pull into the left gutter more to avoid the checkbox */
|
||||
}
|
||||
|
||||
.pm-list-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pm-li--with-handle:hover > .pm-list-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.pm-list-drag-handle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:root {
|
||||
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
|
||||
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
|
||||
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* BEFORE/AFTER lines */
|
||||
.pm-li-drop-before::before,
|
||||
.pm-li-drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--pm-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pm-li-drop-before::before {
|
||||
top: -2px;
|
||||
}
|
||||
.pm-li-drop-after::after {
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
background: var(--pm-fill-target);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-li-drop-outdent::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 3px;
|
||||
background: color-mix(in oklab, Highlight 35%, transparent);
|
||||
}
|
||||
|
||||
.pm-li--with-handle:has(.pm-li-drop-before),
|
||||
.pm-li--with-handle:has(.pm-li-drop-after),
|
||||
.pm-li--with-handle:has(.pm-li-drop-into),
|
||||
.pm-li--with-handle:has(.pm-li-drop-outdent) {
|
||||
background: var(--pm-fill-ancestor);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
|
||||
};
|
||||
|
||||
type MessageForm = {
|
||||
reply_to_id?: string;
|
||||
parent_id?: string;
|
||||
content: string;
|
||||
data?: object;
|
||||
|
||||
@@ -33,6 +33,38 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
|
||||
return res;
|
||||
};
|
||||
|
||||
export const unarchiveAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/unarchive/all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const importChat = async (
|
||||
token: string,
|
||||
chat: object,
|
||||
@@ -77,7 +109,12 @@ export const importChat = async (
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChatList = async (token: string = '', page: number | null = null) => {
|
||||
export const getChatList = async (
|
||||
token: string = '',
|
||||
page: number | null = null,
|
||||
include_pinned: boolean = false,
|
||||
include_folders: boolean = false
|
||||
) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -85,6 +122,14 @@ export const getChatList = async (token: string = '', page: number | null = null
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
if (include_folders) {
|
||||
searchParams.append('include_folders', 'true');
|
||||
}
|
||||
|
||||
if (include_pinned) {
|
||||
searchParams.append('include_pinned', 'true');
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -319,6 +364,45 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getChatListByFolderId = async (token: string, folderId: string, page: number = 1) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (page !== null) {
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/chats/folder/${folderId}/list?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getAllArchivedChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import type { Banner } from '$lib/types';
|
||||
|
||||
export const importConfig = async (token: string, config) => {
|
||||
@@ -202,6 +202,52 @@ export const verifyToolServerConnection = async (token: string, connection: obje
|
||||
return res;
|
||||
};
|
||||
|
||||
type RegisterOAuthClientForm = {
|
||||
url: string;
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
token: string,
|
||||
formData: RegisterOAuthClientForm,
|
||||
type: null | string = null
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = type ? `?type=${type}` : '';
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register${searchParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getOAuthClientAuthorizationUrl = (clientId: string, type: null | string = null) => {
|
||||
const oauthClientId = type ? `${type}:${clientId}` : clientId;
|
||||
return `${WEBUI_BASE_URL}/oauth/clients/${oauthClientId}/authorize`;
|
||||
};
|
||||
|
||||
export const getCodeExecutionConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
error = err.detail || err.message;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -62,6 +62,37 @@ export const getFunctions = async (token: string = '') => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFunctionList = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/functions/list`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const loadFunctionByUrl = async (token: string = '', url: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
+49
-28
@@ -337,14 +337,8 @@ export const getToolServerData = async (token: string, url: string) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = {
|
||||
openapi: res,
|
||||
info: res.info,
|
||||
specs: convertOpenApiToToolPayload(res)
|
||||
};
|
||||
|
||||
console.log(data);
|
||||
return data;
|
||||
console.log(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getToolServersData = async (servers: object[]) => {
|
||||
@@ -356,6 +350,7 @@ export const getToolServersData = async (servers: object[]) => {
|
||||
let error = null;
|
||||
|
||||
let toolServerToken = null;
|
||||
|
||||
const auth_type = server?.auth_type ?? 'bearer';
|
||||
if (auth_type === 'bearer') {
|
||||
toolServerToken = server?.key;
|
||||
@@ -365,18 +360,34 @@ export const getToolServersData = async (servers: object[]) => {
|
||||
toolServerToken = localStorage.token;
|
||||
}
|
||||
|
||||
const data = await getToolServerData(
|
||||
toolServerToken,
|
||||
(server?.path ?? '').includes('://')
|
||||
? server?.path
|
||||
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
|
||||
).catch((err) => {
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
let res = null;
|
||||
const specType = server?.spec_type ?? 'url';
|
||||
|
||||
if (specType === 'url') {
|
||||
res = await getToolServerData(
|
||||
toolServerToken,
|
||||
(server?.path ?? '').includes('://')
|
||||
? server?.path
|
||||
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
|
||||
).catch((err) => {
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
} else if ((specType === 'json' && server?.spec) ?? null) {
|
||||
try {
|
||||
res = JSON.parse(server?.spec);
|
||||
} catch (e) {
|
||||
error = 'Failed to parse JSON spec';
|
||||
}
|
||||
}
|
||||
|
||||
if (res) {
|
||||
const { openapi, info, specs } = {
|
||||
openapi: res,
|
||||
info: res.info,
|
||||
specs: convertOpenApiToToolPayload(res)
|
||||
};
|
||||
|
||||
if (data) {
|
||||
const { openapi, info, specs } = data;
|
||||
return {
|
||||
url: server?.url,
|
||||
openapi: openapi,
|
||||
@@ -483,7 +494,10 @@ export const executeToolServer = async (
|
||||
headers
|
||||
};
|
||||
|
||||
if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) {
|
||||
if (
|
||||
['post', 'put', 'patch', 'delete'].includes(httpMethod.toLowerCase()) &&
|
||||
operation.requestBody
|
||||
) {
|
||||
requestOptions.body = JSON.stringify(bodyParams);
|
||||
}
|
||||
|
||||
@@ -493,18 +507,25 @@ export const executeToolServer = async (
|
||||
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
|
||||
}
|
||||
|
||||
let responseData;
|
||||
try {
|
||||
responseData = await res.json();
|
||||
} catch (err) {
|
||||
responseData = await res.text();
|
||||
}
|
||||
// make a clone of res and extract headers
|
||||
const responseHeaders = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
return responseData;
|
||||
const text = await res.text();
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = JSON.parse(text);
|
||||
} catch {
|
||||
responseData = text;
|
||||
}
|
||||
return [responseData, responseHeaders];
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
console.error('API Request Error:', error);
|
||||
return { error };
|
||||
return [{ error }, null];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,34 @@ export const getModels = async (token: string = '') => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const importModels = async (token: string, models: object[]) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/models/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ models: models })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getBaseModels = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Tags from './common/Tags.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Textarea from './common/Textarea.svelte';
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
export let onDelete: Function = () => {};
|
||||
@@ -42,6 +43,8 @@
|
||||
let enable = true;
|
||||
let apiVersion = '';
|
||||
|
||||
let headers = '';
|
||||
|
||||
let tags = [];
|
||||
|
||||
let modelId = '';
|
||||
@@ -69,6 +72,22 @@
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
let _headers = null;
|
||||
|
||||
if (headers) {
|
||||
try {
|
||||
_headers = JSON.parse(headers);
|
||||
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
|
||||
_headers = null;
|
||||
throw new Error('Headers must be a valid JSON object');
|
||||
}
|
||||
headers = JSON.stringify(_headers, null, 2);
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('Headers must be a valid JSON object'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await verifyOpenAIConnection(
|
||||
localStorage.token,
|
||||
{
|
||||
@@ -77,7 +96,8 @@
|
||||
config: {
|
||||
auth_type,
|
||||
azure: azure,
|
||||
api_version: apiVersion
|
||||
api_version: apiVersion,
|
||||
...(_headers ? { headers: _headers } : {})
|
||||
}
|
||||
},
|
||||
direct
|
||||
@@ -136,6 +156,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
try {
|
||||
const _headers = JSON.parse(headers);
|
||||
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
|
||||
throw new Error('Headers must be a valid JSON object');
|
||||
}
|
||||
headers = JSON.stringify(_headers, null, 2);
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('Headers must be a valid JSON object'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
@@ -149,6 +182,7 @@
|
||||
model_ids: modelIds,
|
||||
connection_type: connectionType,
|
||||
auth_type,
|
||||
headers: headers ? JSON.parse(headers) : undefined,
|
||||
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
|
||||
}
|
||||
};
|
||||
@@ -172,6 +206,9 @@
|
||||
key = connection.key;
|
||||
|
||||
auth_type = connection.config.auth_type ?? 'bearer';
|
||||
headers = connection.config?.headers
|
||||
? JSON.stringify(connection.config.headers, null, 2)
|
||||
: '';
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
tags = connection.config?.tags ?? [];
|
||||
@@ -376,6 +413,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {{\'{{"X-Custom-Header": "value"}}\'}})'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Tags from './common/Tags.svelte';
|
||||
import { getToolServerData } from '$lib/apis';
|
||||
import { verifyToolServerConnection } from '$lib/apis/configs';
|
||||
import AccessControl from './workspace/common/AccessControl.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
export let onDelete: Function = () => {};
|
||||
|
||||
export let show = false;
|
||||
export let edit = false;
|
||||
|
||||
export let direct = false;
|
||||
export let connection = null;
|
||||
|
||||
let url = '';
|
||||
let path = 'openapi.json';
|
||||
|
||||
let auth_type = 'bearer';
|
||||
let key = '';
|
||||
|
||||
let accessControl = {};
|
||||
|
||||
let id = '';
|
||||
let name = '';
|
||||
let description = '';
|
||||
|
||||
let enable = true;
|
||||
|
||||
let loading = false;
|
||||
|
||||
const verifyHandler = async () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === '') {
|
||||
toast.error($i18n.t('Please enter a valid path'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (direct) {
|
||||
const res = await getToolServerData(
|
||||
auth_type === 'bearer' ? key : localStorage.token,
|
||||
path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
).catch((err) => {
|
||||
toast.error($i18n.t('Connection failed'));
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Connection successful'));
|
||||
console.debug('Connection successful', res);
|
||||
}
|
||||
} else {
|
||||
const res = await verifyToolServerConnection(localStorage.token, {
|
||||
url,
|
||||
path,
|
||||
auth_type,
|
||||
key,
|
||||
config: {
|
||||
enable: enable,
|
||||
access_control: accessControl
|
||||
},
|
||||
info: {
|
||||
id,
|
||||
name,
|
||||
description
|
||||
}
|
||||
}).catch((err) => {
|
||||
toast.error($i18n.t('Connection failed'));
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Connection successful'));
|
||||
console.debug('Connection successful', res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
const connection = {
|
||||
url,
|
||||
path,
|
||||
auth_type,
|
||||
key,
|
||||
config: {
|
||||
enable: enable,
|
||||
access_control: accessControl
|
||||
},
|
||||
info: {
|
||||
id: id,
|
||||
name: name,
|
||||
description: description
|
||||
}
|
||||
};
|
||||
|
||||
await onSubmit(connection);
|
||||
|
||||
loading = false;
|
||||
show = false;
|
||||
|
||||
url = '';
|
||||
path = 'openapi.json';
|
||||
key = '';
|
||||
auth_type = 'bearer';
|
||||
|
||||
id = '';
|
||||
name = '';
|
||||
description = '';
|
||||
|
||||
enable = true;
|
||||
accessControl = null;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
if (connection) {
|
||||
url = connection.url;
|
||||
path = connection?.path ?? 'openapi.json';
|
||||
|
||||
auth_type = connection?.auth_type ?? 'bearer';
|
||||
key = connection?.key ?? '';
|
||||
|
||||
id = connection.info?.id ?? '';
|
||||
name = connection.info?.name ?? '';
|
||||
description = connection.info?.description ?? '';
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
accessControl = connection.config?.access_control ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<h1 class=" text-lg font-medium self-center font-primary">
|
||||
{#if edit}
|
||||
{$i18n.t('Edit Connection')}
|
||||
{:else}
|
||||
{$i18n.t('Add Connection')}
|
||||
{/if}
|
||||
</h1>
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close Configure Connection Modal')}
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form
|
||||
class="flex flex-col w-full"
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class="px-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="api-base-url"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="api-base-url"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('API Base URL')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Verify Connection')}
|
||||
className="shrink-0 flex items-center mr-1"
|
||||
>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
on:click={() => {
|
||||
verifyHandler();
|
||||
}}
|
||||
aria-label={$i18n.t('Verify Connection')}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch bind:state={enable} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="url-or-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Auth')}</label
|
||||
>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
|
||||
<option value="bearer">{$i18n.t('Bearer')}</option>
|
||||
<option value="session">{$i18n.t('Session')}</option>
|
||||
|
||||
{#if !direct}
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if auth_type === 'bearer'}
|
||||
<SensitiveInput
|
||||
bind:value={key}
|
||||
placeholder={$i18n.t('API Key')}
|
||||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !direct}
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="enter-id"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('ID')}
|
||||
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
|
||||
>{$i18n.t('Optional')}</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="enter-id"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder={$i18n.t('Enter ID')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="enter-name"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="enter-name"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Enter name')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
for="description"
|
||||
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
|
||||
>{$i18n.t('Description')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="description"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$i18n.t('Enter description')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2 -mx-2">
|
||||
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
|
||||
<AccessControl bind:accessControl />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,797 @@
|
||||
<script lang="ts">
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Tags from './common/Tags.svelte';
|
||||
import { getToolServerData } from '$lib/apis';
|
||||
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
|
||||
import AccessControl from './workspace/common/AccessControl.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
export let onDelete: Function = () => {};
|
||||
|
||||
export let show = false;
|
||||
export let edit = false;
|
||||
|
||||
export let direct = false;
|
||||
export let connection = null;
|
||||
|
||||
let inputElement = null;
|
||||
|
||||
let type = 'openapi'; // 'openapi', 'mcp'
|
||||
|
||||
let url = '';
|
||||
|
||||
let spec_type = 'url'; // 'url', 'json'
|
||||
let spec = ''; // used when spec_type is 'json'
|
||||
let path = 'openapi.json';
|
||||
|
||||
let auth_type = 'bearer';
|
||||
let key = '';
|
||||
|
||||
let accessControl = {};
|
||||
|
||||
let id = '';
|
||||
let name = '';
|
||||
let description = '';
|
||||
|
||||
let oauthClientInfo = null;
|
||||
|
||||
let enable = true;
|
||||
let loading = false;
|
||||
|
||||
const registerOAuthClientHandler = async () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === '') {
|
||||
toast.error($i18n.t('Please enter a valid ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await registerOAuthClient(
|
||||
localStorage.token,
|
||||
{
|
||||
url: url,
|
||||
client_id: id
|
||||
},
|
||||
'mcp'
|
||||
).catch((err) => {
|
||||
toast.error($i18n.t('Registration failed'));
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.warning(
|
||||
$i18n.t(
|
||||
'Please save the connection to persist the OAuth client information and do not change the ID'
|
||||
)
|
||||
);
|
||||
toast.success($i18n.t('Registration successful'));
|
||||
|
||||
console.debug('Registration successful', res);
|
||||
oauthClientInfo = res?.oauth_client_info ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyHandler = async () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (['openapi', ''].includes(type)) {
|
||||
if (spec_type === 'json' && spec === '') {
|
||||
toast.error($i18n.t('Please enter a valid JSON spec'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec_type === 'url' && path === '') {
|
||||
toast.error($i18n.t('Please enter a valid path'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (direct) {
|
||||
const res = await getToolServerData(
|
||||
auth_type === 'bearer' ? key : localStorage.token,
|
||||
path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
).catch((err) => {
|
||||
toast.error($i18n.t('Connection failed'));
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Connection successful'));
|
||||
console.debug('Connection successful', res);
|
||||
}
|
||||
} else {
|
||||
const res = await verifyToolServerConnection(localStorage.token, {
|
||||
url,
|
||||
path,
|
||||
type,
|
||||
auth_type,
|
||||
key,
|
||||
config: {
|
||||
enable: enable,
|
||||
access_control: accessControl
|
||||
},
|
||||
info: {
|
||||
id,
|
||||
name,
|
||||
description
|
||||
}
|
||||
}).catch((err) => {
|
||||
toast.error($i18n.t('Connection failed'));
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Connection successful'));
|
||||
console.debug('Connection successful', res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importHandler = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const json = event.target.result;
|
||||
console.log('importHandler', json);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(json);
|
||||
// validate data
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) {
|
||||
toast.error($i18n.t('Please select a valid JSON file'));
|
||||
return;
|
||||
}
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
if (data.type) type = data.type;
|
||||
if (data.url) url = data.url;
|
||||
|
||||
if (data.spec_type) spec_type = data.spec_type;
|
||||
if (data.spec) spec = data.spec;
|
||||
if (data.path) path = data.path;
|
||||
|
||||
if (data.auth_type) auth_type = data.auth_type;
|
||||
if (data.key) key = data.key;
|
||||
|
||||
if (data.info) {
|
||||
id = data.info.id ?? '';
|
||||
name = data.info.name ?? '';
|
||||
description = data.info.description ?? '';
|
||||
}
|
||||
|
||||
if (data.config) {
|
||||
enable = data.config.enable ?? true;
|
||||
accessControl = data.config.access_control ?? {};
|
||||
}
|
||||
|
||||
toast.success($i18n.t('Import successful'));
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('Please select a valid JSON file'));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const exportHandler = async () => {
|
||||
// export current connection as json file
|
||||
const json = JSON.stringify([
|
||||
{
|
||||
type,
|
||||
url,
|
||||
|
||||
spec_type,
|
||||
spec,
|
||||
path,
|
||||
|
||||
auth_type,
|
||||
key,
|
||||
|
||||
info: {
|
||||
id: id,
|
||||
name: name,
|
||||
description: description
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const blob = new Blob([json], {
|
||||
type: 'application/json'
|
||||
});
|
||||
|
||||
saveAs(blob, `tool-server-${id || name || 'export'}.json`);
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
if (id.includes(':') || id.includes('|')) {
|
||||
toast.error($i18n.t('ID cannot contain ":" or "|" characters'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) {
|
||||
toast.error($i18n.t('Please register the OAuth client'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// validate spec
|
||||
if (spec_type === 'json') {
|
||||
try {
|
||||
const specJSON = JSON.parse(spec);
|
||||
spec = JSON.stringify(specJSON, null, 2);
|
||||
} catch (e) {
|
||||
toast.error($i18n.t('Please enter a valid JSON spec'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const connection = {
|
||||
type,
|
||||
url,
|
||||
|
||||
spec_type,
|
||||
spec,
|
||||
path,
|
||||
|
||||
auth_type,
|
||||
key,
|
||||
config: {
|
||||
enable: enable,
|
||||
access_control: accessControl
|
||||
},
|
||||
info: {
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
|
||||
}
|
||||
};
|
||||
|
||||
await onSubmit(connection);
|
||||
|
||||
loading = false;
|
||||
show = false;
|
||||
|
||||
// reset form
|
||||
type = 'openapi';
|
||||
url = '';
|
||||
|
||||
spec_type = 'url';
|
||||
spec = '';
|
||||
path = 'openapi.json';
|
||||
|
||||
key = '';
|
||||
auth_type = 'bearer';
|
||||
|
||||
id = '';
|
||||
name = '';
|
||||
description = '';
|
||||
oauthClientInfo = null;
|
||||
|
||||
enable = true;
|
||||
accessControl = null;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
if (connection) {
|
||||
type = connection?.type ?? 'openapi';
|
||||
url = connection.url;
|
||||
|
||||
spec_type = connection?.spec_type ?? 'url';
|
||||
spec = connection?.spec ?? '';
|
||||
path = connection?.path ?? 'openapi.json';
|
||||
|
||||
auth_type = connection?.auth_type ?? 'bearer';
|
||||
key = connection?.key ?? '';
|
||||
|
||||
id = connection.info?.id ?? '';
|
||||
name = connection.info?.name ?? '';
|
||||
description = connection.info?.description ?? '';
|
||||
oauthClientInfo = connection.info?.oauth_client_info ?? null;
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
accessControl = connection.config?.access_control ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<h1 class=" text-lg font-medium self-center font-primary">
|
||||
{#if edit}
|
||||
{$i18n.t('Edit Connection')}
|
||||
{:else}
|
||||
{$i18n.t('Add Connection')}
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-1.5 text-xs justify-end">
|
||||
<button
|
||||
class=" hover:underline"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
inputElement?.click();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Import')}
|
||||
</button>
|
||||
|
||||
<button class=" hover:underline" type="button" on:click={exportHandler}>
|
||||
{$i18n.t('Export')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close Configure Connection Modal')}
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="file"
|
||||
hidden
|
||||
accept=".json"
|
||||
on:change={(e) => {
|
||||
importHandler(e);
|
||||
}}
|
||||
/>
|
||||
|
||||
<form
|
||||
class="flex flex-col w-full"
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class="px-1">
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mb-1.5">
|
||||
<div class="flex w-full justify-between items-center">
|
||||
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
|
||||
|
||||
<div class="">
|
||||
<button
|
||||
on:click={() => {
|
||||
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
|
||||
}}
|
||||
type="button"
|
||||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
{$i18n.t('OpenAPI')}
|
||||
{:else if type === 'mcp'}
|
||||
{$i18n.t('MCP')}
|
||||
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="api-base-url"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="api-base-url"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('API Base URL')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Verify Connection')}
|
||||
className="shrink-0 flex items-center mr-1"
|
||||
>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
on:click={() => {
|
||||
verifyHandler();
|
||||
}}
|
||||
aria-label={$i18n.t('Verify Connection')}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch bind:state={enable} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('OpenAPI Spec')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={spec_type}
|
||||
>
|
||||
<option value="url">{$i18n.t('URL')}</option>
|
||||
<option value="json">{$i18n.t('JSON')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if spec_type === 'url'}
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="url-or-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if spec_type === 'json'}
|
||||
<div
|
||||
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
|
||||
<textarea
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
|
||||
bind:value={spec}
|
||||
placeholder={$i18n.t('JSON Spec')}
|
||||
autocomplete="off"
|
||||
required
|
||||
rows="5"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ['', 'url'].includes(spec_type)}
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Auth')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if auth_type === 'oauth_2.1'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col justify-end items-center shrink-0">
|
||||
<Tooltip
|
||||
content={oauthClientInfo
|
||||
? $i18n.t('Register Again')
|
||||
: $i18n.t('Register Client')}
|
||||
>
|
||||
<button
|
||||
class=" text-xs underline dark:text-gray-500 dark:hover:text-gray-200 text-gray-700 hover:text-gray-900 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
registerOAuthClientHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Register Client')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{#if !oauthClientInfo}
|
||||
<div
|
||||
class="text-xs font-medium px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
|
||||
>
|
||||
{$i18n.t('Not Registered')}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs font-medium px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
|
||||
>
|
||||
{$i18n.t('Registered')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
|
||||
<option value="bearer">{$i18n.t('Bearer')}</option>
|
||||
<option value="session">{$i18n.t('Session')}</option>
|
||||
|
||||
{#if !direct}
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{#if type === 'mcp'}
|
||||
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if auth_type === 'bearer'}
|
||||
<SensitiveInput
|
||||
bind:value={key}
|
||||
placeholder={$i18n.t('API Key')}
|
||||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'oauth_2.1'}
|
||||
<div
|
||||
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !direct}
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="enter-id"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('ID')}
|
||||
|
||||
{#if type !== 'mcp'}
|
||||
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
|
||||
>{$i18n.t('Optional')}</span
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="enter-id"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder={$i18n.t('Enter ID')}
|
||||
autocomplete="off"
|
||||
required={type === 'mcp'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="enter-name"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="enter-name"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Enter name')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
for="description"
|
||||
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
|
||||
>{$i18n.t('Description')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="description"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$i18n.t('Enter description')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2 -mx-2">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
|
||||
<AccessControl bind:accessControl />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if type === 'mcp'}
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{$i18n.t('Warning')}:
|
||||
</span>
|
||||
{$i18n.t(
|
||||
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
|
||||
)}
|
||||
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.openwebui.com/features/mcp"
|
||||
target="_blank">{$i18n.t('Read more →')}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
|
||||
<div></div>
|
||||
<div class="flex gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -19,16 +19,19 @@
|
||||
|
||||
let changelog = null;
|
||||
|
||||
onMount(async () => {
|
||||
const res = await getChangelog();
|
||||
changelog = res;
|
||||
});
|
||||
const init = async () => {
|
||||
changelog = await getChangelog();
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:show size="xl">
|
||||
<div class="px-5 pt-4 dark:text-gray-300 text-gray-700">
|
||||
<div class="px-6 pt-5 dark:text-white text-black">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="text-xl font-semibold">
|
||||
<div class="text-xl font-medium">
|
||||
{$i18n.t("What's New in")}
|
||||
{$WEBUI_NAME}
|
||||
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
|
||||
@@ -48,7 +51,7 @@
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
|
||||
<div class="text-sm dark:text-gray-200">
|
||||
v{WEBUI_VERSION}
|
||||
</div>
|
||||
@@ -56,7 +59,7 @@
|
||||
</div>
|
||||
|
||||
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
|
||||
<div class=" overflow-y-scroll max-h-[32rem] scrollbar-hidden">
|
||||
<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
|
||||
<div class="mb-3">
|
||||
{#if changelog}
|
||||
{#each Object.keys(changelog) as version}
|
||||
@@ -65,20 +68,20 @@
|
||||
v{version} - {changelog[version].date}
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-2" />
|
||||
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
|
||||
|
||||
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="font-semibold uppercase text-xs {section === 'added'
|
||||
? 'text-white bg-blue-600'
|
||||
? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
|
||||
: section === 'fixed'
|
||||
? 'text-white bg-green-600'
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-200'
|
||||
: section === 'changed'
|
||||
? 'text-white bg-yellow-600'
|
||||
? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
|
||||
: section === 'removed'
|
||||
? 'text-white bg-red-600'
|
||||
: ''} w-fit px-3 rounded-full my-2.5"
|
||||
? 'bg-red-500/20 text-red-700 dark:text-red-200'
|
||||
: ''} w-fit rounded-xl px-2 my-2.5"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
{:else}
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
|
||||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none w-3"
|
||||
|
||||
@@ -389,7 +389,7 @@
|
||||
: ''}"
|
||||
>
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
|
||||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none w-3"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import { WEBUI_NAME, config, functions, models, settings } from '$lib/stores';
|
||||
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
|
||||
import { onMount, getContext, tick } from 'svelte';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -12,6 +12,7 @@
|
||||
deleteFunctionById,
|
||||
exportFunctions,
|
||||
getFunctionById,
|
||||
getFunctionList,
|
||||
getFunctions,
|
||||
loadFunctionByUrl,
|
||||
toggleFunctionById,
|
||||
@@ -36,6 +37,10 @@
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
|
||||
import ImportModal from '../ImportModal.svelte';
|
||||
import ViewSelector from '../workspace/common/ViewSelector.svelte';
|
||||
import TagSelector from '../workspace/common/TagSelector.svelte';
|
||||
import { capitalizeFirstLetter } from '$lib/utils';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -44,12 +49,16 @@
|
||||
let functionsImportInputElement: HTMLInputElement;
|
||||
let importFiles;
|
||||
|
||||
let tagsContainerElement: HTMLDivElement;
|
||||
let viewOption = '';
|
||||
|
||||
let query = '';
|
||||
let selectedTag = '';
|
||||
let selectedType = '';
|
||||
|
||||
let showImportModal = false;
|
||||
|
||||
let showConfirm = false;
|
||||
let query = '';
|
||||
|
||||
let selectedType = 'all';
|
||||
|
||||
let showManifestModal = false;
|
||||
let showValvesModal = false;
|
||||
@@ -57,17 +66,33 @@
|
||||
|
||||
let showDeleteConfirm = false;
|
||||
|
||||
let loaded = false;
|
||||
let functions = null;
|
||||
let filteredItems = [];
|
||||
$: filteredItems = $functions
|
||||
.filter(
|
||||
(f) =>
|
||||
(selectedType !== 'all' ? f.type === selectedType : true) &&
|
||||
(query === '' ||
|
||||
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
f.id.toLowerCase().includes(query.toLowerCase()))
|
||||
)
|
||||
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
||||
|
||||
$: if (
|
||||
functions &&
|
||||
query !== undefined &&
|
||||
selectedType !== undefined &&
|
||||
viewOption !== undefined
|
||||
) {
|
||||
setFilteredItems();
|
||||
}
|
||||
|
||||
const setFilteredItems = () => {
|
||||
filteredItems = functions
|
||||
.filter(
|
||||
(f) =>
|
||||
(selectedType !== '' ? f.type === selectedType : true) &&
|
||||
(query === '' ||
|
||||
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
f.id.toLowerCase().includes(query.toLowerCase())) &&
|
||||
(viewOption === '' ||
|
||||
(viewOption === 'created' && f.user_id === $user?.id) ||
|
||||
(viewOption === 'shared' && f.user_id !== $user?.id))
|
||||
)
|
||||
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
||||
};
|
||||
const shareHandler = async (func) => {
|
||||
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
@@ -134,7 +159,7 @@
|
||||
if (res) {
|
||||
toast.success($i18n.t('Function deleted successfully'));
|
||||
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
_functions.set(await getFunctions(localStorage.token));
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
@@ -162,7 +187,7 @@
|
||||
: toast.success($i18n.t('Function is now globally disabled'));
|
||||
}
|
||||
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
_functions.set(await getFunctions(localStorage.token));
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
@@ -174,7 +199,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
viewOption = localStorage?.workspaceViewOption || '';
|
||||
functions = await getFunctionList(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return [];
|
||||
});
|
||||
|
||||
await tick();
|
||||
loaded = true;
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftKey = true;
|
||||
@@ -222,424 +256,388 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col mt-1.5 mb-0.5 px-[16px]">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
||||
{$i18n.t('Functions')}
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
||||
<span class="text-base font-lg text-gray-500 dark:text-gray-300">{filteredItems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if loaded}
|
||||
<div class="px-4.5 w-full">
|
||||
<div class="flex flex-col gap-1 px-1 mt-2.5 mb-2">
|
||||
<div class="flex justify-between items-center mb-1 w-full">
|
||||
<input
|
||||
id="documents-import-input"
|
||||
bind:this={functionsImportInputElement}
|
||||
bind:files={importFiles}
|
||||
type="file"
|
||||
accept=".json"
|
||||
hidden
|
||||
on:change={() => {
|
||||
console.log(importFiles);
|
||||
showConfirm = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" flex w-full space-x-2">
|
||||
<div class="flex flex-1">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
<div>
|
||||
{$i18n.t('Functions')}
|
||||
</div>
|
||||
|
||||
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||
{filteredItems.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-end gap-1.5">
|
||||
{#if $user?.role === 'admin'}
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
on:click={() => {
|
||||
functionsImportInputElement.click();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Import')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if functions.length}
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
on:click={async () => {
|
||||
const _functions = await exportFunctions(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (_functions) {
|
||||
let blob = new Blob([JSON.stringify(_functions)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `functions-export-${Date.now()}.json`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Export')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<AddFunctionMenu
|
||||
createHandler={() => {
|
||||
goto('/admin/functions/create');
|
||||
}}
|
||||
importFromLinkHandler={() => {
|
||||
showImportModal = true;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class=" px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
|
||||
>
|
||||
<Plus className="size-3" strokeWidth="2.5" />
|
||||
|
||||
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Function')}</div>
|
||||
</div>
|
||||
</AddFunctionMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={$i18n.t('Search Functions')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if query}
|
||||
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
|
||||
<button
|
||||
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
query = '';
|
||||
<div
|
||||
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100 dark:border-gray-850"
|
||||
>
|
||||
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||
<div class="flex flex-1">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
</div>
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={$i18n.t('Search Functions')}
|
||||
/>
|
||||
|
||||
{#if query}
|
||||
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
|
||||
<button
|
||||
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
query = '';
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||
on:wheel={(e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
bind:this={tagsContainerElement}
|
||||
>
|
||||
<ViewSelector
|
||||
bind:value={viewOption}
|
||||
onChange={async (value) => {
|
||||
localStorage.workspaceViewOption = value;
|
||||
|
||||
await tick();
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
</button>
|
||||
/>
|
||||
|
||||
<TagSelector
|
||||
bind:value={selectedType}
|
||||
items={[
|
||||
{ value: 'pipe', label: $i18n.t('Pipe') },
|
||||
{ value: 'filter', label: $i18n.t('Filter') },
|
||||
{ value: 'action', label: $i18n.t('Action') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if (filteredItems ?? []).length !== 0}
|
||||
<div class="px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2">
|
||||
{#each filteredItems as func (func.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
||||
>
|
||||
<a
|
||||
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
||||
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
|
||||
>
|
||||
<div class="flex items-center text-left">
|
||||
<div class=" flex-1 self-center pl-1">
|
||||
<Tooltip content={func.id} placement="top-start">
|
||||
<div class=" flex items-center gap-1.5">
|
||||
<div
|
||||
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{func.type}
|
||||
</div>
|
||||
|
||||
<div class="line-clamp-1 text-sm">
|
||||
{func.name}
|
||||
</div>
|
||||
{#if func?.meta?.manifest?.version}
|
||||
<div class=" text-gray-500 text-xs font-medium shrink-0">
|
||||
v{func?.meta?.manifest?.version ?? ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex gap-1.5 px-1">
|
||||
<div class="text-xs text-gray-500 shrink-0">
|
||||
<Tooltip
|
||||
content={func?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="flex shrink-0"
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t('By {{name}}', {
|
||||
name: capitalizeFirstLetter(
|
||||
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
|
||||
)
|
||||
})}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
|
||||
{func.meta.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-row gap-0.5 self-center">
|
||||
{#if shiftKey}
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
deleteHandler(func);
|
||||
}}
|
||||
>
|
||||
<GarbageBin />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
{#if func?.meta?.manifest?.funding_url ?? false}
|
||||
<Tooltip content={$i18n.t('Support')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedFunction = func;
|
||||
showManifestModal = true;
|
||||
}}
|
||||
>
|
||||
<Heart />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedFunction = func;
|
||||
showValvesModal = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<FunctionMenu
|
||||
{func}
|
||||
editHandler={() => {
|
||||
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
|
||||
}}
|
||||
shareHandler={() => {
|
||||
shareHandler(func);
|
||||
}}
|
||||
cloneHandler={() => {
|
||||
cloneHandler(func);
|
||||
}}
|
||||
exportHandler={() => {
|
||||
exportHandler(func);
|
||||
}}
|
||||
deleteHandler={async () => {
|
||||
selectedFunction = func;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
toggleGlobalHandler={() => {
|
||||
if (['filter', 'action'].includes(func.type)) {
|
||||
toggleGlobalHandler(func);
|
||||
}
|
||||
}}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</FunctionMenu>
|
||||
{/if}
|
||||
|
||||
<div class=" self-center mx-1">
|
||||
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch
|
||||
bind:state={func.is_active}
|
||||
on:change={async (e) => {
|
||||
toggleFunctionById(localStorage.token, func.id);
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections &&
|
||||
($settings?.directConnections ?? null),
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
||||
<div class="max-w-md text-center">
|
||||
<div class=" text-3xl mb-3">😕</div>
|
||||
<div class=" text-lg font-medium mb-1">{$i18n.t('No functions found')}</div>
|
||||
<div class=" text-gray-500 text-center text-xs">
|
||||
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AddFunctionMenu
|
||||
createHandler={() => {
|
||||
goto('/admin/functions/create');
|
||||
}}
|
||||
importFromLinkHandler={() => {
|
||||
showImportModal = true;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</div>
|
||||
</AddFunctionMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full">
|
||||
<div
|
||||
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
|
||||
>
|
||||
<button
|
||||
class="min-w-fit p-1.5 {selectedType === 'all'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
on:click={() => {
|
||||
selectedType = 'all';
|
||||
}}>{$i18n.t('All')}</button
|
||||
>
|
||||
|
||||
<button
|
||||
class="min-w-fit p-1.5 {selectedType === 'pipe'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
on:click={() => {
|
||||
selectedType = 'pipe';
|
||||
}}>{$i18n.t('Pipe')}</button
|
||||
>
|
||||
|
||||
<button
|
||||
class="min-w-fit p-1.5 {selectedType === 'filter'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
on:click={() => {
|
||||
selectedType = 'filter';
|
||||
}}>{$i18n.t('Filter')}</button
|
||||
>
|
||||
|
||||
<button
|
||||
class="min-w-fit p-1.5 {selectedType === 'action'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
on:click={() => {
|
||||
selectedType = 'action';
|
||||
}}>{$i18n.t('Action')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 px-[16px]">
|
||||
{#each filteredItems as func (func.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
||||
>
|
||||
<a
|
||||
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
||||
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
|
||||
>
|
||||
<div class="flex items-center text-left">
|
||||
<div class=" flex-1 self-center pl-1">
|
||||
<div class=" font-semibold flex items-center gap-1.5">
|
||||
<div
|
||||
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{func.type}
|
||||
</div>
|
||||
|
||||
{#if func?.meta?.manifest?.version}
|
||||
<div
|
||||
class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
v{func?.meta?.manifest?.version ?? ''}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" line-clamp-1">
|
||||
{func.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1.5 px-1">
|
||||
<div class=" text-gray-500 text-xs font-medium shrink-0">{func.id}</div>
|
||||
|
||||
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
|
||||
{func.meta.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-row gap-0.5 self-center">
|
||||
{#if shiftKey}
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
deleteHandler(func);
|
||||
}}
|
||||
>
|
||||
<GarbageBin />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
{#if func?.meta?.manifest?.funding_url ?? false}
|
||||
<Tooltip content={$i18n.t('Support')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedFunction = func;
|
||||
showManifestModal = true;
|
||||
}}
|
||||
>
|
||||
<Heart />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedFunction = func;
|
||||
showValvesModal = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<FunctionMenu
|
||||
{func}
|
||||
editHandler={() => {
|
||||
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
|
||||
}}
|
||||
shareHandler={() => {
|
||||
shareHandler(func);
|
||||
}}
|
||||
cloneHandler={() => {
|
||||
cloneHandler(func);
|
||||
}}
|
||||
exportHandler={() => {
|
||||
exportHandler(func);
|
||||
}}
|
||||
deleteHandler={async () => {
|
||||
selectedFunction = func;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
toggleGlobalHandler={() => {
|
||||
if (['filter', 'action'].includes(func.type)) {
|
||||
toggleGlobalHandler(func);
|
||||
}
|
||||
}}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</FunctionMenu>
|
||||
{/if}
|
||||
|
||||
<div class=" self-center mx-1">
|
||||
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch
|
||||
bind:state={func.is_active}
|
||||
on:change={async (e) => {
|
||||
toggleFunctionById(localStorage.token, func.id);
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections &&
|
||||
($settings?.directConnections ?? null),
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- <div class=" text-gray-500 text-xs mt-1 mb-2">
|
||||
<!-- <div class=" text-gray-500 text-xs mt-1 mb-2">
|
||||
ⓘ {$i18n.t(
|
||||
'Admins have access to all tools at all times; users need tools assigned per model in the workspace.'
|
||||
)}
|
||||
</div> -->
|
||||
|
||||
<div class=" flex justify-end w-full mb-2 px-[16px]">
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
id="documents-import-input"
|
||||
bind:this={functionsImportInputElement}
|
||||
bind:files={importFiles}
|
||||
type="file"
|
||||
accept=".json"
|
||||
hidden
|
||||
on:change={() => {
|
||||
console.log(importFiles);
|
||||
showConfirm = true;
|
||||
}}
|
||||
/>
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<div class=" my-16">
|
||||
<div class=" text-xl font-medium mb-1 line-clamp-1">
|
||||
{$i18n.t('Made by Open WebUI Community')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
|
||||
on:click={() => {
|
||||
functionsImportInputElement.click();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Functions')}</div>
|
||||
|
||||
<div class=" self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
<a
|
||||
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
|
||||
href="https://openwebui.com/functions"
|
||||
target="_blank"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class=" self-center">
|
||||
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
|
||||
<div class=" text-sm line-clamp-1">
|
||||
{$i18n.t('Discover, download, and explore custom functions')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if $functions.length}
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
|
||||
on:click={async () => {
|
||||
const _functions = await exportFunctions(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (_functions) {
|
||||
let blob = new Blob([JSON.stringify(_functions)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `functions-export-${Date.now()}.json`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2 font-medium line-clamp-1">
|
||||
{$i18n.t('Export Functions')} ({$functions.length})
|
||||
</div>
|
||||
|
||||
<div class=" self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<div class=" my-16 px-[16px]">
|
||||
<div class=" text-xl font-medium mb-1 line-clamp-1">
|
||||
{$i18n.t('Made by Open WebUI Community')}
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete function?')}
|
||||
on:confirm={() => {
|
||||
deleteHandler(selectedFunction);
|
||||
}}
|
||||
>
|
||||
<div class=" text-sm text-gray-500 truncate">
|
||||
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<a
|
||||
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
|
||||
href="https://openwebui.com/functions"
|
||||
target="_blank"
|
||||
>
|
||||
<div class=" self-center">
|
||||
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
|
||||
<div class=" text-sm line-clamp-1">
|
||||
{$i18n.t('Discover, download, and explore custom functions')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete function?')}
|
||||
on:confirm={() => {
|
||||
deleteHandler(selectedFunction);
|
||||
}}
|
||||
>
|
||||
<div class=" text-sm text-gray-500">
|
||||
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<ManifestModal bind:show={showManifestModal} manifest={selectedFunction?.meta?.manifest ?? {}} />
|
||||
<ValvesModal
|
||||
bind:show={showValvesModal}
|
||||
type="function"
|
||||
id={selectedFunction?.id ?? null}
|
||||
on:save={async () => {
|
||||
await tick();
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showConfirm}
|
||||
on:confirm={() => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const _functions = JSON.parse(event.target.result);
|
||||
console.log(_functions);
|
||||
|
||||
for (let func of _functions) {
|
||||
if ('function' in func) {
|
||||
// Required for Community JSON import
|
||||
func = func.function;
|
||||
}
|
||||
|
||||
const res = await createNewFunction(localStorage.token, func).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
toast.success($i18n.t('Functions imported successfully'));
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
<ManifestModal bind:show={showManifestModal} manifest={selectedFunction?.meta?.manifest ?? {}} />
|
||||
<ValvesModal
|
||||
bind:show={showValvesModal}
|
||||
type="function"
|
||||
id={selectedFunction?.id ?? null}
|
||||
on:save={async () => {
|
||||
await tick();
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
@@ -648,25 +646,63 @@
|
||||
true
|
||||
)
|
||||
);
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
reader.readAsText(importFiles[0]);
|
||||
}}
|
||||
>
|
||||
<div class="text-sm text-gray-500">
|
||||
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
|
||||
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
|
||||
<ConfirmDialog
|
||||
bind:show={showConfirm}
|
||||
on:confirm={() => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const _functions = JSON.parse(event.target.result);
|
||||
console.log(_functions);
|
||||
|
||||
<ul class=" mt-1 list-disc pl-4 text-xs">
|
||||
<li>{$i18n.t('Functions allow arbitrary code execution.')}</li>
|
||||
<li>{$i18n.t('Do not install functions from sources you do not fully trust.')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
{$i18n.t(
|
||||
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
|
||||
)}
|
||||
for (let func of _functions) {
|
||||
if ('function' in func) {
|
||||
// Required for Community JSON import
|
||||
func = func.function;
|
||||
}
|
||||
|
||||
const res = await createNewFunction(localStorage.token, func).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
toast.success($i18n.t('Functions imported successfully'));
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
reader.readAsText(importFiles[0]);
|
||||
}}
|
||||
>
|
||||
<div class="text-sm text-gray-500">
|
||||
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
|
||||
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
|
||||
|
||||
<ul class=" mt-1 list-disc pl-4 text-xs">
|
||||
<li>{$i18n.t('Functions allow arbitrary code execution.')}</li>
|
||||
<li>{$i18n.t('Do not install functions from sources you do not fully trust.')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
{$i18n.t(
|
||||
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
{/if}
|
||||
|
||||
@@ -41,27 +41,27 @@
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
|
||||
sideOffset={-2}
|
||||
class="w-full max-w-[190px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
sideOffset={6}
|
||||
side="bottom"
|
||||
align="start"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<button
|
||||
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
on:click={async () => {
|
||||
createHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<PencilSolid />
|
||||
<Pencil />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('New Function')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
on:click={async () => {
|
||||
importFromLinkHandler();
|
||||
show = false;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
@@ -366,22 +367,20 @@ class Pipe:
|
||||
</div>
|
||||
|
||||
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
|
||||
{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
|
||||
<CodeEditor
|
||||
bind:this={codeEditor}
|
||||
value={content}
|
||||
lang="python"
|
||||
{boilerplate}
|
||||
onChange={(e) => {
|
||||
_content = e;
|
||||
}}
|
||||
onSave={async () => {
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
<CodeEditor
|
||||
bind:this={codeEditor}
|
||||
value={content}
|
||||
lang="python"
|
||||
{boilerplate}
|
||||
onChange={(e) => {
|
||||
_content = e;
|
||||
}}
|
||||
onSave={async () => {
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pb-3 flex justify-between">
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { i18n as i18nType } from 'i18next';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
|
||||
const i18n = getContext<Writable<i18nType>>('i18n');
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
let TTS_ENGINE = '';
|
||||
let TTS_MODEL = '';
|
||||
let TTS_VOICE = '';
|
||||
let TTS_OPENAI_PARAMS = '';
|
||||
let TTS_SPLIT_ON: TTS_RESPONSE_SPLIT = TTS_RESPONSE_SPLIT.PUNCTUATION;
|
||||
let TTS_AZURE_SPEECH_REGION = '';
|
||||
let TTS_AZURE_SPEECH_BASE_URL = '';
|
||||
@@ -98,18 +100,28 @@
|
||||
};
|
||||
|
||||
const updateConfigHandler = async () => {
|
||||
let openaiParams = {};
|
||||
try {
|
||||
openaiParams = TTS_OPENAI_PARAMS ? JSON.parse(TTS_OPENAI_PARAMS) : {};
|
||||
TTS_OPENAI_PARAMS = JSON.stringify(openaiParams, null, 2);
|
||||
} catch (e) {
|
||||
toast.error($i18n.t('Invalid JSON format for Parameters'));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await updateAudioConfig(localStorage.token, {
|
||||
tts: {
|
||||
OPENAI_API_BASE_URL: TTS_OPENAI_API_BASE_URL,
|
||||
OPENAI_API_KEY: TTS_OPENAI_API_KEY,
|
||||
OPENAI_PARAMS: openaiParams,
|
||||
API_KEY: TTS_API_KEY,
|
||||
ENGINE: TTS_ENGINE,
|
||||
MODEL: TTS_MODEL,
|
||||
VOICE: TTS_VOICE,
|
||||
SPLIT_ON: TTS_SPLIT_ON,
|
||||
AZURE_SPEECH_REGION: TTS_AZURE_SPEECH_REGION,
|
||||
AZURE_SPEECH_BASE_URL: TTS_AZURE_SPEECH_BASE_URL,
|
||||
AZURE_SPEECH_OUTPUT_FORMAT: TTS_AZURE_SPEECH_OUTPUT_FORMAT
|
||||
AZURE_SPEECH_OUTPUT_FORMAT: TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
SPLIT_ON: TTS_SPLIT_ON
|
||||
},
|
||||
stt: {
|
||||
OPENAI_API_BASE_URL: STT_OPENAI_API_BASE_URL,
|
||||
@@ -146,6 +158,7 @@
|
||||
console.log(res);
|
||||
TTS_OPENAI_API_BASE_URL = res.tts.OPENAI_API_BASE_URL;
|
||||
TTS_OPENAI_API_KEY = res.tts.OPENAI_API_KEY;
|
||||
TTS_OPENAI_PARAMS = JSON.stringify(res?.tts?.OPENAI_PARAMS ?? '', null, 2);
|
||||
TTS_API_KEY = res.tts.API_KEY;
|
||||
|
||||
TTS_ENGINE = res.tts.ENGINE;
|
||||
@@ -612,6 +625,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div class="w-full">
|
||||
<div class=" mb-1.5 text-xs font-medium">{$i18n.t('Additional Parameters')}</div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<Textarea
|
||||
className="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
bind:value={TTS_OPENAI_PARAMS}
|
||||
placeholder={$i18n.t('Enter additional parameters in JSON format')}
|
||||
minSize={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if TTS_ENGINE === 'elevenlabs'}
|
||||
<div class=" flex gap-2">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -207,6 +207,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mineru' &&
|
||||
RAGConfig.MINERU_API_MODE === 'cloud' &&
|
||||
RAGConfig.MINERU_API_KEY === ''
|
||||
) {
|
||||
toast.error($i18n.t('MinerU API Key required for Cloud API mode.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RAGConfig.BYPASS_EMBEDDING_AND_RETRIEVAL) {
|
||||
await embeddingModelUpdateHandler();
|
||||
}
|
||||
@@ -337,6 +346,7 @@
|
||||
<option value="datalab_marker">{$i18n.t('Datalab Marker API')}</option>
|
||||
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
|
||||
<option value="mistral_ocr">{$i18n.t('Mistral OCR')}</option>
|
||||
<option value="mineru">{$i18n.t('MinerU')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -714,6 +724,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between w-full mt-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
<Tooltip content={''} placement="top-start">
|
||||
{$i18n.t('Parameters')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="">
|
||||
<Textarea
|
||||
bind:value={RAGConfig.DOCLING_PARAMETERS}
|
||||
placeholder={$i18n.t('Enter additional parameters in JSON format')}
|
||||
minSize={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
|
||||
<div class="my-0.5 flex gap-2 pr-2">
|
||||
<input
|
||||
@@ -734,6 +759,92 @@
|
||||
bind:value={RAGConfig.MISTRAL_OCR_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mineru'}
|
||||
<!-- API Mode Selection -->
|
||||
<div class="flex w-full mt-2">
|
||||
<div class="flex-1 flex justify-between">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('API Mode')}
|
||||
</div>
|
||||
<select
|
||||
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden"
|
||||
bind:value={RAGConfig.MINERU_API_MODE}
|
||||
on:change={() => {
|
||||
// Auto-update URL when switching modes if it's empty or matches the opposite mode's default
|
||||
const cloudUrl = 'https://mineru.net/api/v4';
|
||||
const localUrl = 'http://localhost:8000';
|
||||
|
||||
if (RAGConfig.MINERU_API_MODE === 'cloud') {
|
||||
if (!RAGConfig.MINERU_API_URL || RAGConfig.MINERU_API_URL === localUrl) {
|
||||
RAGConfig.MINERU_API_URL = cloudUrl;
|
||||
}
|
||||
} else {
|
||||
if (!RAGConfig.MINERU_API_URL || RAGConfig.MINERU_API_URL === cloudUrl) {
|
||||
RAGConfig.MINERU_API_URL = localUrl;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="local">{$i18n.t('Self-Hosted')}</option>
|
||||
<option value="cloud">{$i18n.t('minerU managed (Cloud API)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API URL -->
|
||||
<div class="flex w-full mt-2">
|
||||
<input
|
||||
class="flex-1 w-full text-sm bg-transparent outline-hidden"
|
||||
placeholder={RAGConfig.MINERU_API_MODE === 'cloud'
|
||||
? $i18n.t('https://mineru.net/api/v4')
|
||||
: $i18n.t('http://localhost:8000')}
|
||||
bind:value={RAGConfig.MINERU_API_URL}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API Key (Cloud only) -->
|
||||
{#if RAGConfig.MINERU_API_MODE === 'cloud'}
|
||||
<div class="flex w-full mt-2">
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter MinerU API Key')}
|
||||
bind:value={RAGConfig.MINERU_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Parameters -->
|
||||
<div class="flex justify-between w-full mt-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Advanced parameters for MinerU parsing (enable_ocr, enable_formula, enable_table, language, model_version, page_ranges)'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t('Parameters')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="">
|
||||
<Textarea
|
||||
value={typeof RAGConfig.MINERU_PARAMS === 'object' &&
|
||||
RAGConfig.MINERU_PARAMS !== null &&
|
||||
Object.keys(RAGConfig.MINERU_PARAMS).length > 0
|
||||
? JSON.stringify(RAGConfig.MINERU_PARAMS, null, 2)
|
||||
: ''}
|
||||
on:input={(e) => {
|
||||
try {
|
||||
const value = e.target.value.trim();
|
||||
RAGConfig.MINERU_PARAMS = value ? JSON.parse(value) : {};
|
||||
} catch (err) {
|
||||
// Keep the string value if JSON is invalid (user is still typing)
|
||||
RAGConfig.MINERU_PARAMS = e.target.value;
|
||||
}
|
||||
}}
|
||||
placeholder={`{\n "enable_ocr": false,\n "enable_formula": true,\n "enable_table": true,\n "language": "en",\n "model_version": "pipeline",\n "page_ranges": ""\n}`}
|
||||
minSize={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -963,7 +1074,7 @@
|
||||
|
||||
<div class="mt-1 mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t(
|
||||
'Warning: If you update or change your embedding model, you will need to re-import all documents.'
|
||||
'After updating or changing the embedding model, you must reindex the knowledge base for the changes to take effect. You can do this using the "Reindex" button below.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1143,7 +1254,7 @@
|
||||
<div class=" mb-2.5 py-0.5 w-full justify-between">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5'
|
||||
'The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5'
|
||||
)}
|
||||
placement="top-start"
|
||||
className="inline-tooltip"
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2 -mx-2">
|
||||
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
|
||||
<AccessControl bind:accessControl />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="shrink-0 line-clamp-1">
|
||||
<div class=" line-clamp-1">
|
||||
{model.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,6 +399,26 @@
|
||||
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if adminConfig.JWT_EXPIRES_IN === '-1'}
|
||||
<div class="mt-2 text-xs">
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<span class=" font-medium">{$i18n.t('Warning')}:</span>
|
||||
<span
|
||||
><a
|
||||
href="https://docs.openwebui.com/getting-started/env-configuration#jwt_expires_in"
|
||||
target="_blank"
|
||||
class=" underline"
|
||||
>{$i18n.t('No expiration can pose security risks.')}
|
||||
</a></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class=" space-y-3">
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
deleteAllModels,
|
||||
getBaseModels,
|
||||
toggleModelById,
|
||||
updateModelById
|
||||
updateModelById,
|
||||
importModels
|
||||
} from '$lib/apis/models';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { page } from '$app/stores';
|
||||
@@ -40,6 +41,7 @@
|
||||
|
||||
let shiftKey = false;
|
||||
|
||||
let modelsImportInProgress = false;
|
||||
let importFiles;
|
||||
let modelsImportInputElement: HTMLInputElement;
|
||||
|
||||
@@ -464,47 +466,41 @@
|
||||
accept=".json"
|
||||
hidden
|
||||
on:change={() => {
|
||||
console.log(importFiles);
|
||||
if (importFiles.length > 0) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const models = JSON.parse(String(event.target.result));
|
||||
modelsImportInProgress = true;
|
||||
const res = await importModels(localStorage.token, models);
|
||||
modelsImportInProgress = false;
|
||||
|
||||
let reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
let savedModels = JSON.parse(event.target.result);
|
||||
console.log(savedModels);
|
||||
|
||||
for (const model of savedModels) {
|
||||
if (Object.keys(model).includes('base_model_id')) {
|
||||
if (model.base_model_id === null) {
|
||||
upsertModelHandler(model);
|
||||
}
|
||||
} else {
|
||||
if (model?.info ?? false) {
|
||||
if (model.info.base_model_id === null) {
|
||||
upsertModelHandler(model.info);
|
||||
}
|
||||
if (res) {
|
||||
toast.success($i18n.t('Models imported successfully'));
|
||||
await init();
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to import models'));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error($i18n.t('Invalid JSON file'));
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
await _models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections &&
|
||||
($settings?.directConnections ?? null)
|
||||
)
|
||||
);
|
||||
init();
|
||||
};
|
||||
|
||||
reader.readAsText(importFiles[0]);
|
||||
};
|
||||
reader.readAsText(importFiles[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
|
||||
disabled={modelsImportInProgress}
|
||||
on:click={() => {
|
||||
modelsImportInputElement.click();
|
||||
}}
|
||||
>
|
||||
{#if modelsImportInProgress}
|
||||
<Spinner className="size-3" />
|
||||
{/if}
|
||||
<div class=" self-center mr-2 font-medium line-clamp-1">
|
||||
{$i18n.t('Import Presets')}
|
||||
</div>
|
||||
|
||||
@@ -36,11 +36,13 @@
|
||||
|
||||
let updateModelId = null;
|
||||
let updateProgress = null;
|
||||
let updateModelsControllers = {};
|
||||
let updateCancelled = false;
|
||||
let showExperimentalOllama = false;
|
||||
|
||||
const MAX_PARALLEL_DOWNLOADS = 3;
|
||||
|
||||
let modelTransferring = false;
|
||||
let modelLoading = false;
|
||||
let modelTag = '';
|
||||
|
||||
let createModelLoading = false;
|
||||
@@ -65,17 +67,31 @@
|
||||
let deleteModelTag = '';
|
||||
|
||||
const updateModelsHandler = async () => {
|
||||
updateCancelled = false;
|
||||
toast.info('Checking for model updates...');
|
||||
|
||||
for (const model of ollamaModels) {
|
||||
if (updateCancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
console.debug(model);
|
||||
|
||||
updateModelId = model.id;
|
||||
const [res, controller] = await pullModel(localStorage.token, model.id, urlIdx).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
if (error.name !== 'AbortError') {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
return [null, null];
|
||||
}
|
||||
);
|
||||
|
||||
updateModelsControllers = {
|
||||
...updateModelsControllers,
|
||||
[model.id]: controller
|
||||
};
|
||||
|
||||
if (res) {
|
||||
const reader = res.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
@@ -108,19 +124,28 @@
|
||||
} else {
|
||||
updateProgress = 100;
|
||||
}
|
||||
} else {
|
||||
toast.success(data.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error(err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete updateModelsControllers[model.id];
|
||||
updateModelsControllers = { ...updateModelsControllers };
|
||||
}
|
||||
|
||||
if (updateCancelled) {
|
||||
toast.info('Model update cancelled');
|
||||
} else {
|
||||
toast.success('All models are up to date');
|
||||
}
|
||||
updateModelId = null;
|
||||
updateProgress = null;
|
||||
};
|
||||
@@ -143,10 +168,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
modelLoading = true;
|
||||
const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, urlIdx).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
if (error.name !== 'AbortError') {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
return [null, null];
|
||||
}
|
||||
);
|
||||
|
||||
@@ -202,8 +230,6 @@
|
||||
}
|
||||
});
|
||||
} else {
|
||||
toast.success(data.status);
|
||||
|
||||
MODEL_DOWNLOAD_POOL.set({
|
||||
...$MODEL_DOWNLOAD_POOL,
|
||||
[sanitizedModelTag]: {
|
||||
@@ -216,19 +242,23 @@
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (typeof err !== 'string') {
|
||||
err = err.message;
|
||||
}
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error(err);
|
||||
if (typeof err !== 'string') {
|
||||
err = err.message;
|
||||
}
|
||||
|
||||
toast.error(`${err}`);
|
||||
// opts.callback({ success: false, error, modelName: opts.modelName });
|
||||
toast.error(`${err}`);
|
||||
// opts.callback({ success: false, error, modelName: opts.modelName });
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log($MODEL_DOWNLOAD_POOL[sanitizedModelTag]);
|
||||
|
||||
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
|
||||
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]?.done) {
|
||||
toast.success(
|
||||
$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
|
||||
modelName: sanitizedModelTag
|
||||
@@ -253,11 +283,11 @@
|
||||
}
|
||||
|
||||
modelTag = '';
|
||||
modelTransferring = false;
|
||||
modelLoading = false;
|
||||
};
|
||||
|
||||
const uploadModelHandler = async () => {
|
||||
modelTransferring = true;
|
||||
modelLoading = true;
|
||||
|
||||
let uploaded = false;
|
||||
let fileResponse = null;
|
||||
@@ -396,7 +426,7 @@
|
||||
modelUploadInputElement.value = '';
|
||||
}
|
||||
modelInputFile = null;
|
||||
modelTransferring = false;
|
||||
modelLoading = false;
|
||||
uploadProgress = null;
|
||||
|
||||
models.set(
|
||||
@@ -425,6 +455,14 @@
|
||||
);
|
||||
};
|
||||
|
||||
const cancelUpdateModelHandler = async (model: string) => {
|
||||
const controller = updateModelsControllers[model];
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
updateCancelled = true;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelModelPullHandler = async (model: string) => {
|
||||
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
|
||||
if (abortController) {
|
||||
@@ -605,59 +643,61 @@
|
||||
bind:value={modelTag}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
|
||||
on:click={() => {
|
||||
pullModelHandler();
|
||||
}}
|
||||
disabled={modelTransferring}
|
||||
>
|
||||
{#if modelTransferring}
|
||||
<div class="self-center">
|
||||
<svg
|
||||
class=" w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.75s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
<Tooltip content={$i18n.t('Pull Model')} placement="top">
|
||||
<button
|
||||
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
|
||||
on:click={() => {
|
||||
pullModelHandler();
|
||||
}}
|
||||
disabled={modelLoading || modelTag.trim() === ''}
|
||||
>
|
||||
{#if modelLoading}
|
||||
<div class="self-center">
|
||||
<svg
|
||||
class=" w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.75s infinite linear;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
/>
|
||||
<path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
@@ -670,8 +710,35 @@
|
||||
</div>
|
||||
|
||||
{#if updateModelId}
|
||||
<div class="text-xs">
|
||||
Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''}
|
||||
<div class="text-xs flex justify-between items-center">
|
||||
<div>Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''}</div>
|
||||
|
||||
<Tooltip content={$i18n.t('Cancel')}>
|
||||
<button
|
||||
class="text-gray-800 dark:text-gray-100"
|
||||
on:click={() => {
|
||||
cancelUpdateModelHandler(updateModelId);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-800 dark:text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18 17.94 6M18 18 6.06 6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -754,25 +821,28 @@
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
|
||||
on:click={() => {
|
||||
showModelDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
<Tooltip content={$i18n.t('Delete Model')} placement="top">
|
||||
<button
|
||||
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
|
||||
on:click={() => {
|
||||
showModelDeleteConfirm = true;
|
||||
}}
|
||||
disabled={deleteModelTag === ''}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -799,27 +869,31 @@
|
||||
</div>
|
||||
|
||||
<div class="flex self-start">
|
||||
<button
|
||||
class="px-2.5 py-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed"
|
||||
on:click={() => {
|
||||
createModelHandler();
|
||||
}}
|
||||
disabled={createModelLoading}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
<Tooltip content={$i18n.t('Create Model')} placement="top">
|
||||
<button
|
||||
class="px-2.5 py-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed"
|
||||
on:click={() => {
|
||||
createModelHandler();
|
||||
}}
|
||||
disabled={createModelLoading ||
|
||||
createModelName.trim() === '' ||
|
||||
createModelObject.trim() === ''}
|
||||
>
|
||||
<path
|
||||
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -936,57 +1010,59 @@
|
||||
</div>
|
||||
|
||||
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
|
||||
<button
|
||||
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
|
||||
type="submit"
|
||||
disabled={modelTransferring}
|
||||
>
|
||||
{#if modelTransferring}
|
||||
<div class="self-center">
|
||||
<svg
|
||||
class=" w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.75s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
<Tooltip content={$i18n.t('Upload Model')} placement="top">
|
||||
<button
|
||||
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
|
||||
type="submit"
|
||||
disabled={modelLoading}
|
||||
>
|
||||
{#if modelLoading}
|
||||
<div class="self-center">
|
||||
<svg
|
||||
class=" w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.75s infinite linear;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
/>
|
||||
<path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
|
||||
/>
|
||||
<path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
|
||||
|
||||
import AddServerModal from '$lib/components/AddServerModal.svelte';
|
||||
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
|
||||
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
|
||||
|
||||
export let saveSettings: Function;
|
||||
@@ -47,7 +47,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
|
||||
<form
|
||||
class="flex flex-col h-full justify-between text-sm"
|
||||
@@ -83,7 +83,7 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each servers as server, idx}
|
||||
<Connection
|
||||
bind:connection={server}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
export let saveHandler: Function;
|
||||
|
||||
let webSearchEngines = [
|
||||
'ollama_cloud',
|
||||
'perplexity_search',
|
||||
'searxng',
|
||||
'yacy',
|
||||
'google_pse',
|
||||
@@ -130,7 +132,41 @@
|
||||
</div>
|
||||
|
||||
{#if webConfig.WEB_SEARCH_ENGINE !== ''}
|
||||
{#if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
|
||||
{#if webConfig.WEB_SEARCH_ENGINE === 'ollama_cloud'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Ollama Cloud API Key')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Ollama Cloud API Key')}
|
||||
bind:value={webConfig.OLLAMA_CLOUD_WEB_SEARCH_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'perplexity_search'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Perplexity API Key')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Perplexity API Key')}
|
||||
bind:value={webConfig.PERPLEXITY_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user