Compare commits
1226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d342a5060c | |||
| 437d0cf20f | |||
| 02dc3e689c | |||
| 1a97751e37 | |||
| b868da6bcd | |||
| dc4dab7a07 | |||
| 90f4b4fcda | |||
| 4be25c4fdd | |||
| b3e9d16b6f | |||
| de2360ac07 | |||
| 8862210139 | |||
| 5062f9958f | |||
| eb629866fe | |||
| 54149903f0 | |||
| 02b2a391e9 | |||
| c73d9da773 | |||
| eb38389636 | |||
| eebbc48f80 | |||
| cff51f05f5 | |||
| a4735e46b9 | |||
| 6c8dfd8175 | |||
| caaa99c7d3 | |||
| 6fce92aa12 | |||
| 1f49ebf431 | |||
| bfe5523c52 | |||
| 77c8c54b1e | |||
| 478bc9e3f1 | |||
| 675e9bee5a | |||
| 80da840ae5 | |||
| 974b13839e | |||
| 886c132a10 | |||
| c7de057a4a | |||
| 750604a11d | |||
| 69c88e163d | |||
| c8eb8edca4 | |||
| 33e4e0dcc4 | |||
| 7f7cd21018 | |||
| ce4dca47cb | |||
| 160a6694e4 | |||
| 4705c2d988 | |||
| 778dba1d6b | |||
| 27fb20c13a | |||
| eb4eebc3ce | |||
| d64ef1803d | |||
| 346dab3d8f | |||
| 01810e32ad | |||
| a9da054572 | |||
| 42c2393f8e | |||
| e90a618f45 | |||
| 4297c02b12 | |||
| 9035601bdb | |||
| e3ab4bd212 | |||
| fd76b51ab2 | |||
| c0f1aa2919 | |||
| 567c4aabe9 | |||
| 8644532f5b | |||
| 9a3eea6448 | |||
| e623081b2b | |||
| 507b8b213c | |||
| c93f071700 | |||
| 76947ff926 | |||
| a089842368 | |||
| 558ea2a152 | |||
| ad9f2eeb15 | |||
| 1010fd3142 | |||
| 1bbb2b933d | |||
| cc15a01778 | |||
| ceb1cbc009 | |||
| bddadafa07 | |||
| 936d5f2676 | |||
| 309caa82fb | |||
| b0fa4384ea | |||
| 07cbc91a8e | |||
| 0e73f7af09 | |||
| 02b65ea582 | |||
| 7594823edd | |||
| 83890f18b9 | |||
| e2502ec80f | |||
| 6f0277db52 | |||
| 55ca719bbf | |||
| 9e3e24e304 | |||
| b64fd988f0 | |||
| 229e65b9f0 | |||
| bbd3e13094 | |||
| 16e7827134 | |||
| 5028ba18ee | |||
| 7139797be0 | |||
| ee47c9c833 | |||
| 0354775917 | |||
| d4030a8aa5 | |||
| 1f0948bcbe | |||
| 746979d012 | |||
| 18b1a041dd | |||
| 1608a04fa9 | |||
| 5112e0b62c | |||
| f16b5c4460 | |||
| 34b8844ccf | |||
| b52d142c8d | |||
| 4719881105 | |||
| f2650353da | |||
| 76b0e65818 | |||
| 9c9e4f1288 | |||
| 26b1529a45 | |||
| c665d4a7c6 | |||
| c428ad0c1a | |||
| 4b33d7ebc1 | |||
| bf6325ff33 | |||
| 690d6e5eb1 | |||
| 81d4ed79ae | |||
| 4923920bf1 | |||
| 3f1c52e018 | |||
| f5f4b58958 | |||
| 78b1637a03 | |||
| 84659035f0 | |||
| 91810f1c4e | |||
| 591e0aafa1 | |||
| e61bcc8500 | |||
| 66126f3861 | |||
| 79bf3d28d8 | |||
| 9f20687680 | |||
| 48ea043f82 | |||
| a4d1b3e937 | |||
| 5d9a09a88a | |||
| 9a347b0376 | |||
| 42783881e9 | |||
| fb16e28d28 | |||
| 470a074cd1 | |||
| b94245d2ee | |||
| 1acfbb6755 | |||
| d8b5b9fa79 | |||
| 260ead64da | |||
| cac4c6da2e | |||
| 154679200f | |||
| 1527eb6e01 | |||
| 8f2d346e10 | |||
| 97252fa609 | |||
| 9835b3f1dd | |||
| 73bdf86766 | |||
| 60c9db1cb8 | |||
| d0b17f0569 | |||
| b9911c8bc6 | |||
| c8f851bd2d | |||
| 8a104a7ab1 | |||
| 2b99945d27 | |||
| d07fd7d6d8 | |||
| 854440f703 | |||
| cfa6908d57 | |||
| eb3076c1b0 | |||
| d169f086da | |||
| 5d104abd08 | |||
| a803372805 | |||
| 56c0d00e13 | |||
| 3c5e7968f0 | |||
| 94b66b1797 | |||
| ed73ef3d8d | |||
| f02aeea0bb | |||
| 5401e8560b | |||
| ea058841c9 | |||
| c48ac5163c | |||
| 2ca91ceeec | |||
| c306a7e16e | |||
| cc94a90b4d | |||
| 1fa3050f06 | |||
| 1e36a20600 | |||
| 0adc090dcb | |||
| f216bfab63 | |||
| 92e727b1fc | |||
| 557df60c3b | |||
| 7e9d41d664 | |||
| 4f89617192 | |||
| aa06200f78 | |||
| c75fe8e74b | |||
| 0367ad54c5 | |||
| ef975649b2 | |||
| d310a0777c | |||
| 359590ca9d | |||
| eba1540a95 | |||
| ae48838b04 | |||
| 6360af36d8 | |||
| 1ded5ce969 | |||
| 0cd1529008 | |||
| 9263b7568e | |||
| 73d2065227 | |||
| d7cfc1e46a | |||
| 9717ada92f | |||
| 9a8969ca93 | |||
| 2e1b671e8d | |||
| db2b3d7fd8 | |||
| 1004dad274 | |||
| ee3b14233a | |||
| dc0f8ae6f2 | |||
| 1b9d22e324 | |||
| 98d3b23085 | |||
| 6b1df94bf9 | |||
| 95840e307a | |||
| 6df09a4039 | |||
| 459b1c3fda | |||
| bd9f82d5a6 | |||
| e5c8f8110a | |||
| 9c14740ffb | |||
| 74ae177d79 | |||
| 1ea54c3217 | |||
| f0e88dadc8 | |||
| 5cc1eb5170 | |||
| ae06e199d5 | |||
| cc16e06c32 | |||
| 81f611fb73 | |||
| 245e0ee029 | |||
| ba78ebea27 | |||
| f607337582 | |||
| 2a7524056a | |||
| 0214c8db24 | |||
| 8aa2a42dc7 | |||
| f6d8e1e38d | |||
| 7dcd932ad7 | |||
| be301c1c27 | |||
| 5ab32a5086 | |||
| 42c86e7283 | |||
| 74f95a9b0d | |||
| 0b81520e07 | |||
| 4e78b355ef | |||
| d74ee34d91 | |||
| 3127f1b462 | |||
| ab0ee858b7 | |||
| 32a417bbf6 | |||
| e7d2ddbb1d | |||
| 171150c1e1 | |||
| 2ad327a4dc | |||
| d6dae36da0 | |||
| c2cbc47ca7 | |||
| 8ace73da84 | |||
| 3b00e5721a | |||
| 2f642754ac | |||
| 9ef579ce4b | |||
| ecec86dd32 | |||
| 5b125c24d4 | |||
| bc244fdc90 | |||
| 6d0295588e | |||
| a59c967d7e | |||
| 998c86a52b | |||
| 83736e8dbd | |||
| 4cee7c29a5 | |||
| 1413ce4a52 | |||
| c79ff81f55 | |||
| 2c2d06c31b | |||
| 4527c747b4 | |||
| 9c18e707ce | |||
| 070ab26501 | |||
| 90a02e623f | |||
| 18ff358c15 | |||
| 3660bc00fd | |||
| 41b48b54aa | |||
| 4856ce48be | |||
| 0037baeb26 | |||
| c951b4f262 | |||
| 39777e35d8 | |||
| 15e696691c | |||
| 3a21b334cc | |||
| 2dbf7b6764 | |||
| 4a1064cefd | |||
| 315566064a | |||
| 3bba1c2270 | |||
| 1388f4568b | |||
| d1ef538237 | |||
| fc94118b2d | |||
| 8d3133fe28 | |||
| d11e06f1b7 | |||
| 841c9045d7 | |||
| f5e110fbee | |||
| e8e9141061 | |||
| d3737176bc | |||
| 2d9939ed49 | |||
| c66c273f62 | |||
| e7ba8978c6 | |||
| 5b13e3e3f0 | |||
| b0a56375d2 | |||
| 8689f7090f | |||
| 55535a8965 | |||
| 203ec29baf | |||
| 9918ab6265 | |||
| 8a0018cf96 | |||
| 69270e1c9e | |||
| 2e71b3fbb8 | |||
| df42d96c95 | |||
| 2fa3b84241 | |||
| 8854541508 | |||
| 793e628ac3 | |||
| a0268e51fc | |||
| f51d2b026f | |||
| 0f07af1bb8 | |||
| 7d3efb8513 | |||
| adc9076d17 | |||
| 413dcae8a2 | |||
| d34d4297ba | |||
| 6116c6dca0 | |||
| 93931efaa7 | |||
| 3ccf263b10 | |||
| 75e72ea2f9 | |||
| b94aad2895 | |||
| 7bcc0e2e5c | |||
| 46ff3abbb8 | |||
| aa51ce482c | |||
| 251b80ebec | |||
| 4d99baa292 | |||
| 3fcad2f627 | |||
| 04bd0425ea | |||
| 485d689cfd | |||
| 85c7373f68 | |||
| 11e076817a | |||
| cfd2888545 | |||
| adda20509c | |||
| e1dce99147 | |||
| a938c8ae2e | |||
| 5b80932e59 | |||
| 2ba6b423aa | |||
| 02f9fe7890 | |||
| 29f6c72e87 | |||
| bb0e6cb108 | |||
| 6700f7bb72 | |||
| 1baf73bdd5 | |||
| 1d892ce2c5 | |||
| 794b97025d | |||
| ee3b82926b | |||
| 38a382ef88 | |||
| 34146ab60f | |||
| f70b0da156 | |||
| af5628f8ef | |||
| 9907c0a25a | |||
| d78c247036 | |||
| ae0827cec0 | |||
| 064fdecb67 | |||
| bf4f44ee9c | |||
| 212bb68a66 | |||
| aff78e4958 | |||
| ae43562b86 | |||
| 13693554f1 | |||
| 1a3e5ef4c1 | |||
| 26b1a3d7dc | |||
| 3ab7b777b1 | |||
| 1789303886 | |||
| 41107a34ca | |||
| 114c99ae2f | |||
| 3746339cfc | |||
| 7eeff2fdf9 | |||
| 55e7c7854b | |||
| c978a788c8 | |||
| 3d48596c9e | |||
| 072d2000f3 | |||
| 9386fc83a3 | |||
| b63da90ae4 | |||
| 23ff9943a9 | |||
| 33e588cf09 | |||
| 005df577fe | |||
| 1b4cd705d0 | |||
| 8ffc3d746f | |||
| c1202a2327 | |||
| 55d1db1f38 | |||
| 6082e1adae | |||
| c6763521c0 | |||
| 7c398a625a | |||
| cdfcbc4af6 | |||
| 4d766a3edf | |||
| d06e6d6ddc | |||
| 8b78821ba4 | |||
| 552bbcecfa | |||
| f152ad36b3 | |||
| 60ea4214aa | |||
| bc4d6eef33 | |||
| cde72dab71 | |||
| ff791b4814 | |||
| 7eaecbad5a | |||
| 0103d7e82c | |||
| 3309f5d9f1 | |||
| e451f8f63b | |||
| 6dff85b9d2 | |||
| 7e275c1daa | |||
| 1c1c8b18e5 | |||
| 55a572cd39 | |||
| 9adc0c442a | |||
| fd3368c0bf | |||
| ef6d4f2d6c | |||
| 6bdc2ffa79 | |||
| b72019db39 | |||
| 2977910ffd | |||
| f39f4a86ae | |||
| 1dee67b64d | |||
| 2a18dc98ac | |||
| 1f977d072e | |||
| 4754ece4a2 | |||
| 5c3edc2539 | |||
| 4fe2de7864 | |||
| a32d26e61d | |||
| 989d5fd4e2 | |||
| 4e6a7baab7 | |||
| cde21b9f6d | |||
| 86df8bf27e | |||
| 5bc80b145f | |||
| 8dae237a0b | |||
| 4e2240aada | |||
| f93d20ac42 | |||
| 8ff7ff459b | |||
| f48b8ffbf0 | |||
| 3560d2f630 | |||
| 3aeb691d98 | |||
| e0d6074cd2 | |||
| b1bd3084f0 | |||
| 70b28b629e | |||
| 3d1e355df7 | |||
| 7102a63c82 | |||
| 1cea8ec7d4 | |||
| a7a92d2d9b | |||
| 2419899ac6 | |||
| 60f67c7c17 | |||
| 34a55d4524 | |||
| 9771898c58 | |||
| 752238247c | |||
| 3e14524154 | |||
| d8b55afb00 | |||
| 62693938a3 | |||
| 465d6fe514 | |||
| d6b73ea2f2 | |||
| 5774ab4984 | |||
| db05fdaf83 | |||
| d740b545a4 | |||
| 678c44c7cd | |||
| 5cc55e2278 | |||
| 26711c1bcc | |||
| f2cb63140c | |||
| a76a779c01 | |||
| a766521933 | |||
| 7da6b82471 | |||
| 6089de0b17 | |||
| b87c755574 | |||
| b73538ece7 | |||
| 258e9f917b | |||
| 6ecba19447 | |||
| 90584ab6f3 | |||
| 58bc254809 | |||
| 0e311a95a7 | |||
| d0e51bde5d | |||
| 4dc5c1eb4f | |||
| 89669f3fa1 | |||
| f6bd08c852 | |||
| 9b577868c8 | |||
| 91d9870266 | |||
| d47993385a | |||
| a4eb10269e | |||
| ef77c3d45b | |||
| 83f3a9c543 | |||
| d56d74b387 | |||
| 0a8a620fb6 | |||
| f162d4de90 | |||
| 9f61a6f13c | |||
| f31768e20e | |||
| 493f238431 | |||
| 3b821e1f3a | |||
| 085d3cb1c9 | |||
| 116eb7fc55 | |||
| 65f55847a1 | |||
| 0542df147a | |||
| 6cc799b1bb | |||
| b9fc3f367a | |||
| c7b6de6ca4 | |||
| 5f76c250f8 | |||
| 0e3135f8dc | |||
| 7fd94b0e73 | |||
| c4aac0415c | |||
| a27916d1db | |||
| f485309fd6 | |||
| 65834432a3 | |||
| 46d73c9dcd | |||
| a2875f13c6 | |||
| 81383a7df1 | |||
| 4790faba73 | |||
| e88e565ab4 | |||
| 7ddb9700ff | |||
| b645b0dc23 | |||
| 51627555bf | |||
| 1824e69a70 | |||
| 5127354b3e | |||
| dc6df52a91 | |||
| f246a66810 | |||
| c3c857a3ec | |||
| 47329b5032 | |||
| d5e69f182c | |||
| e29d145a1c | |||
| b3ca943da1 | |||
| 56c5bc1d34 | |||
| fd25152076 | |||
| 51cd43229c | |||
| 0d10b946f5 | |||
| 28963815d1 | |||
| 3e3f138d93 | |||
| 4e31fa4427 | |||
| 24dd5b461e | |||
| 1d501cfa3f | |||
| f6d1969067 | |||
| eb16ae92a5 | |||
| 73e28c9393 | |||
| 4198f36c01 | |||
| ec9c066961 | |||
| a05a769938 | |||
| e5b5a17426 | |||
| 29ee53aaa5 | |||
| 60f3ba6b59 | |||
| b7b7b64d31 | |||
| 37eba1c5a6 | |||
| 8c9f267ad2 | |||
| 5afc258c5b | |||
| 42694c7c0c | |||
| 98c4f264e4 | |||
| f45d0f130e | |||
| 98627e42b4 | |||
| f0ec5ee08f | |||
| 4a5401b417 | |||
| 8d739e2aba | |||
| 5087492e25 | |||
| a4d62253df | |||
| 7cfb260b8a | |||
| 49430de42d | |||
| 1be9627dd2 | |||
| f0e0cfcf02 | |||
| 55bfc7cbc2 | |||
| 4113b15a60 | |||
| e8e655d0de | |||
| e5f31c2e14 | |||
| c5276f62d0 | |||
| 8acce144f9 | |||
| e7e752f8e7 | |||
| f44b7a01f5 | |||
| 2c7acb9285 | |||
| 50363ba66b | |||
| 860b90fd17 | |||
| 914ccf07ef | |||
| ba83613ff2 | |||
| 499129625b | |||
| 3271b013a8 | |||
| 638c7ab802 | |||
| 4023f6722b | |||
| e695d854f2 | |||
| 34d569d564 | |||
| e709d6812f | |||
| 3dd8255816 | |||
| 32cfb5788a | |||
| 2dca850cee | |||
| 349ea4ea9e | |||
| 3c22afc5a6 | |||
| 7e453de4f7 | |||
| f1ef09ddc8 | |||
| 7b5880ab9e | |||
| 3332878321 | |||
| e396af3cc8 | |||
| 7cc7b367dc | |||
| 5eae0a5cdd | |||
| 0e50d2b741 | |||
| 7aed64be3c | |||
| 43e5905c13 | |||
| 128cf41fce | |||
| 398718d505 | |||
| bd35809105 | |||
| 2e52ad8ff2 | |||
| 4d2f189810 | |||
| 70a6a24f14 | |||
| 2f9e326dba | |||
| a4251d7e45 | |||
| 82755acdfd | |||
| c27aa569c8 | |||
| ee1bc5f5db | |||
| 3f40d9da70 | |||
| e6297cf414 | |||
| 5944eda0ff | |||
| 1860874192 | |||
| 5dae600ce7 | |||
| f1be85d997 | |||
| ecd74f220c | |||
| 8bd23b9145 | |||
| a4ed16999e | |||
| f102060a6d | |||
| 26a645f9e6 | |||
| fd93bd3414 | |||
| a3ea7bf043 | |||
| 4866bec0f2 | |||
| 804f9f3153 | |||
| ee28032fb9 | |||
| 37658fd541 | |||
| cced77b584 | |||
| f685edd161 | |||
| 18fe17127a | |||
| a209f7f6e0 | |||
| 45e49d33e5 | |||
| 9a8c4da67d | |||
| 39ea7bf63d | |||
| 84ec43105c | |||
| cf4218e688 | |||
| 33a4d1b412 | |||
| c8ef7b0289 | |||
| c767bcaa73 | |||
| 2943955c52 | |||
| 715cf9797a | |||
| 8979987eed | |||
| cd55c3e212 | |||
| 8dba798cce | |||
| 9dccd29c94 | |||
| 026903399b | |||
| 2991d9f1f0 | |||
| 611fe0c8a9 | |||
| 31406caa79 | |||
| 9c64d84ad9 | |||
| 40f5b3d135 | |||
| 869cf9e848 | |||
| 2ddcb30b9a | |||
| 96265cf042 | |||
| 050c4b97a9 | |||
| d0188f3fe1 | |||
| 45f45f5bba | |||
| 8936721414 | |||
| d1a0fbe292 | |||
| 22cfb3c673 | |||
| 51765b619c | |||
| 20544d412e | |||
| cb6e77be3e | |||
| d4b90f93bd | |||
| 26b8ca5b5e | |||
| 57784706e4 | |||
| fc98000aa8 | |||
| 21cc828132 | |||
| 8172c7e3d5 | |||
| 498ff8cdc3 | |||
| e10a00132e | |||
| facb194a07 | |||
| c3c8c605d7 | |||
| 3c2c611ba9 | |||
| a359262616 | |||
| d59b933bf2 | |||
| a7d4c53f3a | |||
| 25898116ea | |||
| 4292358bd5 | |||
| 67023037f8 | |||
| e7ff4768f8 | |||
| c47dd7b771 | |||
| 4498e6faf2 | |||
| 674c1127e2 | |||
| 15b89b9218 | |||
| 008f1dfbda | |||
| 47d413ce7b | |||
| 0753409e7b | |||
| b78dabb442 | |||
| 83024d00bb | |||
| 4f94d21780 | |||
| 5ee791d5d2 | |||
| fb5ef978bf | |||
| 977d638afe | |||
| de27a12151 | |||
| f6b85700ea | |||
| d40f31982b | |||
| 27169124f2 | |||
| b618d84065 | |||
| 15f9a8f3f1 | |||
| a2a9a3a42a | |||
| 4498c21f4c | |||
| d3df8f1f37 | |||
| 96a0b3239b | |||
| 36a81ad43b | |||
| 71a39dbac1 | |||
| 5eab125f13 | |||
| e790e7be7a | |||
| b0df527224 | |||
| 92dfa3f2f2 | |||
| 406251c2f3 | |||
| ee9db91df0 | |||
| 09f6d7ba57 | |||
| 674695918e | |||
| 588b81eeda | |||
| db7f122cb0 | |||
| aacf95cf76 | |||
| 008cd8e6b9 | |||
| c0ac10d5db | |||
| faf935ef52 | |||
| 6acaaea59a | |||
| b6db719758 | |||
| bf49358185 | |||
| 69b3ec3511 | |||
| a600f67d6b | |||
| 09dccdd42f | |||
| be38ca8e81 | |||
| bd3a3635ee | |||
| 1dcbfd47fb | |||
| 4b8e331333 | |||
| cb0fd6ed41 | |||
| e51b661af0 | |||
| a775fc9b50 | |||
| f9ceb7fa89 | |||
| 2112a99b36 | |||
| 736a800c5f | |||
| fcedeb9034 | |||
| 99f3c554c8 | |||
| e7e006e781 | |||
| 803d833908 | |||
| 435efa31ce | |||
| 8977789177 | |||
| 9f1b279e88 | |||
| 8e82f0d239 | |||
| c40ea7f29d | |||
| 253f416de3 | |||
| 53eadb7df7 | |||
| 6c243664bc | |||
| 4cee67e2be | |||
| 730e52a431 | |||
| 8c2afb8157 | |||
| ae0316a30e | |||
| a72e8e0223 | |||
| f66b67c8b8 | |||
| b89019a8e1 | |||
| 0dd9f462ff | |||
| 4dea4fdf54 | |||
| 2863e9f8c4 | |||
| a71d927a0c | |||
| 640dbb6a28 | |||
| 366454e812 | |||
| 4578bf52ee | |||
| 9190d4b542 | |||
| 6fdd19bf14 | |||
| 6d6dfbf02c | |||
| 342582676a | |||
| 60e4d75174 | |||
| 4632f200a9 | |||
| 9d3e0637c8 | |||
| 65ee771fd0 | |||
| 0c5399ca53 | |||
| 584a9a0920 | |||
| 124b7e9154 | |||
| 4764dd5d37 | |||
| 86472bb445 | |||
| 07262fa62c | |||
| a28ea36657 | |||
| 3f7fc1a75a | |||
| 60676bfdcf | |||
| 2734fcad63 | |||
| 3d6e5ff8f9 | |||
| 1554be9da6 | |||
| d6a9efca68 | |||
| b3e56e0c92 | |||
| 15883e5229 | |||
| 0e5696de74 | |||
| 70c87a1ed1 | |||
| 51b200c67b | |||
| e24683d09c | |||
| 61558d59c3 | |||
| 0f8d7982e1 | |||
| d7a5a903a6 | |||
| 16e6cb458f | |||
| 04919757e4 | |||
| 7025cc94cc | |||
| 2d83d0f950 | |||
| 354a179f6d | |||
| b898fc0258 | |||
| eb5c95ef8e | |||
| 512d090f6c | |||
| b8d4577292 | |||
| 18f6ec68b9 | |||
| 7bfd9ac8ac | |||
| 7c52382c90 | |||
| ebc9ccbe1c | |||
| c8ef5a4f38 | |||
| 53583f8d83 | |||
| bae5ff938a | |||
| 0638b9f56c | |||
| acaf9ab50c | |||
| d30a0531d4 | |||
| 5a2ff8b2e5 | |||
| 0d3d824274 | |||
| 8e67bf67e1 | |||
| beb938a9ab | |||
| 40a7b65695 | |||
| 6de9a2af97 | |||
| fe8a3d9f83 | |||
| c6b1c56e9e | |||
| 48288e9ce7 | |||
| 90319593d0 | |||
| f984c6e79a | |||
| 8a0794958d | |||
| 378673408e | |||
| e6f38f52c8 | |||
| 36d02aa147 | |||
| 98570d3547 | |||
| 5fd9db8739 | |||
| b10c70cfcf | |||
| eba2b2cd72 | |||
| 1c5e84ddf2 | |||
| 10b4b86ada | |||
| 9cc3ffb4a9 | |||
| 3fda6e9eed | |||
| 4b35d70078 | |||
| 6512e085c4 | |||
| 0ad397c048 | |||
| b1e8c7d2aa | |||
| b794d61626 | |||
| a06685a47b | |||
| 4777f4fa32 | |||
| 1b1d85fe2e | |||
| edb8971c7d | |||
| 64da99a322 | |||
| 012ce95f27 | |||
| 6c2b2f2c3e | |||
| 2040095050 | |||
| 2388dd7dc3 | |||
| bcb71bb520 | |||
| 9bd84258d0 | |||
| 66c9bf57da | |||
| abc3bac44a | |||
| 2b0cccd5be | |||
| 6d736d3c59 | |||
| 9ba97890a5 | |||
| 58ca51364c | |||
| 308fa924a5 | |||
| 4c872a8d12 | |||
| 925f77f0f5 | |||
| f3f8f9874f | |||
| 52a06bd48a | |||
| 4567cdc0d9 | |||
| a641325707 | |||
| 11f52921dc | |||
| c6ed0b0788 | |||
| 16335f866e | |||
| 0842acad53 | |||
| 0472017cab | |||
| 0bdcb5a337 | |||
| 1994d65306 | |||
| 19274672f9 | |||
| d1df748de3 | |||
| 4d058a125b | |||
| f122525310 | |||
| f5f128620a | |||
| 06635898d0 | |||
| 0ded0b7069 | |||
| 0fa246a1c3 | |||
| 8cb47aebae | |||
| bfc606a9e3 | |||
| 7a21933d10 | |||
| 9364e2fb74 | |||
| 350d52f515 | |||
| 05252e19b5 | |||
| abe42eaf09 | |||
| f2f4baa89a | |||
| 15ae3f588b | |||
| 75932be880 | |||
| 98c7ed965b | |||
| 4d50001c41 | |||
| 857d7e6f37 | |||
| 08ff3bd30f | |||
| debcc3a652 | |||
| 7b78c641fe | |||
| 94f877ff32 | |||
| aa2f7fbe52 | |||
| cdc2b3bf85 | |||
| 90ca2e9b0f | |||
| 76ece4049e | |||
| 1cf1b2ca17 | |||
| 8b6fa1f4ab | |||
| 58dcc1b33f | |||
| cb83831041 | |||
| adf7af34ff | |||
| d933991904 | |||
| 440b64088b | |||
| 0aebdd5f83 | |||
| 261aec8c86 | |||
| 61cfaab915 | |||
| 968462609f | |||
| 601bb78358 | |||
| 631bd20c35 | |||
| c0fcbc5b4c | |||
| be21db7069 | |||
| 8507e5eb0d | |||
| cffbc3558e | |||
| d738044f47 | |||
| e1cdd7e4fe | |||
| 94145c99ae | |||
| 7eae377c01 | |||
| 2376258f02 | |||
| 5c4062c648 | |||
| cb7154cedf | |||
| e4de5c5ad1 | |||
| c24a4da17d | |||
| f949d17db1 | |||
| 6d7744c219 | |||
| 6f06b3d5ed | |||
| 7ce1e9415a | |||
| a9c5c787b9 | |||
| f7e07f3ca1 | |||
| 9f946dec60 | |||
| 7bcfafa6c5 | |||
| 4a70aaa162 | |||
| 12f0ad28bf | |||
| cf60b1882f | |||
| c479c22438 | |||
| 3841e85abb | |||
| 24370d5c40 | |||
| 69171a4c8b | |||
| bd8aa3b6a0 | |||
| fe7e002fea | |||
| 16ee4ac7a2 | |||
| 70285fb6ca | |||
| ade617efa8 | |||
| f0d48a4295 | |||
| 139e764b2f | |||
| 3a4b862e81 | |||
| 637cd136c2 | |||
| 1dc647f43b | |||
| 6890618221 | |||
| a3238aa79f | |||
| 108a019cb8 | |||
| 1c25b06dca | |||
| 36c3fc58b5 | |||
| 0f0ba7dadd | |||
| 5d7766e1b6 | |||
| eca51269bb | |||
| d577ff1e4a | |||
| 6a9d67b5bb | |||
| ebb7ce2092 | |||
| 9a6bf78e14 | |||
| 59171daa35 | |||
| 945275faae | |||
| 2e165926de | |||
| dfc2dc2c0b | |||
| d784eb1e9b | |||
| 6a004205d8 | |||
| ee9099cab9 | |||
| ee901fcd2c | |||
| 7ffcd3908e | |||
| 0dcd6ac983 | |||
| 93415a48e8 | |||
| f8b3a32caf | |||
| 2ae47cf200 | |||
| adcbba34f8 | |||
| 218bd7a402 | |||
| 464462b22e | |||
| a1aceb5f87 | |||
| 52e227f425 | |||
| ea515fa26e | |||
| cc8b5055f2 | |||
| d8fa0f426a | |||
| aa59b32374 | |||
| 877bc23afc | |||
| 0ad448b8a4 | |||
| 93407ba316 | |||
| 78dbad5e1e | |||
| 5b026b2a3e | |||
| bb3526f4e4 | |||
| 17c819a3c2 | |||
| 4c8615f01c | |||
| 85411e4867 | |||
| 4d67c817ec | |||
| 8b4ea5bb78 | |||
| b44eacbc5a | |||
| 4f0e574201 | |||
| 53b8a1f71b | |||
| 9a2c60d595 | |||
| 5df4277216 | |||
| 6769b1967c | |||
| 2c80d95c53 | |||
| 124e1ea4a7 | |||
| 7674e4f093 | |||
| 0afb8f681b | |||
| 157ae917eb | |||
| f593f92f18 | |||
| af0b7d4683 | |||
| 00cb7f5104 | |||
| 068e52f877 | |||
| fe772d95e2 | |||
| ecba37070d | |||
| f23296b22d | |||
| 10f06a64fe | |||
| 8f3144adb5 | |||
| 6089a55da6 | |||
| c81b3ef9ce | |||
| de5e0fbc00 | |||
| c53cd78dcb | |||
| 9793315e0b | |||
| 694fb3776f | |||
| adcc50d337 | |||
| 6b66cb5ef6 | |||
| 58e78e8946 | |||
| b8ea267f8e | |||
| de3317e26b | |||
| fcf7208352 | |||
| b171b0216b | |||
| b062235d0c | |||
| 3107a5363d | |||
| c0385f60ba | |||
| 30068afd78 | |||
| e3f3929198 | |||
| 54f7861b2e | |||
| 68973f9b39 | |||
| a361df840e | |||
| b99f8dabdd | |||
| be6bf76105 | |||
| 38cc6e4762 | |||
| e53152123f | |||
| 07e650c787 | |||
| 753589e51c | |||
| 3dea69f658 | |||
| bef5ec2cea | |||
| 9465e2918b | |||
| 486c004cbb | |||
| 14d876c259 | |||
| 5787c969f6 | |||
| a229f9ea42 | |||
| bcd313c363 | |||
| 02d9d07900 | |||
| bdb7d48cff | |||
| bc9ee7b4d9 | |||
| b535275b17 | |||
| f9756de693 | |||
| b73010bb36 | |||
| bc5b3ec6b8 | |||
| 7611762e04 | |||
| 1eef5b4f6a | |||
| a43d98965b | |||
| 566e25569e | |||
| 47e47e42af | |||
| f9d38a073f | |||
| 47ab4c71d5 | |||
| 39100eca49 | |||
| 6862d618ee | |||
| 157ff57c40 | |||
| d85b52bfc2 | |||
| 05f314bae4 | |||
| ee0d9b7915 | |||
| f3402d3f1f | |||
| dbd0d7d742 | |||
| afa0609ece | |||
| 1b1abdd30c | |||
| 4a8f995c3f | |||
| 7ea1e9cbd0 | |||
| e34ed72e1e | |||
| 865880a0b1 | |||
| 3a6b5ebb5f | |||
| cee645017d | |||
| 035b981e11 | |||
| 8da29566a1 | |||
| f2217da94e | |||
| b312318a99 | |||
| 0a87c1ecd0 | |||
| a87f015246 | |||
| f1c1004225 | |||
| 06657b8109 | |||
| a407a7f1c0 | |||
| 418bd05ae0 | |||
| 86cce2cd88 | |||
| 8970923940 | |||
| 83fad5e9f7 | |||
| e4e69a10ec | |||
| c6a1469fad | |||
| 97cc94756e | |||
| cb73257f14 | |||
| 61366cbcda | |||
| c3e1d2d894 | |||
| 0bfacca0a0 | |||
| 1364df0913 | |||
| 352391fa76 | |||
| 2cb28369b7 | |||
| 3f350f8659 | |||
| 9d8f590fc5 | |||
| defeddf21b | |||
| c97767424f | |||
| d513eb8c4d | |||
| 265d1b2824 | |||
| caf3362be8 | |||
| 3e952044bd | |||
| 7aa7bbc390 | |||
| a97f5adf95 | |||
| 138c4cbfcf | |||
| 7a3c5c0f8a | |||
| 3e513be963 | |||
| f78b238b40 | |||
| bbbe2b66b4 | |||
| 63a0befd3c | |||
| 710320601a | |||
| 67e26fd3af | |||
| 2c35bdbcf5 | |||
| abe865f4f3 | |||
| e1d04be897 | |||
| f419830a52 | |||
| 61bbb99d9e | |||
| 6c159a97b7 | |||
| 124ad948fe | |||
| 947dcd34bd | |||
| 710b5270a1 | |||
| 2bff50f736 | |||
| e24299e66d | |||
| f047b6b3ae | |||
| 368912ca62 | |||
| b1048fc9bc | |||
| 9bb226dc52 | |||
| 0948235c3b | |||
| bd456ed10b | |||
| 223c14f48b | |||
| d0c3180376 | |||
| 3ceaa107ab | |||
| 144d8b1bb7 | |||
| 989938856f | |||
| 8913f37c3d | |||
| 80b5896b70 | |||
| 967b1137dc | |||
| 8cd3bd7997 | |||
| ce0ca894fe | |||
| d1975b740b | |||
| 459a60a242 | |||
| 9a269ec8ab | |||
| d7efdcce2b | |||
| 885c94bda8 | |||
| 2e1ef805ff | |||
| 95b65ff751 | |||
| 35bc831077 | |||
| 5d4505c685 | |||
| b4f340806a | |||
| 7b2f597b30 | |||
| e303c3da3b | |||
| bc5d519c4f | |||
| 7cdff6b1e2 | |||
| b04de83c20 | |||
| dfa2511199 | |||
| d4faa5a5ea | |||
| 2108f420ea | |||
| 42ecdb5407 | |||
| 626fcff417 | |||
| e6b00a8905 | |||
| 03c6caac1f | |||
| 29160741a3 | |||
| 7820a311ba | |||
| 5eb9b58488 | |||
| 51a2d2b701 | |||
| 044fd1bd15 | |||
| 70a31a9a57 | |||
| c7d1d1e390 | |||
| 2d0b94794f | |||
| fbf315e624 | |||
| b9c0a9c3bf | |||
| 6d9996e599 | |||
| 7806cd5aef | |||
| b3622474d7 | |||
| d8bb8c58d0 | |||
| d93cb3658d | |||
| 200fb093b1 | |||
| 4ab831b259 | |||
| 576ee92438 | |||
| af4500e504 | |||
| 016928722c | |||
| 73b69ae408 | |||
| 80376a3fdc | |||
| 305e591ec2 | |||
| 39deadcab1 | |||
| 2153c8ec9f | |||
| a70c718a0d | |||
| c73efab192 | |||
| ce54b1df23 | |||
| 16701befe7 | |||
| 8a6af40d9f | |||
| 9cf6108527 | |||
| 1c1c1c3100 | |||
| def954134c | |||
| c85afce702 | |||
| a25ecfa856 | |||
| 47b007ef19 | |||
| 04fae8b357 | |||
| 1850a985b5 | |||
| 339ed1d72e | |||
| fa1ebfa4fd | |||
| 0820abbc64 | |||
| b94e1c9458 | |||
| fe58ef69d9 | |||
| cc6b51e5ae | |||
| cd2c315495 | |||
| 4b3ed3e802 | |||
| 8cd2157564 | |||
| aaa49bdd6d | |||
| 8da02c669e | |||
| 828656b35f | |||
| 3b97c8d89b | |||
| f5ea1ce250 | |||
| a181b4a731 | |||
| 114f709337 | |||
| a6fb5a0460 | |||
| 7ef181bc13 | |||
| 49a2e5bf57 | |||
| 4403c7b6c2 | |||
| b081e33c0a | |||
| f4c38e6001 | |||
| c40f26946f | |||
| 627b063b88 | |||
| f962bae983 | |||
| e08341dab3 | |||
| 890949abe6 | |||
| 6e43861c0c | |||
| ad275351b6 | |||
| 7d45459a47 | |||
| 5af24b3ebe | |||
| a36692b4a2 | |||
| ca2aaf0321 | |||
| 79f0437980 | |||
| 10daa64d5b | |||
| e0d4c3ec92 | |||
| 65fbbf5e35 | |||
| 10baa6e781 | |||
| 3de14a53c2 | |||
| fe5c02331b | |||
| d040953c76 | |||
| b5c3395f79 | |||
| ed9ab65b5e | |||
| 1a2b360d3d | |||
| 4f6cb771f1 | |||
| 75683e5197 | |||
| 8ea35e3bb4 | |||
| 44349fb62b | |||
| fe1941c13a | |||
| 933a3bbbd3 | |||
| 3909b62ffc | |||
| bec227da30 | |||
| 11487d66fc | |||
| 395098c6f1 | |||
| 72951324df | |||
| 0c42cd2c01 | |||
| c701ebe07b | |||
| b338850cc1 | |||
| 64957db7b3 | |||
| d7147d6cdd |
+10
-1
@@ -19,4 +19,13 @@ FORWARDED_ALLOW_IPS='*'
|
||||
# DO NOT TRACK
|
||||
SCARF_NO_ANALYTICS=true
|
||||
DO_NOT_TRACK=true
|
||||
ANONYMIZED_TELEMETRY=false
|
||||
ANONYMIZED_TELEMETRY=false
|
||||
|
||||
# Valkey Vector Store (requires VECTOR_DB=valkey)
|
||||
# VALKEY_URL='valkey://localhost:6379'
|
||||
# VALKEY_COLLECTION_PREFIX='open_webui'
|
||||
# VALKEY_INDEX_TYPE='HNSW'
|
||||
# VALKEY_DISTANCE_METRIC='COSINE'
|
||||
# VALKEY_HNSW_M='16'
|
||||
# VALKEY_HNSW_EF_CONSTRUCTION='200'
|
||||
# VALKEY_HNSW_EF_RUNTIME='10'
|
||||
|
||||
@@ -1,82 +1,76 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
description: Suggest a new feature or improvement
|
||||
title: 'feat: '
|
||||
labels: ['triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Important Notes
|
||||
### Before submitting
|
||||
|
||||
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.
|
||||
## Before Submitting
|
||||
|
||||
#### Scope
|
||||
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
|
||||
|
||||
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
|
||||
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
|
||||
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
|
||||
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
|
||||
|
||||
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
|
||||
|
||||
This will help us efficiently focus on improving the project.
|
||||
|
||||
### Collaborate respectfully
|
||||
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
|
||||
### Scope Guidelines
|
||||
|
||||
Remember:
|
||||
- Open WebUI is a **volunteer-driven project**
|
||||
- It's managed by a **single maintainer**
|
||||
- It's supported by contributors who also have **full-time jobs**
|
||||
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
|
||||
|
||||
We appreciate your time and ask that you **respect ours**.
|
||||
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
|
||||
|
||||
### Be Respectful
|
||||
|
||||
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
|
||||
|
||||
### Contributing
|
||||
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
|
||||
|
||||
### Bug reproducibility
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
|
||||
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
|
||||
|
||||
### Reproducibility
|
||||
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
|
||||
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Please confirm that you've checked for existing similar requests
|
||||
description: Confirm you have searched for similar requests.
|
||||
options:
|
||||
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
|
||||
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: feature-scope
|
||||
attributes:
|
||||
label: Verify Feature Scope
|
||||
description: Please confirm the feature's scope is within the described scope
|
||||
description: Confirm this request belongs in Issues rather than Discussions.
|
||||
options:
|
||||
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
|
||||
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
|
||||
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
|
||||
description: Is this related to a problem? Describe the pain point clearly.
|
||||
placeholder: "e.g., I'm frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Desired Solution you'd like
|
||||
description: Clearly describe what you want to happen.
|
||||
label: Proposed Solution
|
||||
description: Describe what you would like to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
description: Describe any alternative solutions or workarounds you have considered.
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
description: Add any other context, mockups, or screenshots about the feature request.
|
||||
|
||||
+10
-5
@@ -4,17 +4,22 @@ updates:
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: '/backend'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: 'github-actions'
|
||||
- package-ecosystem: github-actions
|
||||
directory: '/'
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every week
|
||||
interval: monthly
|
||||
target-branch: 'dev'
|
||||
target-branch: dev
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: monthly
|
||||
target-branch: dev
|
||||
|
||||
@@ -10,77 +10,83 @@
|
||||
|
||||
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
|
||||
|
||||
<!--
|
||||
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
|
||||
|
||||
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
|
||||
-->
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
|
||||
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
|
||||
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
|
||||
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
|
||||
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
- **ci**: Changes to our continuous integration processes or workflows
|
||||
- **chore**: Refactor, cleanup, or other non-functional code changes
|
||||
- **docs**: Documentation update or addition
|
||||
- **feat**: Introduces a new feature or enhancement to the codebase
|
||||
- **fix**: Bug fix or error correction
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
|
||||
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** A concise description of the changes is provided below.
|
||||
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
|
||||
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
|
||||
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
|
||||
- [ ] **Testing:** Manual tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
|
||||
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
|
||||
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
|
||||
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
|
||||
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
|
||||
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvement
|
||||
- **refactor**: Code restructuring for better maintainability, readability, or scalability
|
||||
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
- **style**: Formatting changes (whitespace, semicolons, etc.)
|
||||
- **test**: Test additions or corrections
|
||||
- **WIP**: Work in progress
|
||||
|
||||
# Changelog Entry
|
||||
|
||||
### Description
|
||||
|
||||
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
|
||||
- [Describe the changes, including motivation and impact]
|
||||
|
||||
### Added
|
||||
|
||||
- [List any new features, functionalities, or additions]
|
||||
- [New features, functionalities, or additions]
|
||||
|
||||
### Changed
|
||||
|
||||
- [List any changes, updates, refactorings, or optimizations]
|
||||
- [Changes, updates, refactorings, or optimizations]
|
||||
|
||||
### Deprecated
|
||||
|
||||
- [List any deprecated functionality or features that have been removed]
|
||||
- [Deprecated functionality or features]
|
||||
|
||||
### Removed
|
||||
|
||||
- [List any removed features, files, or functionalities]
|
||||
- [Removed features, files, or functionalities]
|
||||
|
||||
### Fixed
|
||||
|
||||
- [List any fixes, corrections, or bug fixes]
|
||||
- [Bug fixes or corrections]
|
||||
|
||||
### Security
|
||||
|
||||
- [List any new or updated security-related changes, including vulnerability fixes]
|
||||
- [Security-related changes or vulnerability fixes]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
|
||||
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
|
||||
|
||||
---
|
||||
|
||||
### Additional Information
|
||||
|
||||
- [Insert any additional context, notes, or explanations for the changes]
|
||||
- [Reference any related issues, commits, or other relevant information]
|
||||
- [Any additional context, notes, or references to related issues/commits]
|
||||
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach any relevant screenshots or videos demonstrating the changes]
|
||||
- [Attach relevant screenshots or videos demonstrating the changes]
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
@@ -88,9 +94,11 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
|
||||
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
|
||||
-->
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backend CI — Python formatting checks via Ruff
|
||||
# Runs on pushes and PRs to main/dev when backend files change
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
|
||||
concurrency:
|
||||
group: backend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── Ruff format check across supported Python versions ───────────────────
|
||||
format-check:
|
||||
name: Ruff Format (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12']
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install formatter
|
||||
run: pip install "ruff>=0.15.5"
|
||||
|
||||
- name: Verify formatting
|
||||
run: ruff format --check . --exclude .venv --exclude venv
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # or whatever branch you want to use
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check for changes in package.json
|
||||
run: |
|
||||
git diff --cached --diff-filter=d package.json || {
|
||||
echo "No changes to package.json"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Get version number from package.json
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
echo "::set-output name=version::$VERSION"
|
||||
|
||||
- name: Extract latest CHANGELOG entry
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_CONTENT=$(awk 'BEGIN {print_section=0;} /^## \[/ {if (print_section == 0) {print_section=1;} else {exit;}} print_section {print;}' CHANGELOG.md)
|
||||
CHANGELOG_ESCAPED=$(echo "$CHANGELOG_CONTENT" | sed ':a;N;$!ba;s/\n/%0A/g')
|
||||
echo "Extracted latest release notes from CHANGELOG.md:"
|
||||
echo -e "$CHANGELOG_CONTENT"
|
||||
echo "::set-output name=content::$CHANGELOG_ESCAPED"
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const changelog = `${{ steps.changelog.outputs.content }}`;
|
||||
const release = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${{ steps.get_version.outputs.version }}`,
|
||||
name: `v${{ steps.get_version.outputs.version }}`,
|
||||
body: changelog,
|
||||
})
|
||||
console.log(`Created release ${release.data.html_url}`)
|
||||
|
||||
- name: Upload package to GitHub release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: package
|
||||
path: |
|
||||
.
|
||||
!.git
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build workflow
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'docker-build.yaml',
|
||||
ref: 'v${{ steps.get_version.outputs.version }}',
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Deploy to HuggingFace Spaces
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-secret:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
token-set: ${{ steps.check-key.outputs.defined }}
|
||||
steps:
|
||||
- id: check-key
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
if: "${{ env.HF_TOKEN != '' }}"
|
||||
run: echo "defined=true" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-secret]
|
||||
if: needs.check-secret.outputs.token-set == 'true'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Remove git history
|
||||
run: rm -rf .git
|
||||
|
||||
- name: Prepend YAML front matter to README.md
|
||||
run: |
|
||||
echo "---" > temp_readme.md
|
||||
echo "title: Open WebUI" >> temp_readme.md
|
||||
echo "emoji: 🐳" >> temp_readme.md
|
||||
echo "colorFrom: purple" >> temp_readme.md
|
||||
echo "colorTo: gray" >> temp_readme.md
|
||||
echo "sdk: docker" >> temp_readme.md
|
||||
echo "app_port: 8080" >> temp_readme.md
|
||||
echo "---" >> temp_readme.md
|
||||
cat README.md >> temp_readme.md
|
||||
mv temp_readme.md README.md
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
- name: Set up Git and push to Space
|
||||
run: |
|
||||
git init --initial-branch=main
|
||||
git lfs install
|
||||
git lfs track "*.ttf"
|
||||
git lfs track "*.jpg"
|
||||
rm demo.png
|
||||
rm banner.png
|
||||
git add .
|
||||
git commit -m "GitHub deploy: ${{ github.sha }}"
|
||||
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main
|
||||
@@ -1,917 +0,0 @@
|
||||
name: Create and publish Docker images with specific build args
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-main-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (latest)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-main-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-cuda-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete huge unnecessary tools folder
|
||||
run: rm -rf /opt/hostedtoolcache
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (cuda tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-cuda-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (cuda)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-cuda-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-cuda126-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete huge unnecessary tools folder
|
||||
run: rm -rf /opt/hostedtoolcache
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (cuda126 tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda126,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-cuda126-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (cuda126)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
USE_CUDA_VER=cu126
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-cuda126-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-ollama-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (ollama tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-ollama,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-ollama-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (ollama)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_OLLAMA=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-ollama-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-slim-image:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (slim tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-slim,onlatest=true
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-slim-${{ matrix.platform }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image (slim)
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_SLIM=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-slim-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-main-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-main-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-main-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-cuda-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-cuda-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-cuda-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-cuda126-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-cuda126-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-cuda126-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default latest tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-cuda126,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-ollama-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-ollama-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-ollama-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default ollama tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-ollama,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
merge-slim-images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-slim-image]
|
||||
steps:
|
||||
# GitHub Packages requires the entire repository name to be in lowercase
|
||||
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-slim-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images (default slim tag)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
suffix=-slim,onlatest=true
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
|
||||
copy-to-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: main
|
||||
suffix: ""
|
||||
- variant: cuda
|
||||
suffix: "-cuda"
|
||||
- variant: cuda126
|
||||
suffix: "-cuda126"
|
||||
- variant: ollama
|
||||
suffix: "-ollama"
|
||||
- variant: slim
|
||||
suffix: "-slim"
|
||||
steps:
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine source and destination tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="openwebui/open-webui"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# For version tags: copy version tag and major.minor tag
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# For main branch
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
echo "tags=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# e.g. latest-cuda -> also tag as just "cuda"
|
||||
VARIANT_NAME="${SUFFIX#-}"
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Copy images from GHCR to Docker Hub
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
# Determine the source tag on GHCR
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
SOURCE_TAG="${VERSION}${SUFFIX}"
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
SOURCE_TAG="latest"
|
||||
else
|
||||
SOURCE_TAG="latest${SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
|
||||
|
||||
echo "Copying from ${SOURCE} to Docker Hub..."
|
||||
|
||||
# Copy each destination tag
|
||||
while IFS= read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
@@ -0,0 +1,331 @@
|
||||
name: Create and publish Docker images with specific build args
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- v*
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- arch: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
variant:
|
||||
- name: main
|
||||
suffix: ""
|
||||
build_args: ""
|
||||
free_disk: false
|
||||
- name: cuda
|
||||
suffix: "-cuda"
|
||||
build_args: "USE_CUDA=true"
|
||||
free_disk: true
|
||||
- name: cuda126
|
||||
suffix: "-cuda126"
|
||||
build_args: |
|
||||
USE_CUDA=true
|
||||
USE_CUDA_VER=cu126
|
||||
free_disk: true
|
||||
- name: ollama
|
||||
suffix: "-ollama"
|
||||
build_args: "USE_OLLAMA=true"
|
||||
free_disk: false
|
||||
- name: slim
|
||||
suffix: "-slim"
|
||||
build_args: "USE_SLIM=true"
|
||||
free_disk: false
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
platform=${{ matrix.platform.arch }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Free disk space
|
||||
if: matrix.variant.free_disk
|
||||
run: rm -rf /opt/hostedtoolcache
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
|
||||
|
||||
- name: Extract metadata for Docker cache
|
||||
id: cache-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
|
||||
flavor: |
|
||||
prefix=cache-${{ matrix.variant.name }}-${{ matrix.platform.arch }}-
|
||||
latest=false
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ matrix.platform.arch }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
${{ matrix.variant.build_args }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.variant.name }}-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- name: main
|
||||
suffix: ""
|
||||
- name: cuda
|
||||
suffix: "-cuda"
|
||||
- name: cuda126
|
||||
suffix: "-cuda126"
|
||||
- name: ollama
|
||||
suffix: "-ollama"
|
||||
- name: slim
|
||||
suffix: "-slim"
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Download digests
|
||||
id: download
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: digests-${{ matrix.variant.name }}-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check digests
|
||||
id: check
|
||||
run: |
|
||||
count=$(find /tmp/digests -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "digest_count=$count" >> $GITHUB_OUTPUT
|
||||
if [ "$count" -lt 2 ]; then
|
||||
echo "::warning::${{ matrix.variant.name }}: found $count digest(s), need 2 (one per arch). Skipping merge."
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker images
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.FULL_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=git-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
|
||||
flavor: |
|
||||
latest=${{ github.ref == 'refs/heads/main' }}
|
||||
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
copy-to-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
needs: [merge]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: main
|
||||
suffix: ""
|
||||
- variant: cuda
|
||||
suffix: "-cuda"
|
||||
- variant: cuda126
|
||||
suffix: "-cuda126"
|
||||
- variant: ollama
|
||||
suffix: "-ollama"
|
||||
- variant: slim
|
||||
suffix: "-slim"
|
||||
|
||||
steps:
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine source and destination tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="openwebui/open-webui"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
echo "tags=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VARIANT_NAME="${SUFFIX#-}"
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Copy images from GHCR to Docker Hub
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
SOURCE_TAG="${VERSION}${SUFFIX}"
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
SOURCE_TAG="latest"
|
||||
else
|
||||
SOURCE_TAG="latest${SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
|
||||
|
||||
echo "Copying from ${SOURCE} to Docker Hub..."
|
||||
|
||||
while IFS= read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Format Backend'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- 3.11.x
|
||||
- 3.12.x
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '${{ matrix.python-version }}'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black
|
||||
|
||||
- name: Format backend
|
||||
run: npm run format:backend
|
||||
|
||||
- name: Check for changes after format
|
||||
run: git diff --exit-code
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Frontend Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Format & Build Frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install --force
|
||||
|
||||
- name: Format Frontend
|
||||
run: npm run format
|
||||
|
||||
- name: Run i18next
|
||||
run: npm run i18n:parse
|
||||
|
||||
- name: Check for Changes After Format
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Build Frontend
|
||||
run: npm run build
|
||||
|
||||
test-frontend:
|
||||
name: 'Frontend Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci --force
|
||||
|
||||
- name: Run vitest
|
||||
run: npm run test:frontend
|
||||
@@ -0,0 +1,63 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Frontend CI — Lint, format check, build, and unit tests
|
||||
# Runs on pushes and PRs to main/dev, skipping backend-only changes
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Frontend Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
|
||||
|
||||
concurrency:
|
||||
group: frontend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── Format, i18n, and production build ────────────────────────────────────
|
||||
format-and-build:
|
||||
name: Format & Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --force
|
||||
|
||||
- name: Verify code formatting
|
||||
run: npm run format
|
||||
|
||||
- name: Verify i18n strings
|
||||
run: npm run i18n:parse
|
||||
|
||||
- name: Ensure working tree is clean
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
# ── Vitest unit tests ────────────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies (frozen lockfile)
|
||||
run: npm ci --force
|
||||
|
||||
- name: Execute test suite
|
||||
run: npm run test:frontend
|
||||
@@ -1,255 +0,0 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
cypress-run:
|
||||
name: Run Cypress Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Maximize build space
|
||||
uses: AdityaGarg8/remove-unwanted-software@v4.1
|
||||
with:
|
||||
remove-android: 'true'
|
||||
remove-haskell: 'true'
|
||||
remove-codeql: 'true'
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and run Compose Stack
|
||||
run: |
|
||||
docker compose \
|
||||
--file docker-compose.yaml \
|
||||
--file docker-compose.api.yaml \
|
||||
--file docker-compose.a1111-test.yaml \
|
||||
up --detach --build
|
||||
|
||||
- name: Delete Docker build cache
|
||||
run: |
|
||||
docker builder prune --all --force
|
||||
|
||||
- name: Wait for Ollama to be up
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
until curl --output /dev/null --silent --fail http://localhost:11434; do
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
echo "Service is up!"
|
||||
|
||||
- name: Preload Ollama model
|
||||
run: |
|
||||
docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
env:
|
||||
LIBGL_ALWAYS_SOFTWARE: 1
|
||||
with:
|
||||
browser: chrome
|
||||
wait-on: 'http://localhost:3000'
|
||||
config: baseUrl=http://localhost:3000
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload Cypress videos
|
||||
with:
|
||||
name: cypress-videos
|
||||
path: cypress/videos
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Extract Compose logs
|
||||
if: always()
|
||||
run: |
|
||||
docker compose logs > compose-logs.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload Compose logs
|
||||
with:
|
||||
name: compose-logs
|
||||
path: compose-logs.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
# pytest:
|
||||
# name: Run Backend Tests
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
|
||||
# - name: Set up Python
|
||||
# uses: actions/setup-python@v5
|
||||
# with:
|
||||
# python-version: ${{ matrix.python-version }}
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: |
|
||||
# python -m pip install --upgrade pip
|
||||
# pip install -r backend/requirements.txt
|
||||
|
||||
# - name: pytest run
|
||||
# run: |
|
||||
# ls -al
|
||||
# cd backend
|
||||
# PYTHONPATH=. pytest . -o log_cli=true -o log_cli_level=INFO
|
||||
|
||||
migration_test:
|
||||
name: Run Migration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
# mysql:
|
||||
# image: mysql
|
||||
# env:
|
||||
# MYSQL_ROOT_PASSWORD: mysql
|
||||
# MYSQL_DATABASE: mysql
|
||||
# options: >-
|
||||
# --health-cmd "mysqladmin ping -h localhost"
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
# ports:
|
||||
# - 3306:3306
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: yezz123/setup-uv@v4
|
||||
with:
|
||||
uv-venv: venv
|
||||
|
||||
- name: Activate virtualenv
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
echo PATH=$PATH >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -r backend/requirements.txt
|
||||
|
||||
- name: Test backend with SQLite
|
||||
id: sqlite
|
||||
env:
|
||||
WEBUI_SECRET_KEY: secret-key
|
||||
GLOBAL_LOG_LEVEL: debug
|
||||
run: |
|
||||
cd backend
|
||||
uvicorn open_webui.main:app --port "8080" --forwarded-allow-ips '*' &
|
||||
UVICORN_PID=$!
|
||||
# Wait up to 40 seconds for the server to start
|
||||
for i in {1..40}; do
|
||||
curl -s http://localhost:8080/api/config > /dev/null && break
|
||||
sleep 1
|
||||
if [ $i -eq 40 ]; then
|
||||
echo "Server failed to start"
|
||||
kill -9 $UVICORN_PID
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Check that the server is still running after 5 seconds
|
||||
sleep 5
|
||||
if ! kill -0 $UVICORN_PID; then
|
||||
echo "Server has stopped"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test backend with Postgres
|
||||
if: success() || steps.sqlite.conclusion == 'failure'
|
||||
env:
|
||||
WEBUI_SECRET_KEY: secret-key
|
||||
GLOBAL_LOG_LEVEL: debug
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_POOL_SIZE: 10
|
||||
DATABASE_POOL_MAX_OVERFLOW: 10
|
||||
DATABASE_POOL_TIMEOUT: 30
|
||||
run: |
|
||||
cd backend
|
||||
uvicorn open_webui.main:app --port "8081" --forwarded-allow-ips '*' &
|
||||
UVICORN_PID=$!
|
||||
# Wait up to 20 seconds for the server to start
|
||||
for i in {1..20}; do
|
||||
curl -s http://localhost:8081/api/config > /dev/null && break
|
||||
sleep 1
|
||||
if [ $i -eq 20 ]; then
|
||||
echo "Server failed to start"
|
||||
kill -9 $UVICORN_PID
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Check that the server is still running after 5 seconds
|
||||
sleep 5
|
||||
if ! kill -0 $UVICORN_PID; then
|
||||
echo "Server has stopped"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that service will reconnect to postgres when connection will be closed
|
||||
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
|
||||
if [[ "$status_code" -ne 200 ]] ; then
|
||||
echo "Server has failed before postgres reconnect check"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Terminating all connections to postgres..."
|
||||
python -c "import os, psycopg2 as pg2; \
|
||||
conn = pg2.connect(dsn=os.environ['DATABASE_URL'].replace('+pool', '')); \
|
||||
cur = conn.cursor(); \
|
||||
cur.execute('SELECT pg_terminate_backend(psa.pid) FROM pg_stat_activity psa WHERE datname = current_database() AND pid <> pg_backend_pid();')"
|
||||
|
||||
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
|
||||
if [[ "$status_code" -ne 200 ]] ; then
|
||||
echo "Server has not reconnected to postgres after connection was closed: returned status $status_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# - name: Test backend with MySQL
|
||||
# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure'
|
||||
# env:
|
||||
# WEBUI_SECRET_KEY: secret-key
|
||||
# GLOBAL_LOG_LEVEL: debug
|
||||
# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql
|
||||
# run: |
|
||||
# cd backend
|
||||
# uvicorn open_webui.main:app --port "8083" --forwarded-allow-ips '*' &
|
||||
# UVICORN_PID=$!
|
||||
# # Wait up to 20 seconds for the server to start
|
||||
# for i in {1..20}; do
|
||||
# curl -s http://localhost:8083/api/config > /dev/null && break
|
||||
# sleep 1
|
||||
# if [ $i -eq 20 ]; then
|
||||
# echo "Server failed to start"
|
||||
# kill -9 $UVICORN_PID
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
# # Check that the server is still running after 5 seconds
|
||||
# sleep 5
|
||||
# if ! kill -0 $UVICORN_PID; then
|
||||
# echo "Server has stopped"
|
||||
# exit 1
|
||||
# fi
|
||||
@@ -0,0 +1,74 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Release — Create GitHub release from CHANGELOG, trigger Docker builds
|
||||
# Runs on pushes to main when package.json version changes
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Create release and trigger downstream workflows ──────────────────────
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Abort if package.json unchanged
|
||||
run: |
|
||||
git diff --cached --diff-filter=d package.json || {
|
||||
echo "package.json not modified — skipping release"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Read version
|
||||
id: pkg
|
||||
run: echo "version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
run: |
|
||||
VER="${{ steps.pkg.outputs.version }}"
|
||||
awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
|
||||
CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
|
||||
echo "Release v${{ steps.pkg.outputs.version }} already exists — skipping creation"
|
||||
else
|
||||
gh release create "v${{ steps.pkg.outputs.version }}" \
|
||||
--title "v${{ steps.pkg.outputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
fi
|
||||
|
||||
- name: Archive source
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-archive
|
||||
path: |
|
||||
.
|
||||
!.git
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'docker.yaml',
|
||||
ref: 'v${{ steps.pkg.outputs.version }}',
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.5
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, backend]
|
||||
- id: ruff-format
|
||||
args: [backend]
|
||||
+847
-1
@@ -5,6 +5,853 @@ 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.9.6] - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- 📦 **Official knowledge base sync tool.** A new companion tool from Open WebUI, oikb, keeps a knowledge base in sync with a local directory, GitHub repo, S3 bucket, Confluence space, or any of more than 40 other sources, uploading only new and changed files using the incremental sync support added in this release. [oikb](https://github.com/open-webui/oikb)
|
||||
- 📂 **Smart directory sync for knowledge bases.** Local directories can now be synced into a knowledge base in one action: file checksums are compared against what's already stored, and only added or modified files are uploaded while removed files and orphaned subdirectories are cleaned up, with the directory structure mirrored automatically and per-file progress shown throughout. [#19190](https://github.com/open-webui/open-webui/issues/19190), [#19394](https://github.com/open-webui/open-webui/issues/19394), [Commit](https://github.com/open-webui/open-webui/commit/60c9db1cb81d021589cb49bee8744a799b51211f), [Commit](https://github.com/open-webui/open-webui/commit/73bdf86766d3f44467cdec436786fe089481baf3), [Commit](https://github.com/open-webui/open-webui/commit/9835b3f1dd7aa9c92ff08a634a0f97cc2a046e42), [Commit](https://github.com/open-webui/open-webui/commit/97252fa609440573251d8e75090f347ed1e51e1d), [Commit](https://github.com/open-webui/open-webui/commit/8f2d346e10c47b57bf6b5a6aa02d453488a88b89), [Commit](https://github.com/open-webui/open-webui/commit/1527eb6e01d441225c979d12d78b7625edf1086f)
|
||||
- 🗂️ **Knowledge base folders.** Files inside a knowledge base can now be organized into nested folders, with breadcrumb navigation that makes it much easier to manage and find content in large collections. [Commit](https://github.com/open-webui/open-webui/commit/c2cbc47ca76ebfa21e1d36279dbd859283cbbfb1), [Commit](https://github.com/open-webui/open-webui/commit/ab0ee858b73738c5b77d1d455e66e66bc61df1c4), [Commit](https://github.com/open-webui/open-webui/commit/2ad327a4dce64a8eedf5eb41c73b8520344fdd82), [Commit](https://github.com/open-webui/open-webui/commit/171150c1e12b5713f77a5ea0dd19c6581c7bef2e), [Commit](https://github.com/open-webui/open-webui/commit/e7d2ddbb1d14c03a52797751d24a98132aac0cd8), [Commit](https://github.com/open-webui/open-webui/commit/32a417bbf66ed28a5ca8a759ad3a87b2a2aa358a)
|
||||
- 🧰 **Filesystem tool for knowledge bases.** A new built-in tool, enabled via the "ENABLE_KB_EXEC" environment variable, lets AI models browse and search knowledge base contents using familiar filesystem commands such as 'ls', 'cat', 'grep', 'find', 'head', 'tail', and 'sed', including pipes between them. [Commit](https://github.com/open-webui/open-webui/commit/5b125c24d4eae925d3287efa626595bab29d5c33), [Commit](https://github.com/open-webui/open-webui/commit/ecec86dd32aa396dd5a383e9be5b0f16c4346c48), [Commit](https://github.com/open-webui/open-webui/commit/9ef579ce4be5b82f9281b085a621fed5b04e0d26), [Commit](https://github.com/open-webui/open-webui/commit/2f642754ac6b8c430228a7fc289357a6c6652235), [Commit](https://github.com/open-webui/open-webui/commit/3b00e5721a60518ea317c4fd61a6d0d182961a2f), [Commit](https://github.com/open-webui/open-webui/commit/4e78b355efe0611f27fddef591c69f7c1259a9f6), [Commit](https://github.com/open-webui/open-webui/commit/74f95a9b0d6b89b241701422170d54e21e701baa), [Commit](https://github.com/open-webui/open-webui/commit/cc16e06c32de48bdf464db5814483a49f3527b63), [Commit](https://github.com/open-webui/open-webui/commit/1ea54c3217f487990b6a4c0f0d8ea675a479b6e1)
|
||||
- ✏️ **File renaming in knowledge bases.** Files inside a knowledge base can now be renamed directly from the workspace, with the new name reflected wherever the file is referenced. [Commit](https://github.com/open-webui/open-webui/commit/3127f1b46255626ea92eb0b598e45078287303f1)
|
||||
- 😀 **Emoji picker in message input.** A new emoji button in the rich text formatting toolbar lets you browse and insert emojis directly into your messages. [#24704](https://github.com/open-webui/open-webui/pull/24704)
|
||||
- 🪄 **Per-chat skills toggle.** Skills can now be turned on or off for a conversation directly from the chat Integrations menu, the same way tools and capabilities already work, instead of only through the model preset. [#25036](https://github.com/open-webui/open-webui/issues/25036), [#25037](https://github.com/open-webui/open-webui/pull/25037)
|
||||
- 🔎 **Access preview for users and groups.** Administrators can now preview exactly which models, knowledge bases, and tools a given user or group can access, making it easier to audit and verify permission setups. [Commit](https://github.com/open-webui/open-webui/commit/9c14740ffb009d550dcbd5d6c599dac57053f112)
|
||||
- 📄 **Configurable knowledge base file page size.** Administrators can now request a larger page size when listing a knowledge base's files through the API, reducing the number of requests needed to retrieve large collections instead of paging through fixed increments of 30. [#25148](https://github.com/open-webui/open-webui/issues/25148), [Commit](https://github.com/open-webui/open-webui/commit/a4d1b3e9378a61a11dd9822a8dbb525f39753081)
|
||||
- 🔃 **Persistent processing indicator for knowledge files.** Files still being processed in a knowledge base now keep showing a processing indicator across page reloads, so you can tell what's still ingesting after navigating away and back. [#25031](https://github.com/open-webui/open-webui/issues/25031), [Commit](https://github.com/open-webui/open-webui/commit/ad9f2eeb15a448147d5e47e6a391cabb9aefd0ea)
|
||||
- 📑 **MinerU file type configuration.** Administrators can now configure which file types are processed by the MinerU document loader, via the new "MINERU_FILE_EXTENSIONS" setting, extending it beyond PDF to formats like DOCX, PPTX, and XLSX. [Commit](https://github.com/open-webui/open-webui/commit/d4030a8aa5d48c2a1cb06c461566844aca2530ab)
|
||||
- 📃 **Legacy Word document support.** Older ".doc" Word files can now have their text extracted by the default document extraction engine, in addition to the modern ".docx" format. [Commit](https://github.com/open-webui/open-webui/commit/9e3e24e304f8ff210494380b46f614a2984cafb3)
|
||||
- 📁 **Create subfolders from the folder header.** Chat folders can now have subfolders created directly from the folder header in the chat view, not just from the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/1f0948bcbef2af73b155535ea27762c522260afc)
|
||||
- ⚡ **Faster initial page loads.** The configuration endpoint that loads on every page visit no longer runs an unnecessary user-count query, making the initial application load lighter on the database, especially on instances with many users. [Commit](https://github.com/open-webui/open-webui/commit/0adc090dcbe636c9c9645d8ec7f6b89ea514870b)
|
||||
- 🚀 **Faster tool-enabled chat completions.** Chat completions that use multiple tools now start faster because the tools they reference are fetched from the database in a single batch query instead of one query per tool. [#24808](https://github.com/open-webui/open-webui/pull/24808), [Commit](https://github.com/open-webui/open-webui/commit/cc94a90b4d4d690bc7cb9f7124f2d6e552973970)
|
||||
- 🏎️ **More responsive web search under load.** Web search through SearXNG, Google PSE, Brave, Serper, and Serpstack now uses non-blocking network calls, so the server stays responsive to other users while a search is in flight, and concurrent multi-query searches complete faster. [Commit](https://github.com/open-webui/open-webui/commit/b94245d2ee191e8ef118bf9de1ff7539503bfec9)
|
||||
- 🐎 **Lighter Ollama backend connections.** Requests to Ollama backends now reuse a shared connection pool instead of opening a fresh session each time, reducing TCP and TLS handshake overhead for installs that poll Ollama frequently or have multiple backends configured. [Commit](https://github.com/open-webui/open-webui/commit/5d9a09a88a9094ebfcd249340be3aaee544b34d0)
|
||||
- 💽 **Fewer redundant model-list writes.** On multi-instance deployments backed by Redis, the model list is no longer rewritten when it hasn't changed, cutting a major source of redundant writes. [#25469](https://github.com/open-webui/open-webui/issues/25469), [#25474](https://github.com/open-webui/open-webui/pull/25474), [Commit](https://github.com/open-webui/open-webui/commit/fd76b51ab2ad4c4192f2c98153e47888712c2009)
|
||||
- 📉 **Faster websocket disconnect cleanup.** Disconnecting from a collaborative session no longer triggers a scan across the entire Redis keyspace, using a per-session index instead, which keeps disconnects cheap on large deployments. [#25466](https://github.com/open-webui/open-webui/issues/25466), [Commit](https://github.com/open-webui/open-webui/commit/c7de057a4a54cc80f366ad949417e78fae756d4c)
|
||||
- 📝 **Frontmatter auto-fill for tools, functions, and skills.** Opening a tool, function, or skill editor now auto-fills the name, id, and description fields from the file's frontmatter, saving you from re-entering metadata already declared in the source. [#24649](https://github.com/open-webui/open-webui/pull/24649), [Commit](https://github.com/open-webui/open-webui/commit/ef975649b26d3e7cd589c49be2fc77cee80fad8f)
|
||||
- 🪪 **More user placeholders in custom headers.** Custom-header templates for direct connections and tool servers now support "{{USER_EMAIL}}" and "{{USER_ROLE}}" alongside the existing user and session placeholders. [Commit](https://github.com/open-webui/open-webui/commit/ed73ef3d8df988b0e9646b82df5b1a453202ef8d)
|
||||
- ⏱️ **Configurable MCP connection timeout.** The timeout for the initial handshake with an MCP tool server is now configurable via the new "MCP_INITIALIZE_TIMEOUT" setting, so servers that are slow to start or expose many tools can finish connecting instead of timing out. [#25011](https://github.com/open-webui/open-webui/pull/25011), [Commit](https://github.com/open-webui/open-webui/commit/4297c02b121180e239a61c483ac8477cc557d4ef)
|
||||
- 📐 **Profile image size limit.** Administrators can now cap the size of inline profile images via the new "PROFILE_IMAGE_MAX_DATA_URI_SIZE" setting, bounding how much database and cache space inline avatars and model icons can consume. [#25468](https://github.com/open-webui/open-webui/issues/25468), [#25476](https://github.com/open-webui/open-webui/pull/25476)
|
||||
- 🎫 **Wildcard OAuth role mapping.** Administrators can now set "\*" in the allowed OAuth roles to grant the user role to any authenticated OAuth user, instead of having to enumerate every accepted role. [#25062](https://github.com/open-webui/open-webui/pull/25062), [Commit](https://github.com/open-webui/open-webui/commit/07cbc91a8eba3a9a3b39588b2ae5de916930af70)
|
||||
- 📊 **Paginated feedback history.** The feedback and evaluation history list is now paginated, keeping it responsive for instances that have accumulated large numbers of feedback entries. [Commit](https://github.com/open-webui/open-webui/commit/160a6694e4bd66fc42e6361947516e8e15414ef3)
|
||||
- 🔘 **Bulk enable or disable automations.** Automations can now be enabled or disabled in bulk from an actions menu on the automations page, instead of toggling each one individually. [Commit](https://github.com/open-webui/open-webui/commit/675e9bee5af8b9fb390fcb235a6c2c4766e78c75)
|
||||
- ➡️ **Optional auto-redirect to single sign-on.** Administrators can now enable "OAUTH_AUTO_REDIRECT" so that, on deployments with a single sign-on provider and no other login methods, users are sent straight to the provider instead of seeing a login page first. [#25067](https://github.com/open-webui/open-webui/pull/25067), [Commit](https://github.com/open-webui/open-webui/commit/d64ef1803d2f5cedb7b3a308151d08ff2cd2b8e1)
|
||||
- ☁️ **Azure AI Foundry v1 with Entra ID.** Open WebUI now supports Azure AI Foundry's OpenAI v1 endpoint together with Microsoft Entra ID authentication, so these connections work without manual workarounds. [#24761](https://github.com/open-webui/open-webui/issues/24761), [#24985](https://github.com/open-webui/open-webui/pull/24985), [Commit](https://github.com/open-webui/open-webui/commit/eb4eebc3ce1042cb0d393bf890c1895db6e08b19)
|
||||
- 🌎 **Linkup web search provider.** Administrators can now select Linkup as the web search provider from the admin settings, with options to configure the API key and search depth. [#24752](https://github.com/open-webui/open-webui/pull/24752), [Commit](https://github.com/open-webui/open-webui/commit/56c0d00e13c74d665124ec9f1cae78e83ca60b6a)
|
||||
- 🧊 **Valkey vector database support.** Valkey can now be used as the vector database backend, configurable through new "VALKEY_URL" and related settings including index type, distance metric, and HNSW tuning. [#24769](https://github.com/open-webui/open-webui/pull/24769), [Commit](https://github.com/open-webui/open-webui/commit/c0f1aa291938bbef62db8d4013dbc498b0abea15)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Spanish (Spain), Swedish, German, Korean, Catalan, Russian, Irish, Simplified Chinese, Traditional Chinese, Finnish, Polish, Turkish, and Malay were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section — some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
|
||||
- 🛡️ **Tool server permission enforcement.** The per-user permission for inline tool servers is now enforced on chat-completion requests, so users without that permission can no longer bypass the admin setting by supplying tool servers directly in their requests. [Commit](https://github.com/open-webui/open-webui/commit/5cc1eb517094e3507915664817285f1e6e37a16d)
|
||||
- 🔒 **Knowledge base access check in search tool.** The built-in knowledge search tool now verifies that the caller can access a knowledge base before searching it by id, preventing users from reading the contents of knowledge bases they have not been granted access to. [#25113](https://github.com/open-webui/open-webui/pull/25113)
|
||||
- 🗄️ **Cross-user access to retrieval collections.** Resolving the documents used for retrieval now verifies the caller's access to each referenced file and rejects client-supplied collection names, preventing a crafted request from pulling another user's files or vector collections into its context. [Commit](https://github.com/open-webui/open-webui/commit/ee47c9c833f8889f3abe99d2266c56e8f8d40230)
|
||||
- 🔣 **Collection name validation.** Vector collection names are now rejected unless they contain only safe characters, preventing malformed names from reaching the vector store or breaking out of a database query expression. [#24982](https://github.com/open-webui/open-webui/pull/24982)
|
||||
- 🚫 **Unscoped retrieval collections denied by default.** Retrieval requests for collection names that don't correspond to a known file, memory, web-search, or knowledge base are now denied for non-admins by default, with a new "ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS" setting to restore the previous behavior if needed. [Commit](https://github.com/open-webui/open-webui/commit/c93f071700520a60833b7f9616c292b11f210880)
|
||||
- 📜 **Prompt history authorization.** Comparing, deleting, and restoring prompt versions now verify the history entry belongs to the prompt you're authorized for, preventing access to or modification of another prompt's version history. [#25056](https://github.com/open-webui/open-webui/pull/25056)
|
||||
- 🚦 **Code interpreter permission on the legacy path.** The legacy code-execution path now enforces the same permission and capability checks as the current one, so users without the code interpreter permission can no longer trigger code execution through it. [#24724](https://github.com/open-webui/open-webui/pull/24724)
|
||||
- 🧱 **API key endpoint restriction bypass.** The endpoint allow-list that limits which paths an API key may reach is now matched against the routed request path directly, preventing a crafted request from slipping past the restriction. [#25123](https://github.com/open-webui/open-webui/pull/25123)
|
||||
- 🚧 **System prompt bypass via request parameter.** The flag that skips a model's configured system prompt can no longer be set by external clients through a request parameter, so admin-configured system prompts can't be bypassed from the API. [#25156](https://github.com/open-webui/open-webui/pull/25156)
|
||||
- 🚪 **Terminal proxy path traversal.** The terminal proxy now fully decodes request paths before validating them, blocking multi-encoded payloads that could otherwise escape the intended path. [#25157](https://github.com/open-webui/open-webui/pull/25157)
|
||||
- 🪤 **Cache file path traversal.** The cache file server now requires an exact directory boundary match, closing a gap where a sibling directory whose name began with the cache directory's name could be used to serve files from outside it. [#25086](https://github.com/open-webui/open-webui/pull/25086)
|
||||
- 🔀 **Ollama backend selection access check.** Requests can no longer target an arbitrary Ollama backend by index; a caller-supplied backend selector is now verified against the backends that actually serve the requested model. [Commit](https://github.com/open-webui/open-webui/commit/7139797be04030b9d1016782f1dbb251c5fe68bb)
|
||||
- 🔓 **Cross-user file exfiltration via image URLs.** When a chat message references a file by id in an "image_url" field, the server now resolves that file only for its owner, an administrator, or a user with an explicit read grant, preventing other authenticated users from extracting a file's contents by routing it through the model. [#24625](https://github.com/open-webui/open-webui/pull/24625), [Commit](https://github.com/open-webui/open-webui/commit/c75fe8e74b72617c51282cc3ea0a2e8d9cdd9140)
|
||||
- 📌 **Chat file attachment access checks.** Attaching files to a chat now links only files the caller can read, preventing a user from associating another user's file with their chat to access its contents. [#25054](https://github.com/open-webui/open-webui/pull/25054)
|
||||
- 🧾 **Model knowledge file ownership checks.** Creating or updating a model now verifies that any knowledge files attached to it are files the editor can access, preventing another user's files from being attached to a model. [#25055](https://github.com/open-webui/open-webui/pull/25055), [Commit](https://github.com/open-webui/open-webui/commit/27fb20c13a4bf8501f7a485abe0654eb5880980d)
|
||||
- 📅 **Calendar event move authorization.** Updating a calendar event to move it into a different calendar now requires write access on the destination calendar, preventing users from injecting events into calendars they cannot write to. [#24764](https://github.com/open-webui/open-webui/pull/24764)
|
||||
- 📣 **Channel chat access control.** Generating a response in a channel context now verifies the caller's access to that channel and scopes the included messages, preventing access to channels or messages the user isn't permitted to see. [#24725](https://github.com/open-webui/open-webui/pull/24725)
|
||||
- 🕸️ **Web loader SSRF gating with Playwright.** When the Playwright-based web loader is in use, page navigations and redirects are now validated the same way as the default loader, closing a gap where the Playwright path could reach internal or otherwise blocked URLs. [#24756](https://github.com/open-webui/open-webui/pull/24756)
|
||||
- 🛂 **DNS rebinding protection for URL fetches.** The IP address validated for an outbound URL fetch is now the same one used for the actual connection, closing a DNS rebinding window where an attacker-controlled hostname could resolve to a public IP during the safety check and then to a private IP when the connection was opened. [#24759](https://github.com/open-webui/open-webui/pull/24759)
|
||||
- 🪞 **OAuth profile picture redirect handling.** The OAuth profile picture fetch now follows redirects only when administrators have explicitly allowed it, closing a window where a redirect from an externally validated URL could be used to reach internal addresses. [#24809](https://github.com/open-webui/open-webui/pull/24809)
|
||||
- 🧼 **Model profile image script injection.** Model profile images are now validated on save and only served inline when they are a known-safe image type, preventing a crafted SVG profile image from running scripts in other users' browsers, while existing legacy images that fail validation are cleared gracefully instead of breaking the model list. [#25060](https://github.com/open-webui/open-webui/pull/25060), [#25173](https://github.com/open-webui/open-webui/pull/25173)
|
||||
- 🧯 **Diagram rendering script injection.** Mermaid diagrams rendered in chat are now sanitized before display, preventing a crafted diagram from running scripts in the viewer's browser. [#25219](https://github.com/open-webui/open-webui/pull/25219)
|
||||
- 🔐 **Shared-chat file write protection.** Access to a file through a shared chat now only grants read access, so users who can read a shared chat can no longer modify or delete files attached to it. [#24755](https://github.com/open-webui/open-webui/pull/24755)
|
||||
- 🔏 **Cross-origin embed prompt control.** When Open WebUI is embedded in an iframe on a different origin, the embedding page can now only drive the chat input or submit prompts if the user has explicitly opted in via the "iframe Sandbox Allow Same Origin" setting, preventing untrusted host pages from triggering confirmation dialogs or controlling the chat. [#24767](https://github.com/open-webui/open-webui/pull/24767), [Commit](https://github.com/open-webui/open-webui/commit/eb3076c1b02d90c2ce6e6d3beb08a37987c740ec)
|
||||
- 🗂️ **Chat folder ownership checks.** Creating a chat or updating a chat's folder now verifies the referenced folder belongs to the current user, preventing chats from being associated with folders owned by other people. [#24588](https://github.com/open-webui/open-webui/pull/24588)
|
||||
- 🧩 **Chat recovery from corrupted history.** Chats whose internal message graph was left in a malformed state by a failed regeneration now open and load correctly, with missing roles, parent references, and current-message pointers reconstructed automatically instead of breaking the chat. [#24424](https://github.com/open-webui/open-webui/issues/24424), [#24157](https://github.com/open-webui/open-webui/issues/24157), [#20474](https://github.com/open-webui/open-webui/issues/20474), [#24799](https://github.com/open-webui/open-webui/pull/24799), [Commit](https://github.com/open-webui/open-webui/commit/d310a0777c4c48ec772bdc9a510005d5e91b09c7)
|
||||
- 📨 **Imported chats with folders appear correctly.** Importing grouped chats no longer leaves them invisible when a referenced folder is missing; such chats now appear in the chat list instead of being silently orphaned. [#24910](https://github.com/open-webui/open-webui/issues/24910), [Commit](https://github.com/open-webui/open-webui/commit/7f7cd210186cb6a67e028c17974eb210fc7ba9fd)
|
||||
- 🎟️ **MCP tool server sessions stay connected.** OAuth-authenticated MCP tool server sessions are no longer mistakenly refreshed and deleted by the single sign-on session handler, so those connections stay active. [#24618](https://github.com/open-webui/open-webui/issues/24618), [Commit](https://github.com/open-webui/open-webui/commit/c8eb8edca4174ec68fabd071d6b08c0bc07f8117)
|
||||
- 🤝 **MCP OAuth scope discovery.** The OAuth flow for MCP tool servers now reads the scopes a server advertises through its Protected Resource Metadata, so connecting to servers that declare their own scopes succeeds. [#24730](https://github.com/open-webui/open-webui/issues/24730), [#24690](https://github.com/open-webui/open-webui/pull/24690)
|
||||
- 🔍 **Web search reliability.** Web search again fetches page content reliably with the default web loader engine, a new "USER_AGENT" environment variable lets administrators set a real browser user-agent so fetches aren't blocked by Cloudflare, Wikipedia, and other bot-detection systems, and the startup script no longer fails to launch when these new environment variables are unset. [#24560](https://github.com/open-webui/open-webui/issues/24560), [#24793](https://github.com/open-webui/open-webui/issues/24793), [#24683](https://github.com/open-webui/open-webui/pull/24683), [Commit](https://github.com/open-webui/open-webui/commit/f60733758272c4532cd032e518c9cd73f648043a)
|
||||
- 🔥 **Firecrawl web search results.** Web search using Firecrawl now returns results correctly regardless of which response format the Firecrawl version uses. [#24712](https://github.com/open-webui/open-webui/pull/24712)
|
||||
- 🦅 **Kagi web search.** Web search using Kagi works again after its API endpoint and request method were updated to match Kagi's current API. [#25015](https://github.com/open-webui/open-webui/pull/25015)
|
||||
- 🔢 **Bracketed numbers in code blocks.** Numbers in square brackets such as "[0]" inside code blocks are no longer stripped out as if they were source citations, so code displays and copies correctly. [#24948](https://github.com/open-webui/open-webui/issues/24948), [Commit](https://github.com/open-webui/open-webui/commit/e90a618f4555cea2c024bb60c1332ca04eed96da)
|
||||
- 🔌 **API chat completions reliability.** Direct calls to the chat completions API no longer fail with an internal error when no chat session identifier is supplied. [#24553](https://github.com/open-webui/open-webui/issues/24553), [#25235](https://github.com/open-webui/open-webui/issues/25235), [Commit](https://github.com/open-webui/open-webui/commit/bc244fdc90504824b76654880898bf3f6649c299), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc)
|
||||
- 🖼️ **ComfyUI image generation and editing.** Generating and editing images via a ComfyUI backend now works again, including when ComfyUI is hosted on a private or internal network where URL validation was previously blocking the admin-configured endpoint. [#24565](https://github.com/open-webui/open-webui/issues/24565), [Commit](https://github.com/open-webui/open-webui/commit/7dcd932ad7cb5feab007737c6e0281def5cd47fc), [Commit](https://github.com/open-webui/open-webui/commit/8aa2a42dc7887512e697ff85d044220945ced40f)
|
||||
- 🖌️ **Image generation with non-standard response headers.** Image generation now works with backends that return valid JSON without a standard content-type header, instead of rejecting the response. [#24838](https://github.com/open-webui/open-webui/pull/24838)
|
||||
- 🐘 **Knowledge search on large documents.** Searching knowledge bases on PostgreSQL no longer fails when scanning across documents with very large extracted text content. [#24670](https://github.com/open-webui/open-webui/issues/24670), [Commit](https://github.com/open-webui/open-webui/commit/d74ee34d9128295faa116c919bd1dcca77744975)
|
||||
- 💬 **Chat title generation.** Automatically generated chat titles now use the model currently selected in the dropdown for the active chat and fall back to the model from the active message branch otherwise, and a clear message is shown if no model is available instead of an unhelpful error. [#24604](https://github.com/open-webui/open-webui/issues/24604), [#24745](https://github.com/open-webui/open-webui/issues/24745), [Commit](https://github.com/open-webui/open-webui/commit/e5c8f8110a88739e011d8ab23e5cb11b4768469f), [Commit](https://github.com/open-webui/open-webui/commit/3c5e7968f0b5130890e17f1f2a2a52297cb145a6)
|
||||
- 🧮 **Message search and analytics consistency.** Edits, deletions, and branch changes made in a chat are now reflected in message search results and analytics counts instead of leaving stale entries behind. [#25205](https://github.com/open-webui/open-webui/pull/25205), [Commit](https://github.com/open-webui/open-webui/commit/aa06200f789a4c3fc54ea9a141cd347d76cda4c5)
|
||||
- 🩹 **Graceful handling of in-chat task failures.** When web search query generation, image prompt generation, or a tool call fails or references a missing tool, the chat now falls back or surfaces a clear error instead of breaking partway through the response. [#25038](https://github.com/open-webui/open-webui/issues/25038), [#25144](https://github.com/open-webui/open-webui/issues/25144), [Commit](https://github.com/open-webui/open-webui/commit/b64fd988f02b8a4295193892b24b00bdf635a4dc)
|
||||
- 🎛️ **Filter changes to message output.** Filter functions that modify a message's structured output after generation now have those changes saved and displayed, instead of being discarded when only the output, not the text content, was changed. [#24884](https://github.com/open-webui/open-webui/pull/24884)
|
||||
- ⏩ **Titles and tags reflect filtered output.** Outlet filters now run before automatic title, tag, and follow-up generation, so those are based on the final filtered message instead of the unfiltered version. [#24717](https://github.com/open-webui/open-webui/pull/24717)
|
||||
- 💾 **Action-replaced message content persists.** Message content replaced by an action function through its event emitter is now kept when the chat is saved, instead of reverting to the original after a page reload. [#24585](https://github.com/open-webui/open-webui/issues/24585), [#25485](https://github.com/open-webui/open-webui/pull/25485)
|
||||
- 🏷️ **Skill mentions in messages.** Mentioning a skill in a message now keeps the skill's name as readable text instead of removing it, and selecting a skill without typing anything no longer causes an error on providers that reject empty messages. [#24929](https://github.com/open-webui/open-webui/issues/24929), [Commit](https://github.com/open-webui/open-webui/commit/01810e32ad51305ca3247e8f83da95f6e6260f0c)
|
||||
- 🧹 **Usage timer cleanup on send failure.** The background usage-stats timer started during message generation is now always cleared, even when sending a message fails, preventing leaked timers from accumulating over a session. [#25478](https://github.com/open-webui/open-webui/pull/25478)
|
||||
- 🗑️ **Background tasks stop when a chat is removed.** Deleting or archiving a chat now cancels any in-flight generation or title and tag tasks for it, instead of leaving orphaned background work running. [#25050](https://github.com/open-webui/open-webui/pull/25050), [Commit](https://github.com/open-webui/open-webui/commit/778dba1d6b8dc3962163fa7bf9d802d9a07fba26)
|
||||
- ⌨️ **Responsive knowledge file search.** Searching for knowledge files in the chat picker and model knowledge selector now matches on file names by default instead of scanning the full extracted text of every document on each keystroke, keeping the search responsive on large deployments, with content search available as an explicit opt-in. [#25082](https://github.com/open-webui/open-webui/issues/25082), [#25119](https://github.com/open-webui/open-webui/pull/25119), [Commit](https://github.com/open-webui/open-webui/commit/591e0aafa1d5e21dcd9fa1279dada2076968b9cb)
|
||||
- 📥 **Document processing with empty embeddings.** Saving documents to the vector database no longer crashes when an embedding step returns no vectors, allowing the process to continue instead of failing the whole upload. [#25166](https://github.com/open-webui/open-webui/issues/25166)
|
||||
- 🔤 **Non-UTF-8 text and CSV uploads.** Text and CSV files saved in legacy encodings, including Latin-1, Windows-1252, and Chinese encodings such as GB18030, are now detected and loaded correctly instead of being rejected as binary or failing with an empty-content error. [#25172](https://github.com/open-webui/open-webui/issues/25172), [#24973](https://github.com/open-webui/open-webui/issues/24973), [Commit](https://github.com/open-webui/open-webui/commit/6f0277db52d005420480abb0702d421525d6ea8b), [Commit](https://github.com/open-webui/open-webui/commit/1bbb2b933d4cee70a5102eca4109e77b8625f8fc)
|
||||
- 🧽 **Null bytes in nested data no longer break saves.** Data containing null bytes nested inside structured fields is now sanitized correctly before being written, preventing database errors that the previous check failed to catch. [#25018](https://github.com/open-webui/open-webui/pull/25018), [Commit](https://github.com/open-webui/open-webui/commit/e3ab4bd212e44c39f439ec4ff5df7c3dbd046895)
|
||||
- 🧠 **Clear error when no embedding model is configured.** Using knowledge or retrieval features without a loaded embedding model now returns a clear setup error explaining what to configure, instead of failing with a cryptic crash. [Commit](https://github.com/open-webui/open-webui/commit/55ca719bbf76306ed647424c728f730d0bef21f9)
|
||||
- 🧲 **Memory search quality.** Memory searches now apply the configured embedding query prefix, so retrieval works correctly with embedding models that require one for queries. [#24921](https://github.com/open-webui/open-webui/pull/24921), [Commit](https://github.com/open-webui/open-webui/commit/ce4dca47cb19a6582fd8a550806c89ab297c038d)
|
||||
- 📚 **Knowledge tool context overflow.** The built-in tool that lists a model's knowledge no longer dumps every file in every knowledge base into the model's context; it now returns summaries by default and paginates file listings only for a requested knowledge base. [#25105](https://github.com/open-webui/open-webui/pull/25105), [Commit](https://github.com/open-webui/open-webui/commit/0e73f7af099e1f9437c55a5c2d16741c97dbd57f)
|
||||
- ⏳ **Terminal session stability.** The terminal proxy no longer hangs when one direction of the connection closes before the other, so terminal sessions shut down cleanly instead of stalling. [#25464](https://github.com/open-webui/open-webui/issues/25464), [#25479](https://github.com/open-webui/open-webui/pull/25479)
|
||||
- 🧷 **Tool call continuity with strict providers.** Chats that contain incomplete tool calls or orphaned tool results no longer fail to continue when sent to providers that strictly validate tool pairings, such as Anthropic and AWS Bedrock Converse. [#24758](https://github.com/open-webui/open-webui/issues/24758), [#24940](https://github.com/open-webui/open-webui/issues/24940), [#24798](https://github.com/open-webui/open-webui/pull/24798), [Commit](https://github.com/open-webui/open-webui/commit/cfa6908d579e1f7f202a321289029996130b8411)
|
||||
- 🛑 **Stream termination for pipe functions.** Streamed responses from pipe functions now always send the standard end-of-stream marker, so chat clients and external integrations reliably detect when a response is complete instead of waiting on streams that already finished. [#24763](https://github.com/open-webui/open-webui/pull/24763)
|
||||
- 🔊 **Non-blocking text-to-speech transcoding.** Converting text-to-speech audio to MP3 no longer blocks the server's event loop, so other requests stay responsive even while a TTS response is being transcoded. [#24876](https://github.com/open-webui/open-webui/pull/24876)
|
||||
- 🎚️ **Default text-to-speech voice.** Text-to-speech requests now honor the voice specified in the request and fall back to the configured default only when none is given, instead of always using the admin default or failing. [#15143](https://github.com/open-webui/open-webui/issues/15143), [#25035](https://github.com/open-webui/open-webui/issues/25035), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc), [Commit](https://github.com/open-webui/open-webui/commit/750604a11d4adcb5ae568b9fc93010031ad394f9)
|
||||
- 🪝 **Reliable knowledge base file linking.** Files uploaded to a knowledge collection are now linked on the server as part of the upload itself, so they remain attached to the collection even if you navigate away or close the page before processing finishes. [#24807](https://github.com/open-webui/open-webui/issues/24807), [Commit](https://github.com/open-webui/open-webui/commit/d0b17f056911ec73df2b686a0d92bb789391db27)
|
||||
- ☁️ **Azure connections on custom hostnames.** Connections marked as the Azure provider now use the Azure code path even when the endpoint does not contain "azure" in its hostname, fixing custom Azure deployments served from non-standard domains. [#24882](https://github.com/open-webui/open-webui/pull/24882), [Commit](https://github.com/open-webui/open-webui/commit/c8f851bd2de3d127f1e818fc116c27174f96d3f1)
|
||||
- 🗓️ **Clearing calendar event fields.** Removing the description or location from a calendar event now saves correctly instead of silently keeping the previous value. [#25026](https://github.com/open-webui/open-webui/issues/25026), [Commit](https://github.com/open-webui/open-webui/commit/91810f1c4e93d4f559bdf61cd085cadc288a732d), [Commit](https://github.com/open-webui/open-webui/commit/78b1637a035d71099262412e5dee3e4d65c7fb2f)
|
||||
- 💭 **Advanced parameter settings.** Custom reasoning tags and custom model parameters are now saved correctly instead of being dropped, and the presence penalty and repeat penalty no longer save the frequency penalty's value instead of their own. [#25183](https://github.com/open-webui/open-webui/pull/25183), [#25200](https://github.com/open-webui/open-webui/pull/25200), [#25204](https://github.com/open-webui/open-webui/pull/25204)
|
||||
- 📏 **Long username display.** Long usernames no longer overflow their containers in the admin user list, user modals, and sidebar. [#25185](https://github.com/open-webui/open-webui/pull/25185)
|
||||
- 🎯 **All skills selectable in the model editor.** The model editor's skills selector now lists every skill you have access to, with a search box for large lists, instead of showing only the first 30 with no way to reach the rest. [#24873](https://github.com/open-webui/open-webui/issues/24873), [Commit](https://github.com/open-webui/open-webui/commit/936d5f2676dfbd2ba763b30af33a8557dcda9b05)
|
||||
- 🔔 **Accurate knowledge upload feedback.** Dragging files into a knowledge base no longer shows an upload notification before the upload has actually been processed. [#25484](https://github.com/open-webui/open-webui/pull/25484)
|
||||
- ♿ **High-contrast timestamp readability.** The user message timestamp now uses the correct colors in high-contrast mode instead of inverted ones, keeping it readable. [#25461](https://github.com/open-webui/open-webui/pull/25461)
|
||||
- ♿ **Keyboard and screen reader access to menus.** The integrations, more-options, and user menus are now real buttons with labels and keyboard support, so they can be opened with the keyboard and announced by screen readers. [Commit](https://github.com/open-webui/open-webui/commit/346dab3d8f909fc321a49ea2be633ea5c4c4a349)
|
||||
- 🖱️ **Focus-loss handling in editors.** Workspace and admin editors for models, tools, functions, and skills again respond correctly when the browser window loses focus, after the wrong event name was being listened for. [#25459](https://github.com/open-webui/open-webui/pull/25459)
|
||||
- 🛟 **Resilience to corrupted local storage.** Corrupted data in the browser's local storage no longer crashes the interface; affected settings and dismissed-banner state now fall back to safe defaults. [#25481](https://github.com/open-webui/open-webui/pull/25481)
|
||||
- 📶 **Quieter reconnection notifications.** Brief connection interruptions, such as backgrounding a mobile tab, no longer flash a "connection lost" warning, and the "reconnected" message only appears if a disconnect was actually shown. [Commit](https://github.com/open-webui/open-webui/commit/77c8c54b1ea07189e21313f9cd401f3b956bb93a)
|
||||
- 🍎 **Safari PDF handling.** PDF processing now works in Safari, which doesn't support the stream iteration the previous code relied on. [#25151](https://github.com/open-webui/open-webui/issues/25151), [#25473](https://github.com/open-webui/open-webui/pull/25473)
|
||||
- 🎙️ **Voice mode mute shortcut listing.** The keyboard shortcut for muting voice mode now appears in the keyboard shortcuts help modal. [#25193](https://github.com/open-webui/open-webui/pull/25193)
|
||||
- 📎 **Document attachments in channel model replies.** Tagging a model in a channel thread now forwards uploaded non-image documents such as PDFs and DOCX files into the model's context, so document summarization and comparison workflows that already worked in direct chat now work in channels too. [#24896](https://github.com/open-webui/open-webui/issues/24896), [#24898](https://github.com/open-webui/open-webui/pull/24898), [Commit](https://github.com/open-webui/open-webui/commit/7e9d41d664d7065a92ac93606d18e88c45eb36b6)
|
||||
- 🙈 **Hidden models in channel mentions.** Models marked as hidden no longer appear in the channel message-input model mention selector, matching how hidden models are excluded elsewhere in the interface. [#24892](https://github.com/open-webui/open-webui/pull/24892)
|
||||
- 🧵 **Channel thread and pinned message stability.** Opening a channel thread or the pinned messages view no longer fails to render when a message or its data is missing. [#25209](https://github.com/open-webui/open-webui/pull/25209)
|
||||
- 📺 **YouTube short link transcripts.** Pasting a "youtu.be" short link into a chat now loads the video transcript correctly instead of failing with an empty-content error. [#24856](https://github.com/open-webui/open-webui/issues/24856), [Commit](https://github.com/open-webui/open-webui/commit/1e36a206008c3abce5ccdc98d5750e30a7345b98)
|
||||
- 🙉 **Hidden models in default-model and automation pickers.** The admin pickers for default models and default pinned models, and the automation model dropdown, now filter out hidden models, consistent with how hidden models are treated elsewhere. [#24869](https://github.com/open-webui/open-webui/issues/24869), [Commit](https://github.com/open-webui/open-webui/commit/1fa3050f069a72de1daaaa29233b545c4deaea51), [Commit](https://github.com/open-webui/open-webui/commit/4705c2d98812c1189c2ae4960399bc8888d4861c)
|
||||
- 🔊 **Speech-to-text SSL setting honored.** Speech-to-text requests now respect the "AIOHTTP_CLIENT_SESSION_SSL" setting, so administrators using self-signed certificates or custom SSL configurations can use STT engines that were previously failing TLS verification. [#24568](https://github.com/open-webui/open-webui/issues/24568), [#24857](https://github.com/open-webui/open-webui/pull/24857), [Commit](https://github.com/open-webui/open-webui/commit/2ca91ceeeca6a6a21e5a6ad68ea3ab8c9d9f6deb), [Commit](https://github.com/open-webui/open-webui/commit/94b66b17972e0ad77954df4b81a6bb86a7a6a04b)
|
||||
- 🔗 **Placeholders in MCP connection headers.** Custom header templates configured on MCP server connections now have their "{{USER_ID}}", "{{USER_NAME}}", "{{USER_EMAIL}}", "{{USER_ROLE}}", "{{CHAT_ID}}", and "{{MESSAGE_ID}}" placeholders interpolated at request time, matching how custom headers already work for direct connections and tool servers. [#24822](https://github.com/open-webui/open-webui/pull/24822)
|
||||
- 🪟 **Bing search CLI smoke test.** Running the Bing web-search module from the command line for a quick connectivity check no longer raises an error about missing arguments. [#24765](https://github.com/open-webui/open-webui/issues/24765), [#24768](https://github.com/open-webui/open-webui/pull/24768)
|
||||
- 🩺 **Database health check recovery.** After a transient database connection error, the health check endpoint now recovers automatically instead of staying permanently broken on the affected worker. [Commit](https://github.com/open-webui/open-webui/commit/0b81520e072bb4ed15532b8e153b43dd3243feaf)
|
||||
- 🥾 **Startup on non-Unicode consoles.** Open WebUI no longer crashes at startup when the console can't encode the banner's box-drawing characters, such as on Windows or with redirected or headless output, falling back to a plain-text banner instead. [#24965](https://github.com/open-webui/open-webui/issues/24965), [#25482](https://github.com/open-webui/open-webui/pull/25482)
|
||||
- 🆕 **First admin signup after a reset.** Creating the first administrator account is no longer blocked by a previously stored signup setting, so a fresh or reset instance can always be bootstrapped. [#24821](https://github.com/open-webui/open-webui/pull/24821)
|
||||
- 🪵 **JSON exception logging.** With JSON log formatting enabled, exceptions are now recorded correctly with a structured type, message, and stacktrace instead of being dropped, and a logging failure can no longer crash the application. [#25135](https://github.com/open-webui/open-webui/issues/25135), [Commit](https://github.com/open-webui/open-webui/commit/79bf3d28d88e78b0136ef3c3c9f8e7bb85d3cea9)
|
||||
- 🧭 **Workspace skills permission.** Users granted only the "workspace.skills" permission can now see the workspace entry in the sidebar and are correctly routed to the skills page from the workspace index. [#24729](https://github.com/open-webui/open-webui/pull/24729)
|
||||
- 🔁 **Resilient database migrations.** Database migrations now skip tables, indexes, and columns that already exist and add missing primary keys to legacy tables, so upgrades succeed even when parts of the schema were manually or partially created beforehand. [Commit](https://github.com/open-webui/open-webui/commit/81f611fb73c726cfcfc20ee57a926a543f07e95f), [Commit](https://github.com/open-webui/open-webui/commit/f0e88dadc8502ea05b1a00f3155bee7d1cf32249), [Commit](https://github.com/open-webui/open-webui/commit/bd9f82d5a681ee94bde44325033447500a0c76bf), [Commit](https://github.com/open-webui/open-webui/commit/459b1c3fda2ec3579fbd2ab408d0fdeb07c96b99), [Commit](https://github.com/open-webui/open-webui/commit/6df09a4039d181f4e2d41324e93cd36c6fb27dfd), [Commit](https://github.com/open-webui/open-webui/commit/95840e307a66429168775de389b329487a4311c4), [Commit](https://github.com/open-webui/open-webui/commit/6b1df94bf933af92f5c7d093a2d92e2e50d6fdd0), [Commit](https://github.com/open-webui/open-webui/commit/98d3b2308564e2112290773ea240b90419ebb48d), [Commit](https://github.com/open-webui/open-webui/commit/1b9d22e324181b96511a15cbacc40fdbb439ad79), [Commit](https://github.com/open-webui/open-webui/commit/dc0f8ae6f2372b6e5dd42f3520813b09c54146c0), [Commit](https://github.com/open-webui/open-webui/commit/ee3b14233a642ff1bc38aef03a2418afcf5d6c1d), [Commit](https://github.com/open-webui/open-webui/commit/1004dad2749bcc369d511315a808d4bb5277854c), [Commit](https://github.com/open-webui/open-webui/commit/db2b3d7fd86cc7d76424671ead3c62a3f6302fe7), [Commit](https://github.com/open-webui/open-webui/commit/2e1b671e8db49f47d69fd59ece2b58e109ec8b84), [Commit](https://github.com/open-webui/open-webui/commit/9a8969ca93f8c9109d956518a085be3e2afacc07), [Commit](https://github.com/open-webui/open-webui/commit/9717ada92fdb3761a217d309f322ee9fbe418d0d), [Commit](https://github.com/open-webui/open-webui/commit/d7cfc1e46a8f3e5c8f6758c12ef641f41fb51a39), [Commit](https://github.com/open-webui/open-webui/commit/9263b7568eaa83f43e4a118c98490c4f7aaba570), [Commit](https://github.com/open-webui/open-webui/commit/73d2065227e651cf90a2cfade721516815743ab4), [#24722](https://github.com/open-webui/open-webui/pull/24722)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
|
||||
- ⚙️ **Tool-call iteration cap renamed and raised.** The environment variable that limits how many tool calls a single chat response may make is now "CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS", with its default raised from 30 to 256 and a new "-1" value for unlimited; the previous "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" name continues to work as a fallback, and chats that hit the cap now show a clear error in-chat instead of stopping silently. [#24918](https://github.com/open-webui/open-webui/pull/24918), [Commit](https://github.com/open-webui/open-webui/commit/2b99945d2726bdf5aed7b1712f9e3b7b622671df)
|
||||
- 🔐 **Reduced public "/api/config" exposure.** The "/api/config" response no longer includes several feature flags ("enable_api_keys", "enable_password_change_form", "enable_version_update_check", "enable_public_active_users_count", "enable_easter_eggs") for unauthenticated callers, reducing information disclosure to anonymous visitors. [Commit](https://github.com/open-webui/open-webui/commit/245e0ee029e9e10617f62953a9e6f67dd00ecf81), [Commit](https://github.com/open-webui/open-webui/commit/ae06e199d5d3f65296a978cc35079bdacba596d2)
|
||||
- 🔑 **"WEBUI_SECRET_KEY" is now a hard requirement even for unsupported deployments.** Deployments that start the backend in an explicitly unsupported way (such as invoking uvicorn directly) without setting "WEBUI_SECRET_KEY" will now refuse to start instead of falling back to an empty key; the supported start methods (start.sh, start_windows.bat, and "open-webui serve") still set or auto-generate it automatically, so standard deployments are unaffected. Direct Uvicorn startup is not supported. [#25218](https://github.com/open-webui/open-webui/pull/25218)
|
||||
|
||||
## [0.9.5] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- 🛡️ **Redirect-based SSRF protection.** All outbound HTTP requests now block 3xx redirects by default via a new `AIOHTTP_CLIENT_ALLOW_REDIRECTS` environment variable, preventing redirect-based SSRF where a public URL silently redirects to internal addresses (RFC 1918, loopback, cloud-metadata endpoints). Affected call sites include web fetch, image loading, OAuth discovery, tool server execution, and code interpreter login. [#24491](https://github.com/open-webui/open-webui/pull/24491)
|
||||
- 🛡️ **Iframe content security policy.** Administrators can now configure a Content-Security-Policy for all srcdoc iframes (Artifacts, tool embeds, file previews, citation modals) via the `IFRAME_CSP` environment variable, restricting what LLM-generated or user-uploaded HTML can load and execute inside previews. [Commit](https://github.com/open-webui/open-webui/commit/3bba1c227059a44c7eeefa97b8c69a63bf4f3454)
|
||||
- 🎛️ **Granular markdown rendering controls.** Users can now independently disable Markdown rendering for user messages and assistant responses from Interface settings, preventing unintended formatting when pasting text that contains Markdown-sensitive characters. [Commit](https://github.com/open-webui/open-webui/commit/4a1064cefd6f48a8b3b02cd31f77838c8802b635)
|
||||
- 🔧 **Terminal proxy response headers.** Administrators can now inject custom response headers into terminal proxy responses via the `TERMINAL_PROXY_HEADERS` environment variable (JSON object), enabling deployment-specific security headers like sandbox policies for proxied content. [Commit](https://github.com/open-webui/open-webui/commit/8d3133fe2835122bffaa4f2ce584730bc9c78981)
|
||||
- 🔌 **Channel streaming and tool support.** Mentioning a model in a Channel now streams responses in real time and supports the full chat completion pipeline, including native and default function calling, built-in tools (web search, image generation), user tools, MCP tools, filters, and RAG knowledge injection — the same capabilities available in standard chats.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📝 **Notes create and open reliability.** Creating new notes and opening existing notes no longer fails with a TypeError caused by `is_pinned` being passed to the SQLAlchemy model on create, and passed twice to `NoteResponse` on read. [#24484](https://github.com/open-webui/open-webui/issues/24484), [#24486](https://github.com/open-webui/open-webui/pull/24486)
|
||||
- 🔐 **Skill public sharing permission enforcement.** Creating or updating skills now filters access grants through the `sharing.public_skills` permission, preventing non-admin users from making skills publicly accessible without the required permission. [#24494](https://github.com/open-webui/open-webui/pull/24494)
|
||||
- 🔐 **Calendar public sharing permission enforcement.** Creating or updating calendars now filters access grants through a new `sharing.public_calendars` permission, preventing users from making calendars publicly readable or writable without explicit admin-granted sharing permission. [#24493](https://github.com/open-webui/open-webui/pull/24493)
|
||||
- 🔐 **Feedback user attribution spoofing.** Submitting evaluation feedback can no longer forge the `user_id` field through mass-assignment, preventing authenticated users from attributing ratings to other users and corrupting Elo leaderboard rankings and admin feedback exports. [#24508](https://github.com/open-webui/open-webui/pull/24508)
|
||||
- 🛡️ **Image URL redirect-based SSRF.** Chat messages containing image URLs no longer follow 3xx redirects to internal addresses during base64 conversion, closing the most reachable redirect-based SSRF variant that required no special permissions or feature flags. [#24524](https://github.com/open-webui/open-webui/pull/24524)
|
||||
- 🛡️ **Collection write access on file processing.** The `process_file` and `process_files_batch` retrieval endpoints now enforce collection write-access checks before embedding content, preventing authenticated users from injecting file content into another user's knowledge-base collection. [#24524](https://github.com/open-webui/open-webui/pull/24524)
|
||||
- 🔐 **Tool source code update authorization.** Updating a tool's Python source code now requires `workspace.tools` or `workspace.tools_import` permission, preventing users with only a write-access grant from overwriting executable tool code while still allowing metadata edits. [#24513](https://github.com/open-webui/open-webui/pull/24513)
|
||||
- 🔐 **Channel message ownership enforcement.** Updating or deleting messages in group and DM channels now requires message ownership, preventing channel members from tampering with or silently removing other members' messages. [#24506](https://github.com/open-webui/open-webui/pull/24506)
|
||||
- 🔐 **Channel pin write permission.** Pinning and unpinning messages on standard channels now requires write permission instead of read permission, preventing read-only users from modifying pinned content. [#24521](https://github.com/open-webui/open-webui/pull/24521)
|
||||
- 🛡️ **Image generation URL validation.** Generated image URLs are now validated through `validate_url()` before fetching, aligning the defense-in-depth posture with sibling image-loading paths. [#24518](https://github.com/open-webui/open-webui/pull/24518)
|
||||
- 🔐 **Model params exposure for read-only users.** The per-model API endpoint now strips the `params` dict (including system prompts) from responses to callers without write access, preventing read-only users from viewing admin-curated model configuration. [#24525](https://github.com/open-webui/open-webui/pull/24525)
|
||||
- 🛡️ **URL parser SSRF bypass.** URL validation now rejects backslash, tab, CR, and LF characters that cause urllib and requests/aiohttp to disagree on the target host, closing a parser-confusion SSRF bypass. [#24534](https://github.com/open-webui/open-webui/pull/24534)
|
||||
- 🛡️ **Profile image MIME-type allowlist.** Serving profile images from data URIs now enforces a strict MIME-type allowlist (PNG, JPEG, GIF, WEBP by default, configurable via `PROFILE_IMAGE_ALLOWED_MIME_TYPES`) and sets `X-Content-Type-Options: nosniff`, preventing stored-XSS through SVG or other executable content types. [Commit](https://github.com/open-webui/open-webui/commit/15e696691cad98692c329de62ed8a5bdb3a26d4e)
|
||||
- 🔐 **File ownership in folder and knowledge attachments.** Attaching files to folders or knowledge bases now verifies per-file read access, and folder file lists in chat middleware are filtered to entries the caller can read, preventing unauthorized file content from being injected into RAG context. [Commit](https://github.com/open-webui/open-webui/commit/2dbf7b6764a7922458d3b0139687ad6dcd7596d9)
|
||||
- 🔐 **Shared chat access for owners and admins.** Chat owners can now view and clone their own shared chats without requiring an explicit access grant, and administrators can manage shared chat access controls on any chat. [Commit](https://github.com/open-webui/open-webui/commit/3a21b334cce30226750c5c537345dc51bb8bef17), [Commit](https://github.com/open-webui/open-webui/commit/315566064aedeff071854b023d09e5f1ea0eb950)
|
||||
- 🧵 **Legacy chat history self-healing.** Loading legacy conversations now automatically detects broken parent-link graphs in migrated message records, merges missing messages from the embedded JSON history, and backfills them to the normalized table so future loads use the fast path without data loss. [Commit](https://github.com/open-webui/open-webui/commit/1388f4568b8f508c26542673dd01f1fa049e798a)
|
||||
- 🎛️ **Filter selector reactivity.** Model filter checkboxes now derive state reactively from the current filter list and selected IDs instead of capturing a one-time snapshot at mount, so checkboxes update correctly when model contexts or filter configurations change at runtime. [Commit](https://github.com/open-webui/open-webui/commit/d1ef5382377f590f97a6dbaee88f369e6d7c5f6f)
|
||||
- 🌐 **Portuguese (Brazil) translation updates.** Translations for newly added UI items were added along with a consistency pass across existing entries. [#24503](https://github.com/open-webui/open-webui/pull/24503)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧹 **Removed unauthenticated retrieval status endpoint.** The unauthenticated `GET /api/v1/retrieval/` status endpoint has been removed as dead code — retrieval configuration is already available through authenticated admin endpoints. [#24497](https://github.com/open-webui/open-webui/pull/24497)
|
||||
- 📋 **PR template issue requirement.** Pull requests now require a linked Issue or Discussion reference, ensuring better traceability for all contributions. PRs without a linked issue or discussion may be closed without review.
|
||||
|
||||
## [0.9.4] - 2026-05-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📜 **Chat scroll position on load.** Opening a chat conversation now reliably scrolls to the bottom of the message history, fixing a regression caused by `content-visibility: auto` where estimated element sizes prevented the initial scroll from reaching the true bottom.
|
||||
|
||||
## [0.9.3] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- 🔇 **Voice Mode mute control.** Voice Mode now includes a dedicated mute toggle with an "M" shortcut and auto-unmute after assistant playback, so you can prevent accidental interruptions from background noise without leaving the call overlay. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c), [#23832](https://github.com/open-webui/open-webui/issues/23832)
|
||||
- 🚀 **Faster prompt list loading.** Prompt and prompt-tag pages now load much faster for non-admin users, even with large prompt libraries, because accessible prompts are filtered efficiently in a single database query. [#24288](https://github.com/open-webui/open-webui/pull/24288), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
|
||||
- ⚡ **Faster chat history loading.** Chat history maps now load from normalized message records when available, reducing overhead for large conversations while preserving fallback behavior for legacy chats. [Commit](https://github.com/open-webui/open-webui/commit/485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f), [#23159](https://github.com/open-webui/open-webui/pull/23159)
|
||||
- 🗑️ **Delete from conversation menu.** You can now delete the current conversation directly from the chat menu with a confirmation step, so cleanup is faster without searching through the full chat list. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4), [#24329](https://github.com/open-webui/open-webui/issues/24329)
|
||||
- ⬆️ **Scroll to Top shortcut.** Long conversations now include a Scroll to Top action in the chat menu when you are away from the top, making it much faster to jump back to the beginning of a chat. [Commit](https://github.com/open-webui/open-webui/commit/cdfcbc4af6e9aec835b88dc1806a2a46711e6947), [#24133](https://github.com/open-webui/open-webui/issues/24133)
|
||||
- 📅 **Calendar creation flow.** Users can now create calendars from a dedicated modal and a quick-add action in the calendar sidebar, making calendar setup faster from the calendar workspace. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840)
|
||||
- 🧭 **Unified model unload controls.** Administrators can now unload running models from the model selector across supported providers, with loaded-state indicators shown for Ollama and llama.cpp models. [Commit](https://github.com/open-webui/open-webui/commit/4fe2de78643c2213652190d2820f4e8d9f4f89cc)
|
||||
- ⚡ **Health check responsiveness.** Health and readiness probes now avoid blocking database calls and skip sync session commit handling on probe paths, improving responsiveness and reducing false unready transitions during database pressure. [#24380](https://github.com/open-webui/open-webui/pull/24380), [#24384](https://github.com/open-webui/open-webui/pull/24384)
|
||||
- 🎛️ **Playground controls panel.** The Playground now includes a dedicated Controls toggle so you can adjust parameters like temperature and related settings per chat run without changing model-level defaults. [Commit](https://github.com/open-webui/open-webui/commit/c6763521c00f042a28829e33fb6f1b7355054046), [#24103](https://github.com/open-webui/open-webui/issues/24103)
|
||||
- 🎙️ **STT file extension controls.** Administrators can now configure which audio file extensions are accepted for speech-to-text uploads, helping enforce safer and more predictable upload policies. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
|
||||
- 📷 **Remembered call camera selection.** Voice call overlay now remembers your last selected camera and restores it automatically when available, so you do not need to reselect it every time you start voice mode. [Commit](https://github.com/open-webui/open-webui/commit/5c3edc2539ac4d92c4cc2d37079549995203238a), [#24416](https://github.com/open-webui/open-webui/issues/24416)
|
||||
- 👥 **User group prompt variable.** System and template prompts now support the "{{USER_GROUPS}}" variable, which expands to the user’s group memberships so prompts can adapt to role- or access-based context automatically. [Commit](https://github.com/open-webui/open-webui/commit/c1202a23277abb8e7080271a929dcc9d29b67e66), [#24462](https://github.com/open-webui/open-webui/issues/24462)
|
||||
- 🔐 **Public chat sharing permission control.** Administrators can now control whether users are allowed to create publicly shareable chats through a dedicated permission setting. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4)
|
||||
- 🔐 **Profile image forwarding control.** Administrators can now disable external profile image URL forwarding with the "ENABLE_PROFILE_IMAGE_URL_FORWARDING" setting to prevent browser metadata leaks to third-party servers. [#24420](https://github.com/open-webui/open-webui/pull/24420)
|
||||
- 🏷️ **Dynamic header template variables.** Administrators can now use chat, message, and user template variables in custom connection and tool server headers so each request can carry per-conversation context automatically. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
|
||||
- 🛂 **MCP OAuth server URL setting.** Static OAuth tool server setups can now define a separate OAuth server URL, making discovery and client registration work when authentication endpoints are hosted separately from the tool server URL. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164), [#24216](https://github.com/open-webui/open-webui/issues/24216)
|
||||
- ⚡ **Faster memory query performance.** Per-user memory lookups and deletions now run much faster at scale because the memory user filter is indexed for existing and new installations. [Commit](https://github.com/open-webui/open-webui/commit/38a382ef888685650135d61dcc8ec0e29eb65573), [#23836](https://github.com/open-webui/open-webui/pull/23836)
|
||||
- 🚀 **Smarter function dependency installs.** Function dependencies are now skipped when they were already preinstalled and unchanged, reducing first-load delays and repeated package installation churn after startup. [Commit](https://github.com/open-webui/open-webui/commit/ae43562b869b24699408e5ab107261a0a8bdb4bc), [#24166](https://github.com/open-webui/open-webui/pull/24166)
|
||||
- 🔎 **Brave LLM Context web search.** Administrators can now choose Brave LLM Context as a web search provider to retrieve richer grounded passages with a configurable context token budget. [Commit](https://github.com/open-webui/open-webui/commit/6700f7bb72d14a3f8dbb72dfa064cae3b3dc29ac), [#24120](https://github.com/open-webui/open-webui/issues/24120)
|
||||
- 🗂️ **Open Terminal date sorting.** Open Terminal now includes sort controls for name and date, with directory-first ordering and modified-time visibility to make file browsing faster. [Commit](https://github.com/open-webui/open-webui/commit/6bdc2ffa79d72daf78981209c9c5292c697cbfe5), [#24425](https://github.com/open-webui/open-webui/issues/24425)
|
||||
- 🎤 **Voice mode prompt toggle.** Administrators can now explicitly enable or disable the Voice Mode custom prompt behavior from Interface settings, giving finer control over how voice replies are guided. [Commit](https://github.com/open-webui/open-webui/commit/17893038869e3a763a8b34457f723b9666804e27)
|
||||
- 🧮 **LaTeX copy shortcut.** You can now click rendered LaTeX expressions to copy the raw formula to your clipboard, making it easier to reuse equations outside chat. [Commit](https://github.com/open-webui/open-webui/commit/064fdecb675c176a04b024c16ce179f4dda45236), [#24244](https://github.com/open-webui/open-webui/pull/24244)
|
||||
- ✨ **Smoother rich text editing.** The message composer now defers formatting toolbar refresh work to the next animation frame, reducing typing jank while formatting controls stay accurate. [Commit](https://github.com/open-webui/open-webui/commit/794b97025d4c56f91d49c9d1ec4775d2ea07b53a), [#24013](https://github.com/open-webui/open-webui/pull/24013)
|
||||
- 🖼️ **Arena model profile images.** Arena models can now reliably display configured profile images instead of falling back to the default icon. [Commit](https://github.com/open-webui/open-webui/commit/1dee67b64d0b34e70bac949682b216c0aaec8152), [#24412](https://github.com/open-webui/open-webui/issues/24412)
|
||||
- 🔄 **Replaceable tool embed updates.** Pipes and Tools can now overwrite previously emitted rich-UI embeds in-place by passing a `replace` flag on the `embeds` event, enabling live dashboards and progress panels that update without stacking duplicate entries.
|
||||
- ✏️ **Assistant response editing and continuation.** You can now edit and restructure assistant output items — including reasoning blocks, tool calls, and text content — from a dedicated editor view, and continue generating from the edited state so the model receives full prior context.
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Chinese, Catalan, Filipino, and Korean were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🧵 **Background code execution reliability.** Code execution no longer hangs indefinitely when you switch conversations or browser tabs during a run, and disconnected or inactive sessions now fail with a clear timeout error instead of endless processing. [Commit](https://github.com/open-webui/open-webui/commit/552bbcecfae5ae273ab98e2ce3e540d0771aa964), [#24089](https://github.com/open-webui/open-webui/issues/24089)
|
||||
- 🎙️ **Voice recording MIME fallback support.** Voice recording now tries a broader set of browser-supported audio formats and resets halted audio playback cleanly, improving microphone capture reliability in browsers where recording previously failed to start. [Commit](https://github.com/open-webui/open-webui/commit/8ffc3d746f20007e9eb4e3ae4f152f383bc371e1), [#24162](https://github.com/open-webui/open-webui/issues/24162)
|
||||
- 🧠 **Direct-connection task generation reliability.** Title, tags, follow-up, emoji, query, and related task-generation endpoints now work correctly when chats use direct-connection models instead of failing with model-not-found errors. [Commit](https://github.com/open-webui/open-webui/commit/1b4cd705d0b9a51a5e3a7851ec012fb3141eb0a9), [Commit](https://github.com/open-webui/open-webui/commit/005df577fec16733a64edbec8a1b46f42f4e9a43), [#24092](https://github.com/open-webui/open-webui/issues/24092)
|
||||
- 🔧 **Parameterized URL tool readiness.** New chats now wait for model defaults to finish applying before auto-submit, preventing early requests that can miss configured external tools. [Commit](https://github.com/open-webui/open-webui/commit/212bb68a66435dc1803a6d67cb4ea584d3455fb7), [#24176](https://github.com/open-webui/open-webui/issues/24176)
|
||||
- 🚦 **MCP cleanup response reliability.** Successful native MCP tool calls no longer get replaced by a 500 "No response returned" error during cleanup, so valid chat responses are now returned consistently. [#24105](https://github.com/open-webui/open-webui/pull/24105)
|
||||
- 🧵 **Active task state recovery.** Chat input no longer stays blocked by unrelated background tasks after a response is already complete, and interrupted assistant replies are now marked done more reliably. [Commit](https://github.com/open-webui/open-webui/commit/04bd0425ead28185bcd124e77892e31209a6e15b), [#23264](https://github.com/open-webui/open-webui/pull/23264)
|
||||
- 📌 **Per-user note pinning behavior.** Pinned notes are now tracked per user instead of with a shared note-level flag, so one person’s pin changes no longer affect everyone else. [Commit](https://github.com/open-webui/open-webui/commit/33e588cf09b294f0abe08b9566efa8545a7dbf92)
|
||||
- 🧱 **Custom header value coercion.** Custom header values are now converted to text before requests are sent, preventing request failures when non-text values are configured. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
|
||||
- 🔗 **HTTP share link copy fallback.** Copy Link now works reliably on HTTP deployments by using a selection-based fallback when secure clipboard APIs are unavailable. [Commit](https://github.com/open-webui/open-webui/commit/f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4), [#24135](https://github.com/open-webui/open-webui/issues/24135)
|
||||
- 🧵 **Regeneration loading lock recovery.** Chats no longer get stuck in a permanent loading state after failed regenerations because invalid message-tree references are repaired before rendering. [Commit](https://github.com/open-webui/open-webui/commit/ee3b82926b37843f2771c6a8d432781a557ea96a), [#24424](https://github.com/open-webui/open-webui/issues/24424)
|
||||
- 📸 **Complete chat image capture.** Downloaded chat snapshots now include all messages more reliably through visibility overrides and layout timing improvements during capture. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840), [#24088](https://github.com/open-webui/open-webui/issues/24088)
|
||||
- 🗓️ **Calendar deletion lock handling.** Calendar deletion now avoids SQLite write-lock contention by revoking calendar access grants in a separate transaction after calendar and event removal. [Commit](https://github.com/open-webui/open-webui/commit/1d892ce2c513c4d933c902de5e5d76c317a06dd2)
|
||||
- 🧩 **Filter and internal tool coexistence.** Internal tools now remain available when filters add provider-native tools, so filter-added tools no longer replace the built-in tool set during request processing. [Commit](https://github.com/open-webui/open-webui/commit/02f9fe78907c2ecf6f1d93646cbfa2173409bbe8), [#24237](https://github.com/open-webui/open-webui/issues/24237)
|
||||
- 🛠️ **OpenAPI tool spec compatibility.** OpenAPI tool integrations now handle null or non-operation path entries more safely and parse path-level parameters consistently, preventing crashes and improving tool execution reliability across imperfect OpenAPI specs. [Commit](https://github.com/open-webui/open-webui/commit/2ba6b423aa0c9c800bd96cb638c6ade867cac0f6), [Commit](https://github.com/open-webui/open-webui/commit/5b80932e5951786bb348b91589e8d87753f18905), [#24376](https://github.com/open-webui/open-webui/pull/24376)
|
||||
- 🧰 **OpenAPI tool schema parsing.** OpenAPI tool imports now ignore non-method path item fields and correctly resolve nested composition schemas, preventing invalid tool parsing for compatible specs. [Commit](https://github.com/open-webui/open-webui/commit/85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc), [#23254](https://github.com/open-webui/open-webui/pull/23254)
|
||||
- 🌍 **Web search proxy compatibility.** DuckDuckGo search now respects configured proxy environments more reliably, and trust-env behavior defaults to enabled so proxied web loading does not fail unexpectedly. [Commit](https://github.com/open-webui/open-webui/commit/bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297), [#23810](https://github.com/open-webui/open-webui/pull/23810)
|
||||
- 🧾 **Final markdown render flush.** Streaming markdown now forces an immediate final parse when generation completes, preventing stale or partially rendered final output. [Commit](https://github.com/open-webui/open-webui/commit/29f6c72e879d67f23021938e24a21914cc9fb120), [#24088](https://github.com/open-webui/open-webui/issues/24088)
|
||||
- 🛡️ **Webhook avatar URL validation.** Channel webhook profile image URLs are now validated before saving, preventing invalid or unsafe avatar URLs from being accepted. [#24370](https://github.com/open-webui/open-webui/pull/24370)
|
||||
- 📝 **System prompt editor scroll stability.** Editing large system prompts no longer jumps the page back to the top, so you can continue editing long model prompts without losing your place. [Commit](https://github.com/open-webui/open-webui/commit/c978a788c8315e37357c93c1b605a2831fc77485), [#23999](https://github.com/open-webui/open-webui/issues/23999)
|
||||
- 🔎 **Knowledge content search matching.** Knowledge file search now matches both file titles and file content, so relevant files are easier to find even when the keyword is not in the filename. [Commit](https://github.com/open-webui/open-webui/commit/11e076817ae5db34621ce03136353248f7377d97), [#24297](https://github.com/open-webui/open-webui/pull/24297)
|
||||
- ⚡ **Faster prompt tag loading.** Prompt tag filters now load much faster for non-admin users by fetching only accessible tags directly, avoiding per-prompt permission checks and unnecessary prompt data loading. [#24287](https://github.com/open-webui/open-webui/pull/24287), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
|
||||
- 🧾 **Citation overflow badge readability.** Citation overflow badges now keep multi-digit counts readable in a single compact bubble, preventing wrapped or cramped display when many sources are attached. [Commit](https://github.com/open-webui/open-webui/commit/23ff9943a9fc8c314100fa074157853fbece1a55), [#24391](https://github.com/open-webui/open-webui/pull/24391)
|
||||
- 🌐 **Yandex result parsing guard.** Yandex web search no longer fails when some XML fields are missing in individual results, so valid search responses continue to return usable sources instead of dropping to no results. [Commit](https://github.com/open-webui/open-webui/commit/9386fc83a3eff3e55cc157ac8c15c337e3d822c1), [#24243](https://github.com/open-webui/open-webui/issues/24243)
|
||||
- 🎧 **Safer voice transcription uploads.** Empty or failed voice conversions are now rejected with a clear error instead of continuing as malformed audio, reducing failed transcription attempts from corrupted or near-empty recordings. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c)
|
||||
- 🎚️ **Safer chunked STT processing.** Chunked transcription now limits worker concurrency when no external STT engine is configured, reducing failed transcription behavior caused by overly parallel local processing. [Commit](https://github.com/open-webui/open-webui/commit/55e7c7854bba5182803239c903a0ac2d14426a4c)
|
||||
- 📈 **Imported chat analytics coverage.** Imported ChatGPT conversations now carry proper model and timestamp metadata and reliably write imported messages into analytics-backed storage, so imported chats are reflected correctly in Admin Analytics totals and model usage views. [Commit](https://github.com/open-webui/open-webui/commit/4d766a3edfa116abcefe7168f1d1284683b860b2), [#24263](https://github.com/open-webui/open-webui/issues/24263)
|
||||
- 📎 **Knowledge collection persistence.** Knowledge collections selected with the chat input selector now remain attached after reloads and chat switches, so attached context no longer disappears between sessions. [Commit](https://github.com/open-webui/open-webui/commit/7c398a625a8d51f79d80217f6d329fc30c72b782), [#24142](https://github.com/open-webui/open-webui/issues/24142)
|
||||
- 🧹 **Embedding model name trimming.** Embedding model names entered in Documents settings now automatically trim surrounding whitespace, preventing silent embedding failures caused by accidental trailing spaces. [Commit](https://github.com/open-webui/open-webui/commit/6082e1adaebc8aa3e7f55265c8dc2dbe130c0446), [#24090](https://github.com/open-webui/open-webui/issues/24090)
|
||||
- 🔊 **PCM TTS playback compatibility.** Text-to-speech audio returned as PCM is now converted to MP3 before delivery, so speech playback works correctly with providers that return raw PCM audio. [Commit](https://github.com/open-webui/open-webui/commit/ff791b4814fc1453df2235ea78016d7015aa6806), [#24143](https://github.com/open-webui/open-webui/issues/24143)
|
||||
- 🪟 **Windows PostgreSQL startup compatibility.** Windows pip installs using PostgreSQL now start reliably with psycopg async by using a compatible event loop policy instead of the default Proactor loop. [Commit](https://github.com/open-webui/open-webui/commit/7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9), [#24152](https://github.com/open-webui/open-webui/issues/24152)
|
||||
- ⏱️ **MCP OAuth timeout control.** OAuth token exchanges for MCP tool server connections now respect the configurable client timeout setting, reducing callback failures with slower providers. [Commit](https://github.com/open-webui/open-webui/commit/cde72dab71671645e119564ca9747ce25dd590ad), [#24138](https://github.com/open-webui/open-webui/issues/24138)
|
||||
- 📄 **PDF text search restoration.** PDF previews now include a proper text layer so browser text selection and find-in-page search work again instead of rendering only image-like pages. [Commit](https://github.com/open-webui/open-webui/commit/bc4d6eef33dcb92719b07483cdb1d63ebf250721), [#24149](https://github.com/open-webui/open-webui/issues/24149)
|
||||
- 🔑 **Android password autofill support.** Password inputs now expose the expected field name metadata, improving password manager autofill reliability on Android login pages. [Commit](https://github.com/open-webui/open-webui/commit/60ea4214aa42f1ad22142f1a43535007a2293d16), [#24137](https://github.com/open-webui/open-webui/issues/24137)
|
||||
- 🎤 **Non-blocking STT processing.** Speech-to-text transcription no longer blocks the server event loop during both live transcription and uploaded audio file processing, so other users can continue using chats and live connections under concurrent load. [#24338](https://github.com/open-webui/open-webui/pull/24338), [#24379](https://github.com/open-webui/open-webui/pull/24379), [#24169](https://github.com/open-webui/open-webui/issues/24169)
|
||||
- 🌐 **SearXNG language parameter handling.** Web searches now send clean multi-language values without trailing separators, so SearXNG requests no longer fail when multiple languages are selected. [Commit](https://github.com/open-webui/open-webui/commit/6dff85b9d205cfc4bc2845dac40909b8d859910c), [#24198](https://github.com/open-webui/open-webui/issues/24198)
|
||||
- 📂 **File modal open-link behavior.** Clicking a file name in the file details modal now opens the correct file content in a new tab for uploaded file items instead of failing to open. [#24125](https://github.com/open-webui/open-webui/pull/24125)
|
||||
- 📎 **Chat attachment display recovery.** Files attached by chat tools now appear reliably in assistant responses, including non-image file attachments that were previously hidden. [Commit](https://github.com/open-webui/open-webui/commit/7eeff2fdf945024585a01b72071a61971afc844d), [#24332](https://github.com/open-webui/open-webui/pull/24332)
|
||||
- 🧱 **Channel embed rendering guard.** Channel message embeds now appear only for model-generated messages and are suppressed in reply previews, preventing unintended embed expansion in regular user posts. [Commit](https://github.com/open-webui/open-webui/commit/e1dce9914745de9b4d2c67b1deddde3472ce4dfa)
|
||||
- 🛡️ **Safer image URL handling.** Untrusted external image URLs are now blocked in profile and rich-text image rendering paths, preventing unintended client-side requests to attacker-controlled domains. [#24420](https://github.com/open-webui/open-webui/pull/24420)
|
||||
- 🛡️ **Sanitized spreadsheet HTML previews.** Spreadsheet previews now sanitize generated HTML before rendering, reducing the risk of unsafe content being executed when opening office files in chat and file modals. [#24468](https://github.com/open-webui/open-webui/pull/24468)
|
||||
- 🧰 **Multi-worker tool update consistency.** Updated tool code now refreshes correctly across workers without requiring a full service restart, so chats no longer run stale tool versions after edits. [Commit](https://github.com/open-webui/open-webui/commit/3309f5d9f11f521c0ee97b64c59a83e3cf390bde), [#24400](https://github.com/open-webui/open-webui/issues/24400), [#24433](https://github.com/open-webui/open-webui/pull/24433)
|
||||
- 🧩 **Default model metadata env parsing.** The "DEFAULT_MODEL_METADATA" environment setting is now parsed and applied correctly, including when persistent config is disabled, so configured model capability defaults are no longer ignored at startup. [Commit](https://github.com/open-webui/open-webui/commit/0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97), [#24319](https://github.com/open-webui/open-webui/issues/24319)
|
||||
- 🔄 **Config import and Redis consistency.** Imported settings now remain effective after import because configuration values are immediately synchronized to Redis, preventing stale cached values from overriding imported permissions and settings. [Commit](https://github.com/open-webui/open-webui/commit/55a572cd398c9b4e6118728f8f129941437aa225), [Commit](https://github.com/open-webui/open-webui/commit/1c1c8b18e5cc90ca3c6961a4c193a4363febbc83), [#24346](https://github.com/open-webui/open-webui/issues/24346)
|
||||
- 🔔 **LDAP signup webhook parity.** New accounts created through LDAP now trigger the same signup webhook notifications as password and OAuth signups, so downstream provisioning and audit automations receive consistent events. [Commit](https://github.com/open-webui/open-webui/commit/fd3368c0bff168417e3c49ffd73491c344702339), [#24377](https://github.com/open-webui/open-webui/issues/24377)
|
||||
- 🦆 **DDGS auto-backend compatibility.** Web search now handles DDGS automatic backend selection correctly and safely falls back on empty or rate-limited responses, preventing search failures in newer DDGS versions. [Commit](https://github.com/open-webui/open-webui/commit/9adc0c442a57eaa88a5f30c2b2cb393623154e20), [#24188](https://github.com/open-webui/open-webui/issues/24188)
|
||||
- 🤖 **Automation update tool reliability.** Updating existing automations in chat now works correctly instead of failing with a missing method error. [Commit](https://github.com/open-webui/open-webui/commit/f39f4a86aedc2769d8268670a020b1f3c16776dd), [#24405](https://github.com/open-webui/open-webui/issues/24405#issuecomment-4408011166)
|
||||
- 📅 **Calendar event permission checks.** Calendar event update and delete actions now handle ownership and access checks more reliably, returning clean access-denied results when appropriate. [Commit](https://github.com/open-webui/open-webui/commit/2977910ffd9d2369dfa504aa6ab12745b3dbd19a)
|
||||
- 🛡️ **Safer cached file delivery.** Cached files that are not recognized as image, audio, or video now download as attachments instead of rendering inline, reducing the risk of unsafe browser content handling. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
|
||||
- 📊 **Streaming token analytics accuracy.** Admin Analytics now records and aggregates token usage correctly for streaming chats across Responses API and OpenAI-compatible providers, including fallback handling for provider usage formats that use prompt and completion token keys. [Commit](https://github.com/open-webui/open-webui/commit/989d5fd4e2ce285edf4475a1e13f0981a78d3821), [Commit](https://github.com/open-webui/open-webui/commit/a32d26e61d24d9f63650faed5cb8909ed90af661), [#24217](https://github.com/open-webui/open-webui/issues/24217), [#24294](https://github.com/open-webui/open-webui/issues/24294), [#24241](https://github.com/open-webui/open-webui/issues/24241)
|
||||
- 🔗 **Admin shared chat links.** Admin users can now open and clone shared chat links reliably without 401 errors because shared links are now resolved by share ID first, with safe fallback behavior for direct chat ID access. [Commit](https://github.com/open-webui/open-webui/commit/cde21b9f6dc11575a668484f42440824ec5a4fae), [#24311](https://github.com/open-webui/open-webui/issues/24311), [#24096](https://github.com/open-webui/open-webui/issues/24096)
|
||||
- 💾 **Chat settings persistence.** System prompts and other chat-level settings now persist correctly after creating a new chat and reloading, preventing prompt loss in affected conversations. [Commit](https://github.com/open-webui/open-webui/commit/86df8bf27e1b84abbe2eeedcc8650df59c7d23d6), [#24193](https://github.com/open-webui/open-webui/issues/24193), [#24270](https://github.com/open-webui/open-webui/issues/24270)
|
||||
- 💾 **Chat control autosave persistence.** Changes to chat controls like system prompt, parameters, and attached files are now autosaved on existing chats, so edits are no longer lost when you refresh or navigate away before sending a message. [Commit](https://github.com/open-webui/open-webui/commit/a938c8ae2e45a00d2f06151fdaeaee94e54a8095), [#23897](https://github.com/open-webui/open-webui/pull/23897)
|
||||
- ☁️ **OneDrive option visibility.** OneDrive personal and business upload options now appear only when their respective client IDs are configured, preventing unavailable options from showing in attachment menus. [Commit](https://github.com/open-webui/open-webui/commit/b72019db393a658ca0ceecdcc59b70f6cc5dcd40), [#24411](https://github.com/open-webui/open-webui/issues/24411)
|
||||
- 🧠 **Reasoning content leakage prevention.** Tool-call round-trip messages no longer wrap reasoning text in `<think>` tags inside the content field, preventing raw markup from leaking into chat output for models whose templates don't strip think tags (e.g. Gemma 4). [#23844](https://github.com/open-webui/open-webui/issues/23844)
|
||||
- 🖥️ **Terminal sidebar auto-open guard.** The terminal sidebar no longer auto-opens on chat load when OpenTerminal is disabled, because stale terminal IDs saved on models or in localStorage are now validated against available terminal servers before use.
|
||||
- 🔁 **Single-confirmation connection deletion.** Deleting OpenAI, Ollama, tool server, and terminal server connections now shows exactly one confirmation dialog instead of two, because redundant outer confirmation wrappers were removed from all connection components.
|
||||
- 🧵 **Reliable background task cleanup.** The chat task lifecycle now deregisters completed tasks before checking for remaining siblings, eliminating the off-by-one timing issue that could leave the stop button stuck or dismiss the sidebar activity spinner too early.
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
|
||||
- 🚪 **Signout request method.** The signout endpoint now requires POST instead of GET, so custom clients and integrations must update logout calls accordingly. [#24420](https://github.com/open-webui/open-webui/pull/24420)
|
||||
|
||||
## [0.9.2] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- 🧠 **PaddleOCR-vl document extraction.** Administrators can now use PaddleOCR-vl as a content extraction engine for document processing, with configurable API URL and token settings in document retrieval configuration. [#23945](https://github.com/open-webui/open-webui/pull/23945)
|
||||
- 🔥 **Firecrawl v2 API.** Firecrawl web loading now uses the v2 API directly with proper retry logic, exponential backoff on rate limits, and configurable timeout handling, improving reliability for both cloud and self-hosted Firecrawl setups. [#23934](https://github.com/open-webui/open-webui/pull/23934)
|
||||
- ⏰ **Calendar event reminder customization.** Calendar events now support a configurable `reminder_minutes` parameter, allowing models to set custom reminder durations instead of the default 10-minute notification.
|
||||
- 🔑 **Custom API key header.** Administrators can now configure a custom header name for API key authentication via the `CUSTOM_API_KEY_HEADER` environment variable, enabling compatibility with reverse proxies that use the `Authorization` header for their own authentication.
|
||||
- 🔌 **OAuth session disconnection.** Users can now disconnect OAuth sessions for specific providers (e.g., MCP connections) through a new API endpoint, enabling cleaner re-authentication workflows.
|
||||
- 📚 **Source overflow indicator.** The Sources button now shows a +N badge when more than three sources are available, so hidden sources are clearly indicated in chat responses. [#23918](https://github.com/open-webui/open-webui/pull/23918)
|
||||
- ⚡ **Model list performance.** Model list API responses now strip base64 profile image data from paginated results, and model tags are fetched via a dedicated efficient query instead of loading all models. This significantly reduces payload sizes and improves workspace Models page responsiveness.
|
||||
- ⚡ **Model avatar cache reuse.** Default model profile images now redirect to a shared static path instead of reading files from disk per-request, reducing repeated I/O and improving loading efficiency when multiple models use the fallback icon. [#24015](https://github.com/open-webui/open-webui/pull/24015)
|
||||
- 🚀 **Faster splash image loading.** Splash screen images are now prioritized earlier during page load with preload links, improving first-load LCP behavior and reducing delayed image discovery. [#24011](https://github.com/open-webui/open-webui/pull/24011)
|
||||
- 🧵 **Streaming markdown performance stability.** Streaming responses now stay more memory-efficient by preventing repeated cleanup callback registration during markdown updates. [#24048](https://github.com/open-webui/open-webui/pull/24048)
|
||||
- 📊 **Telemetry gauge reliability.** OpenTelemetry user gauge callbacks now use synchronous database queries directly, eliminating cross-thread async bridging issues that could cause silent failures in metric collection.
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Finnish, Korean, Portuguese (Brazil), and Dutch were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔧 **MCP task cancellation stability.** Interrupted MCP tool calls no longer cause CPU spikes or runaway cleanup behavior. MCP client disconnection now runs in the same asyncio task as connection, respecting cancel scope constraints, and chat-active events are properly shielded during cancellation.
|
||||
- 🧠 **Persistent chat skill injection.** Skills mentioned in persisted chats now inject into the system prompt reliably. Skill ID extraction from `<$skillId|label>` message tags is now handled server-side, and tags are stripped before messages reach the model.
|
||||
- 🗄️ **Async database driver migration.** The async database backend now uses psycopg (v3) instead of asyncpg, eliminating brittle SSL parameter translation and supporting native libpq connection strings including `sslmode`, `options`, and `target_session_attrs` without any stripping or conversion.
|
||||
- 🐳 **Docker ARM64 reliability.** Docker images built for arm64 via QEMU cross-compilation no longer produce 0-byte corrupted Python dependencies. `UV_LINK_MODE=copy` is now set in the Dockerfile to force reliable file installation.
|
||||
- 🛠️ **Throttle request handling.** Request handling no longer fails when user activity status updates are throttled with a non-zero interval. [#23979](https://github.com/open-webui/open-webui/pull/23979)
|
||||
- ✍️ **Rich text extension conflicts.** Rich text editing no longer triggers duplicate extension conflicts for lists and code blocks, improving editor stability. [#24009](https://github.com/open-webui/open-webui/pull/24009)
|
||||
- 🔇 **Fetch URL null content guard.** The `fetch_url` built-in tool now safely handles `None` content returned by web loaders instead of crashing with a `TypeError`.
|
||||
- 🌐 **OAuth discovery fallback.** OAuth protected resource discovery now falls back to well-known RFC 9728 URIs when the `WWW-Authenticate` header doesn't contain a `resource_metadata` link, improving compatibility with more MCP server implementations.
|
||||
- 🔐 **Session token resolution.** Session user endpoints now gracefully handle missing `Authorization` headers by falling back to cookie and request state tokens, preventing errors when used behind forward-auth proxies.
|
||||
- 🚫 **Direct API error responses.** Chat completion requests without a WebSocket channel (direct API calls) now return proper HTTP error responses instead of silently returning null on failure.
|
||||
- 📡 **Cancelled response stream cleanup.** Cancelled chat generation now explicitly closes the upstream response body iterator, preventing orphaned async generators from spinning in anyio internals.
|
||||
- 🔒 **Model profile image path safety.** Model profile image endpoints now validate and sanitize static asset redirect paths, preventing path traversal through encoded dots or malicious URL patterns.
|
||||
- 📊 **RAG template validation UI.** The Documents settings page now displays a warning when RAG templates contain multiple `[context]` or `{{CONTEXT}}` placeholders, helping administrators avoid accidental redundant context injection.
|
||||
- 🧩 **Automation model detection.** The `create_automation` tool now correctly detects the current model ID even when `model_id` is not yet set in metadata, falling back to the model dict.
|
||||
- 🔄 **MCP resource content handling.** MCP tool results with the `resource` content type are now correctly detected and their `resource.text` payload is extracted, instead of being silently ignored.
|
||||
- 🔄 **Ollama and OpenAI metadata forwarding.** Ollama and OpenAI proxy routes now forward request metadata to downstream handlers, ensuring consistent context propagation.
|
||||
- 🧹 **Browser-native message virtualization.** The custom JavaScript-based message culling system (spacers, height caching, scroll listeners) was replaced with CSS `content-visibility: auto`, letting the browser natively skip rendering of off-screen messages without destroying component trees. This eliminates scroll jump artifacts and mount/destroy thrashing while preserving memory efficiency in long conversations.
|
||||
- 📻 **Redis notification compatibility.** Redis pub/sub now handles missing or incompatible `client_name` support more gracefully, preventing connection errors with certain Redis configurations.
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚙️ **psycopg v3 async driver.** The async database driver has been migrated from `asyncpg` to `psycopg` (v3). This is a transparent change for most deployments, but custom connection strings with `asyncpg`-specific parameters may need adjustment.
|
||||
- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176.
|
||||
- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility.
|
||||
|
||||
## [0.9.1] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛 **Missing `aiosqlite` dependency.** Fixed a startup crash (`ModuleNotFoundError: No module named 'aiosqlite'`) when installing Open WebUI via `pip` or `uv` by adding the missing `aiosqlite` package to `pyproject.toml`. The dependency was listed in `requirements.txt` but not in the published package metadata, so it was not installed automatically. [#23916](https://github.com/open-webui/open-webui/issues/23916)
|
||||
- 🐛 **Missing `asyncpg` dependency.** Added the missing `asyncpg` package to `pyproject.toml` to prevent the same startup crash for PostgreSQL users. Like `aiosqlite`, it was present in `requirements.txt` but absent from the published package dependencies.
|
||||
|
||||
## [0.9.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- 🖥️ **Official Open WebUI Desktop App.** Open WebUI is now available as a native desktop app for Mac, Windows, and Linux. No Docker, no terminal, no setup. Runs Open WebUI locally without any server setup, or connects to your existing remote Open WebUI instances. Switch between multiple servers instantly from the sidebar. Comes with a system-wide floating chat bar (Shift+Cmd+I on macOS, Shift+Ctrl+I on Windows/Linux), system-wide push-to-talk, offline support after first launch, automatic updates, and zero telemetry. [#8262](https://github.com/open-webui/open-webui/issues/8262), [Desktop](https://github.com/open-webui/desktop)
|
||||
- 🤖 **Scheduled chat automations.** You can now schedule the AI to run tasks automatically on a recurring basis: daily digests, periodic reports, anything you'd otherwise need to remember to ask for. Create and manage automations from the Automations page or directly in chat, with full run history and manual trigger controls. [#23303](https://github.com/open-webui/open-webui/pull/23303), [Commit](https://github.com/open-webui/open-webui/commit/5a2ff8b2e5b6f55a20f7ed491f818490eb535ea7), [Commit](https://github.com/open-webui/open-webui/commit/d30a0531d4add045c21a2368d6321a9b1906865f), [Commit](https://github.com/open-webui/open-webui/commit/bae5ff938ac88a3a647cc31ca8db1101015ae18b), [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
|
||||
- 🧰 **Automation tools in chat.** Built-in chat tools can now create, update, list, pause, and delete scheduled automations directly in conversation when automation access is enabled. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
|
||||
- ⏱️ **Automation scheduling limits.** Administrators can now set "AUTOMATION_MAX_COUNT" and "AUTOMATION_MIN_INTERVAL" to limit how many automations each non-admin user can create and prevent overly frequent schedules that could overload the system. [Commit](https://github.com/open-webui/open-webui/commit/406251c2f358ffabce4d631c98c6f2c879feae5c)
|
||||
- 📋 **Task management tool.** AI models can now create, update, and track tasks within a chat conversation, breaking down complex requests into manageable steps with real-time status updates. [Commit](https://github.com/open-webui/open-webui/commit/bcb71bb5206ac01d97a39fde8ecf0e0541dde636)
|
||||
- 🗓️ **Calendar workspace and event management.** Open WebUI now has a full Calendar workspace. Create and manage events, set up recurring schedules, get reminders via in-app toasts or browser notifications, and see your scheduled automations alongside your calendar. [#23880](https://github.com/open-webui/open-webui/pull/23880)
|
||||
- 🔔 **Calendar reminders and alerts.** Calendar events now support reminder options from no alert up to one hour before start time, with upcoming alerts delivered through in-app toasts, browser notifications, and optional webhooks while avoiding duplicate sends. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be)
|
||||
- ⚙️ **Scheduler reminder configuration.** Administrators can now configure calendar reminder processing with "SCHEDULER_POLL_INTERVAL" and "CALENDAR_ALERT_LOOKAHEAD_MINUTES", while existing "AUTOMATION_POLL_INTERVAL" setups continue to work as a legacy fallback. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be)
|
||||
- ☁️ **Azure responses support.** Azure OpenAI connections now support the newer "/openai/v1" format, enabling chat, responses, and proxy calls to work correctly with that endpoint style. [#23484](https://github.com/open-webui/open-webui/pull/23484)
|
||||
- 🤖 **Ollama responses support.** The Ollama proxy now supports the Responses API, letting clients use "/v1/responses" directly with Ollama-hosted models through Open WebUI. [#23483](https://github.com/open-webui/open-webui/pull/23483)
|
||||
- 🧩 **Responses tool output rendering.** Built-in tool outputs in Responses API flows now render more consistently so downstream chat output is easier to interpret. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23482](https://github.com/open-webui/open-webui/pull/23482)
|
||||
- 🔎 **Responses citation visibility.** Responses API flows now emit citation sources more consistently, making linked references easier to preserve and display in chat output. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23774](https://github.com/open-webui/open-webui/issues/23774)
|
||||
- 📎 **Attach previously uploaded files.** The chat input menu now includes a Files tab for browsing and attaching previously uploaded files, eliminating the need to re-upload files you have already shared. [Commit](https://github.com/open-webui/open-webui/commit/edb8971c7dbd974322c3207c4655ff66479c3ee2)
|
||||
- 🧷 **Default model terminal selection.** Workspace model editors can now preselect an Open Terminal connection, so new chats automatically start with the model’s configured terminal ready to use. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d), [#23605](https://github.com/open-webui/open-webui/issues/23605)
|
||||
- 🎙️ **Mistral TTS support.** Mistral can now be used as a text-to-speech provider, with admin settings for the API key, base URL, voices, and model selection. [Commit](https://github.com/open-webui/open-webui/commit/4cee67e2be0c80a0b501073ea49a80d13efd1c41)
|
||||
- 🎧 **STT preprocessing bypass option.** Administrators can now enable "AUDIO_STT_SKIP_PREPROCESSING" to send audio files directly to the speech-to-text backend, reducing memory and CPU consumption during large uploads for better transcription performance and stability on constrained deployments. [#23661](https://github.com/open-webui/open-webui/pull/23661)
|
||||
- 🗑️ **Admin model deletion.** Administrators can now delete Ollama models directly from the model selector menu, making it easier to clean up unused or unwanted models. [Commit](https://github.com/open-webui/open-webui/commit/2388dd7dc3530b5dd5419c5d0bb1bcdcb7544099)
|
||||
- 🔌 **Backend outlet filters for local and persisted chats.** Pipeline and function outlet filters now run reliably in backend completion flows for persisted chats and temporary local chats. [#3237](https://github.com/open-webui/open-webui/issues/3237), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
|
||||
- 🎨 **Emoji shortcode support.** Typing a colon in the chat input now opens an emoji suggestion menu, making it easier to insert emojis using shortcodes like :wave:. [Commit](https://github.com/open-webui/open-webui/commit/2040095050056d01c61aa597c5010445449a42c7)
|
||||
- 📌 **Recently used emojis.** The emoji picker now shows your most recently used emojis at the top, making it faster to find emojis you use often. [Commit](https://github.com/open-webui/open-webui/commit/64da99a32218171d41b3af5acc14783de8dbdf49)
|
||||
- 👆 **Swipe to reply on mobile.** Swiping right on a message now triggers a reply, making it easier to respond on touch devices with a natural gesture. [Commit](https://github.com/open-webui/open-webui/commit/012ce95f27d57bea8911bd63bfb923443c5797ae)
|
||||
- 📱 **Screen-awake voice recording.** Voice recording now keeps the screen awake during active dictation and safely re-acquires wake lock after visibility changes, helping prevent long transcriptions from being cut off on mobile devices. [#23145](https://github.com/open-webui/open-webui/issues/23145)
|
||||
- 🔔 **Unread chat indicators.** Sidebar chats now show unread status and are marked as read when opened, making it easier to spot conversations with new activity. [Commit](https://github.com/open-webui/open-webui/commit/0638b9f56ce1ba8a496d0e84da2e7fa178b01a3f)
|
||||
- 🔌 **WebSocket reconnect status feedback.** Open WebUI now warns when the real-time connection drops and confirms when it reconnects, while avoiding a reconnect message on the initial page load. [Commit](https://github.com/open-webui/open-webui/commit/1824e69a70e756cfcf543a9fbe4b0780d9b57292)
|
||||
- 📍 **Pinned notes in sidebar.** Notes can now be pinned to the sidebar for quick access, and you can also create a new note directly from the pinned notes section. [Commit](https://github.com/open-webui/open-webui/commit/ecd74f220c7dd671d5705189a3f4493a3868c8bf), [Commit](https://github.com/open-webui/open-webui/commit/f1be85d997439b49fc143d2bcd2dc710f44446c8)
|
||||
- 🗂️ **Model selector focus.** The model selector now resets its search only when it opens, making the popup feel more predictable while still focusing the search field automatically. [Commit](https://github.com/open-webui/open-webui/commit/b89019a8e1f96e01dc8e19a81ef8fb4f4eae3eef)
|
||||
- 🗂️ **Model selector layout.** The model selector now behaves more predictably as a custom popup, and the completions playground uses a simpler model picker for easier selection. [Commit](https://github.com/open-webui/open-webui/commit/c40ea7f29d34fa9535cdf9ffe599f4429ff3f455)
|
||||
- 🎚️ **Active filter valve shortcut.** Active filter badges now expose valve configuration directly in the chat input area, so filter tuning is faster during conversations. [Commit](https://github.com/open-webui/open-webui/commit/3c22afc5a67404047797921185aca984b10b45cd), [#23811](https://github.com/open-webui/open-webui/issues/23811), [#23813](https://github.com/open-webui/open-webui/pull/23813)
|
||||
- 🎨 **Theme updates.** Other windows can now update the app theme directly, keeping the interface in sync when theme changes are triggered externally. [Commit](https://github.com/open-webui/open-webui/commit/9f1b279e88bd22dfff4d2531209536dea6a2f65e)
|
||||
- 🚀 **Async performance and responsiveness improvements.** The core backend database and request paths now run asynchronously across the application, massively improving responsiveness and performance under concurrent load and reducing request blocking during heavy activity. [Commit](https://github.com/open-webui/open-webui/commit/27169124f220e5cea21c88601c731c3749496ab0), [Commit](https://github.com/open-webui/open-webui/commit/8936721414a17832852a90f3ee592af5a8b7232d)
|
||||
- ⚡ **Drawer performance and memory optimization.** Drawer interactions now stay smoother over long sessions by removing stale keyboard listeners on teardown, which reduces memory growth and avoids accumulated event handling overhead. [#23724](https://github.com/open-webui/open-webui/pull/23724#issuecomment-4245840810)
|
||||
- 🚀 **Chat history memory culling.** Long conversations now stay responsive no matter how many messages they contain. Off-screen messages are unloaded automatically and reloaded as you scroll, keeping memory usage low and the UI smooth on both desktop and mobile. [#23067](https://github.com/open-webui/open-webui/issues/23067), [Commit](https://github.com/open-webui/open-webui/commit/026903399be73ac4b6c226647110e5662d043a50), [Commit](https://github.com/open-webui/open-webui/commit/9dccd29c94875e6f0ac373c5802cb183296e47ff)
|
||||
- 🧵 **Async file and knowledge processing performance.** File processing, knowledge reindexing, and channel message helper paths now consistently await async operations, preventing skipped processing steps and improving reliability and performance of indexing and tool responses. [Commit](https://github.com/open-webui/open-webui/commit/de27a121511a31606f250ba4033490797216a0eb)
|
||||
- 🚀 **Persistent chat payload efficiency.** Persisted chats now use server-side history loading instead of repeatedly resending full message payloads, improving multimodal performance and reducing stale-history overwrite risk across devices. [#19064](https://github.com/open-webui/open-webui/issues/19064), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
|
||||
- 🧵 **Non-blocking file storage operations.** Uploading, reading, transcribing, and deleting files now offloads storage I/O to background threads, keeping the application responsive during file-heavy workflows. [Commit](https://github.com/open-webui/open-webui/commit/4866bec0f238198a721c952fe18dd04ba643be33)
|
||||
- 🏎️ **Streaming response performance.** Streaming responses now process each output line in a single step instead of two separate yields, reducing async overhead and improving responsiveness during long-running generations. [#23266](https://github.com/open-webui/open-webui/pull/23266)
|
||||
- 🔎 **Faster mention parsing.** Chat text with HTML-like content, file paths, or tool output now parses mentions more efficiently, which helps keep typing and rendering responsive in messages that contain many '<' characters. [#23551](https://github.com/open-webui/open-webui/pull/23551)
|
||||
- 🧪 **Code block rendering performance.** Code blocks now reuse a shared HTML unescape helper, reducing extra browser work when displaying encoded output in chat. [#23553](https://github.com/open-webui/open-webui/pull/23553)
|
||||
- 🚀 **Inline code rendering performance.** Inline code tokens in streaming responses now fade in with a lightweight CSS animation, making chat output feel smoother while reducing interface overhead during rapid token updates. [#23258](https://github.com/open-webui/open-webui/pull/23258)
|
||||
- 🎞️ **Streaming text token animation performance.** Streaming text tokens now use a lightweight CSS intro animation, making output feel smoother while reducing transition overhead and preventing tokens from fading out when generation completes. [#23257](https://github.com/open-webui/open-webui/pull/23257)
|
||||
- 🎯 **Template token scan optimization.** Streaming responses now skip unnecessary token-replacement processing when no template markers are present, reducing per-update overhead and keeping chat output smoother during rapid generation. [#23161](https://github.com/open-webui/open-webui/pull/23161)
|
||||
- 🔬 **Chinese text processing guard performance.** Streaming responses without Chinese characters now skip unnecessary Chinese-format processing checks, reducing per-update overhead and keeping output smoother during rapid generation. [#23162](https://github.com/open-webui/open-webui/pull/23162)
|
||||
- 🧠 **HTML entity decode performance.** Streaming text decoding now avoids repeated document parsing for HTML entity handling, reducing memory churn and improving responsiveness in token-heavy chat output. [#23165](https://github.com/open-webui/open-webui/pull/23165)
|
||||
- 🏷️ **Chat title update performance.** Chat title updates now run in a single database operation instead of multiple round trips, improving responsiveness and reducing overhead when titles are generated or renamed. [#23214](https://github.com/open-webui/open-webui/pull/23214)
|
||||
- 📂 **Faster chat list queries performance.** Chat and folder lists now load more efficiently by fetching only the fields needed for sidebar views, improving responsiveness when browsing large conversation histories. [Commit](https://github.com/open-webui/open-webui/commit/0e5696de74cc0ba55b24cfc3d02efa83f08d7d3f)
|
||||
- 📈 **Sidebar memory optimization.** Sidebar chat items now use shared drag-preview resources and safer listener cleanup, reducing memory growth and keeping large chat lists more responsive during long sessions. [#23209](https://github.com/open-webui/open-webui/pull/23209)
|
||||
- 🧠 **Image viewer memory optimization.** Viewing images and SVGs now uses significantly less memory and performs faster, keeping the application snappy and responsive even when browsing through many media files during extended sessions. [#23236](https://github.com/open-webui/open-webui/pull/23236)
|
||||
- 📡 **Optimized user activity tracking performance.** User activity updates now use a single database query instead of multiple operations, improving response times across all authenticated requests. [#23215](https://github.com/open-webui/open-webui/pull/23215)
|
||||
- 👥 **Faster channel thread author loading.** Channel thread responses now load author details in a single batch query, reducing database overhead and improving responsiveness in threads with many participants. [#23795](https://github.com/open-webui/open-webui/pull/23795)
|
||||
- 💨 **Optimized shared chat deletion.** Deleting shared chats by user is now faster and more memory-efficient by only loading necessary data. [#23216](https://github.com/open-webui/open-webui/pull/23216)
|
||||
- 🗃️ **Faster chat tag loading.** Chat tag lookups now load only the metadata needed instead of full chat payloads, improving responsiveness for chats with large histories. [#23798](https://github.com/open-webui/open-webui/pull/23798)
|
||||
- 📎 **Faster chat file deduplication.** Attaching files to chat messages now checks duplicates more efficiently, reducing overhead when handling larger file lists. [#23800](https://github.com/open-webui/open-webui/pull/23800)
|
||||
- 📈 **Faster message diff checks.** Chat message and status updates now compare content more efficiently during streaming, making active conversations feel smoother and more responsive. [#23370](https://github.com/open-webui/open-webui/pull/23370)
|
||||
- ⚖️ **Faster deep equality checks.** Chat message updates, model selection, note editing, code block refreshes, and rich text state comparisons now use deep equality checks that reduce unnecessary UI work and improve responsiveness in active sessions. [#23845](https://github.com/open-webui/open-webui/pull/23845)
|
||||
- 🏃 **Faster knowledge access updates.** Updating access grants for knowledge items now completes with less backend overhead, making permission changes apply more quickly. [#23799](https://github.com/open-webui/open-webui/pull/23799)
|
||||
- 🧹 **Mermaid render cleanup performance.** Mermaid diagrams now always clean up temporary render elements after failures, reducing DOM buildup and keeping repeated rendering more stable over time. [#23727](https://github.com/open-webui/open-webui/pull/23727)
|
||||
- 🖼️ **Model image lookup efficiency.** Model profile image requests now reuse the current request database session, reducing per-request overhead and improving response efficiency. [#23796](https://github.com/open-webui/open-webui/pull/23796)
|
||||
- 👤 **User endpoint query reduction.** Session-based user settings and status endpoints now avoid redundant user re-fetches, reducing unnecessary database load while preserving behavior. [#23794](https://github.com/open-webui/open-webui/pull/23794)
|
||||
- 🚦 **Faster startup performance.** Open WebUI now checks for Torch MPS support only on macOS, avoiding unnecessary startup work on other platforms. [#23438](https://github.com/open-webui/open-webui/pull/23438)
|
||||
- 🛡️ **Redis timeout consistency.** Redis connections now honor the "REDIS_SOCKET_CONNECT_TIMEOUT" setting across standard and cluster setups, helping workers fail faster when Redis is unreachable. [#23572](https://github.com/open-webui/open-webui/pull/23572)
|
||||
- 🧰 **AIOHTTP pool controls.** Administrators can now tune shared outbound HTTP connection behavior with "AIOHTTP_POOL_CONNECTIONS", "AIOHTTP_POOL_CONNECTIONS_PER_HOST", and "AIOHTTP_POOL_DNS_TTL" for better control under high concurrency. [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135)
|
||||
- ⏱️ **MCP tool server timeout configuration.** Administrators can now configure request timeouts for MCP tool server connections via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable. [Commit](https://github.com/open-webui/open-webui/commit/10b4b86ada93cd62d994c3179ff14dfd1a6e56f0)
|
||||
- 🎫 **Static OAuth tool authentication.** Tool server authentication now works reliably for both "oauth_2.1" and "oauth_2.1_static" connection types, so OAuth-backed tool access is correctly detected and forwarded during chat requests. [Commit](https://github.com/open-webui/open-webui/commit/60676bfdcfbce1a69b3e97f2013f0cfd63371737)
|
||||
- 🗄️ **Configurable storage local cache.** Administrators can now disable persistent local caching for cloud-backed uploads with the "STORAGE_LOCAL_CACHE" setting, reducing local disk usage by cleaning temporary upload copies after processing. [Commit](https://github.com/open-webui/open-webui/commit/8172c7e3d56918d1372be06b9369b58a3a88f6b1)
|
||||
- 🚪 **Back-channel logout.** OpenID Connect providers can now trigger centralized logout through the "ENABLE_OAUTH_BACKCHANNEL_LOGOUT" setting, helping administrators invalidate user sessions more reliably across connected devices. [Commit](https://github.com/open-webui/open-webui/commit/0dd9f462ffb2f160bc4aebad182047f41874d250)
|
||||
- 🛡️ **Expanded security header controls.** Administrators can now configure additional browser security headers, including "CONTENT_SECURITY_POLICY_REPORT_ONLY", "CROSS_ORIGIN_EMBEDDER_POLICY", "CROSS_ORIGIN_OPENER_POLICY", and "CROSS_ORIGIN_RESOURCE_POLICY", for stricter and more flexible deployment hardening. [Commit](https://github.com/open-webui/open-webui/commit/f246a66810fa4995d9494da3599c0fb297fb0213)
|
||||
- 🖼️ **Image MIME fallback option.** Administrators can now enable "ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK" so image-to-base64 conversion can still detect common image types by file extension when MIME metadata is missing, improving compatibility on minimal container images and older file records. [Commit](https://github.com/open-webui/open-webui/commit/5127354b3eb4eaa71bc4ad68da69729e2196e7a4)
|
||||
- 🛡️ **Public sharing permissions.** Public channels, models, notes, prompts, and tools now respect allowed access grants more consistently, helping administrators control who can share content more safely. [Commit](https://github.com/open-webui/open-webui/commit/9d3e0637c86292b8b92e7607097a83f1075d7cd8)
|
||||
- 🆔 **Skill lookup by ID.** Skill instructions now include each skill’s ID, and the skill viewer now finds skills by ID in a case-insensitive way so attached skills are identified more reliably in chats. [Commit](https://github.com/open-webui/open-webui/commit/65ee771fd0d62d785ecbcf189e3f5b63858c11e6)
|
||||
- 🏷️ **Source context metadata.** Retrieval source context now includes each source’s resource type and resource ID metadata, helping downstream model workflows preserve richer source identity during processing. [Commit](https://github.com/open-webui/open-webui/commit/c3c8c605d76a3b0ee067307f9cef6d081658e287)
|
||||
- 🗂️ **Feedback filtering.** Administrators can now filter feedback history by model and export only the feedback they need. [Commit](https://github.com/open-webui/open-webui/commit/60e4d7517463690b3a87de38babc9ac561897c61)
|
||||
- 📤 **CSV feedback export.** Feedback history can now be exported as either JSON or CSV, making it easier to analyze feedback in spreadsheet tools. [Commit](https://github.com/open-webui/open-webui/commit/342582676a5212bf196a69d11825cb407992f257)
|
||||
- 📝 **Optional GET audit logging.** Administrators can now enable auditing for GET requests with the "ENABLE_AUDIT_GET_REQUESTS" setting when they need fuller request visibility. [Commit](https://github.com/open-webui/open-webui/commit/5ee791d5d28f236755243cb7d16d8737bb69ce36)
|
||||
- 🕒 **Model access updates.** Changing a model’s access grants now updates its timestamp, so recently modified models stay easier to find and sort correctly. [Commit](https://github.com/open-webui/open-webui/commit/53eadb7df7281f5661cbe22c8b26b5aedaba3083)
|
||||
- 💬 **Queued message handling.** Queued chat messages now send more reliably without advancing the queue too early, keeping follow-up prompts in the intended order. [Commit](https://github.com/open-webui/open-webui/commit/730e52a431d157dc62d72260668087437f1d52f4)
|
||||
- 🔒 **Rendered content safety.** Placeholder descriptions and the pending account notice now render markdown with safer sanitization ordering, reducing the risk of unsafe HTML appearing in these views. [Commit](https://github.com/open-webui/open-webui/commit/253f416de3f2d3a939a6feef2a56413fd61cc70b)
|
||||
- 🛡️ **Safer placeholder rendering.** Chat placeholder descriptions and the pending account notice now sanitize rendered markdown more consistently, reducing the risk of unsafe content being shown in these views. [Commit](https://github.com/open-webui/open-webui/commit/ae0316a30e01a2e5ff3f9d2f9f759c1cd6410f34)
|
||||
- 🧮 **Usage analytics accuracy.** Token usage is now normalized before chat messages are saved, so model and user usage reports stay accurate across OpenAI-compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/4dea4fdf54e00ebaba8e3178128bf8709453d2a2)
|
||||
- 🧩 **Richer Anthropic tool results.** Anthropic-compatible tool calls now preserve more tool result content types, including images and structured search or document outputs, so models can use fuller tool context instead of receiving only plain text fragments. [#23188](https://github.com/open-webui/open-webui/issues/23188), [Commit](https://github.com/open-webui/open-webui/commit/40f5b3d135190dc9a2d8e94dbb1b2cbcbd829132)
|
||||
- 🖼️ **ComfyUI request reliability.** ComfyUI image generation and editing now use shared async connections with consistent SSL handling, making image uploads and workflow runs more reliable under concurrent load. [Commit](https://github.com/open-webui/open-webui/commit/5944eda0ff25a284f7157252683bccede741cbe7)
|
||||
- 🎛️ **Reranking batch size control.** Administrators can now set "RAG_RERANKING_BATCH_SIZE" in Documents settings to control reranking workload size, helping balance retrieval speed and resource usage for their deployment. [Commit](https://github.com/open-webui/open-webui/commit/4d2f18981051205016bd24d39521e25a33581225)
|
||||
- 🔗 **Shared chat access controls.** You can now control who has access to a shared chat by granting access to specific users or groups, instead of sharing with anyone who has the link.
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Irish, Catalan, German, Simplified Chinese, Hindi, and Portuguese (Brazil) were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Model description XSS protection.** Model descriptions shown in chat placeholders are now sanitized before rendering, preventing malicious links from executing scripts and helping protect user sessions from takeover. [#23621](https://github.com/open-webui/open-webui/pull/23621)
|
||||
- 🧠 **Memory search filtering.** Memory search now correctly filters by the query text instead of returning unrelated results. [Commit](https://github.com/open-webui/open-webui/commit/43e5905c133049036353978704b0abd179716749), [#23826](https://github.com/open-webui/open-webui/issues/23826)
|
||||
- 📊 **Shared chat analytics consistency.** Usage and message-count analytics now count assistant activity consistently across regular and shared chats, improving accuracy in model, user, chat, and time-based reporting views. [Commit](https://github.com/open-webui/open-webui/commit/e29d145a1cff23122de16123a4cfda1b84abffbb)
|
||||
- 🧭 **Safer in-flight chat navigation.** Sending a message no longer overwrites your active chat or causes duplicate background notifications when you switch conversations before a response finishes. [Commit](https://github.com/open-webui/open-webui/commit/dc6df52a917b49fa1264ac81a8cc74603f6155b3)
|
||||
- 🗣️ **Pipeline error detail visibility.** Pipeline inlet and outlet failures now preserve and surface provider error details more reliably in chat error messages, making troubleshooting failed requests much clearer. [Commit](https://github.com/open-webui/open-webui/commit/d5e69f182cd7a6371ab25248f6432b277f83ef23)
|
||||
- 📨 **Shared chat event routing.** Message update and send events now target the chat owner’s event channel, so shared chats receive the correct real-time updates instead of routing events to the acting user. [Commit](https://github.com/open-webui/open-webui/commit/47329b5032ba29716a7e7e973b07c6d9894968e0)
|
||||
- 🔐 **Consistent outbound SSL handling.** External requests for tools, functions, terminals, webhooks, retrieval loaders, audio provider discovery, and OpenAI-compatible embedding calls now consistently apply the configured SSL client setting, improving reliability for deployments that require custom certificate or verification behavior. [Commit](https://github.com/open-webui/open-webui/commit/fd25152076ea7c310e42c9bacc5cd2b544eeae48), [Commit](https://github.com/open-webui/open-webui/commit/56c5bc1d3487020ab886d3332aacc1644c1d6123)
|
||||
- 🖼️ **Image SSL setting support.** Image generation now respects the configured SSL session setting, preventing avoidable connection failures in strict certificate environments. [Commit](https://github.com/open-webui/open-webui/commit/128cf41fcedf2638fc8a6acd850d8b0409be1c4e), [#23777](https://github.com/open-webui/open-webui/issues/23777)
|
||||
- 🗂️ **Folder ownership assignment hardening.** Folder create and update inputs now reject unexpected extra fields, preventing clients from overriding protected values like ownership through mass-assignment payloads. [#23648](https://github.com/open-webui/open-webui/pull/23648)
|
||||
- 🔐 **Knowledge file deletion ownership checks.** Collaborators with knowledge base write access can no longer permanently delete files they do not own, preventing unintended file removal across other linked chats and knowledge bases. [Commit](https://github.com/open-webui/open-webui/commit/914ccf07ef158afe5588b97ed42778c93c439938), [#23636](https://github.com/open-webui/open-webui/pull/23636#issuecomment-4232439454)
|
||||
- 🗑️ **Knowledge deletion reliability.** Deleting a knowledge base by ID now completes reliably without unexpected failures. [Commit](https://github.com/open-webui/open-webui/commit/7e453de4f7794ff386e285aa5951b94e926ec273), [#23776](https://github.com/open-webui/open-webui/issues/23776), [#23814](https://github.com/open-webui/open-webui/pull/23814)
|
||||
- 🔐 **OAuth 2.1 PKCE enforcement.** OAuth 2.1 providers now default to S256 PKCE even when discovery metadata omits supported challenge methods, preventing login failures with providers that require PKCE by default. [#23667](https://github.com/open-webui/open-webui/issues/23667), [Commit](https://github.com/open-webui/open-webui/commit/050c4b97a95addc5eaeef86ba00631673a90dec4)
|
||||
- 🔐 **Static OAuth scope handling.** Static OAuth credential flows now prioritize administrator-defined scopes and handle OAuth 2.1 static flow behavior more reliably. [Commit](https://github.com/open-webui/open-webui/commit/349ea4ea9e577f2cbfb4917ef5f52e5ac53c5b70), [#23668](https://github.com/open-webui/open-webui/issues/23668), [#23696](https://github.com/open-webui/open-webui/pull/23696), [#23783](https://github.com/open-webui/open-webui/pull/23783)
|
||||
- 🔐 **Static OAuth tool registration reliability.** Static OAuth tool server registration now resolves and uses saved admin credentials more reliably, preventing registration failures when valid client credentials are provided. [#23670](https://github.com/open-webui/open-webui/issues/23670), [Commit](https://github.com/open-webui/open-webui/commit/2943955c529138c0e530fd07b6333a0052e3684e), [Commit](https://github.com/open-webui/open-webui/commit/c767bcaa739f76b1a4337dfd9d6be47adb504825)
|
||||
- ⏳ **OAuth token expiry fallback.** OAuth sessions now always store a safe expiry value even when providers omit "expires_in" or "expires_at", so token refresh checks continue working and tool calls are less likely to fail later with unexpected authorization errors. [#23669](https://github.com/open-webui/open-webui/issues/23669), [Commit](https://github.com/open-webui/open-webui/commit/31406caa795173a59d5843d3601b891bf617cbaa)
|
||||
- 🔑 **Anthropic x-api-key model access.** Anthropic-compatible clients can now authenticate with the "x-api-key" header across all relevant API routes, so model listing requests like GET "/api/v1/models" no longer fail with unauthorized errors. [#23319](https://github.com/open-webui/open-webui/issues/23319), [Commit](https://github.com/open-webui/open-webui/commit/611fe0c8a938539b73b559e84964f40c30bf436d)
|
||||
- 🔑 **SSO password option visibility.** Account settings now hide password change controls when password-change access is disabled, avoiding misleading password options for SSO-focused setups. [#15292](https://github.com/open-webui/open-webui/issues/15292), [Commit](https://github.com/open-webui/open-webui/commit/cced77b584d6ea46c58fecddb2b3dd5e955c8417)
|
||||
- 🔑 **Open Terminal MCP authentication.** Open Terminal MCP tool calls now include the configured API key when calling internal routes, preventing unauthorized errors for commands like file reads and command execution. [#106](https://github.com/open-webui/open-terminal/pull/106)
|
||||
- 🧯 **Provider error freeze recovery.** Task-based chat requests now surface provider HTTP errors through normal failure handling, so content-filter and other upstream 4xx responses no longer leave chats stuck in a perpetual loading state. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5)
|
||||
- 🔄 **Immediate outlet filter updates.** Assistant messages modified by outlet filters now appear correctly as soon as streaming completes, without requiring a page refresh. [#23829](https://github.com/open-webui/open-webui/pull/23829)
|
||||
- 🌊 **Middleware cancellation reliability.** Long-running requests now complete more reliably by preventing middleware-level cancellations from interrupting in-flight database and embedding work, reducing unexpected failures and noisy error logs when connections close early. [#23709](https://github.com/open-webui/open-webui/pull/23709)
|
||||
- 🚦 **Async vector search responsiveness.** File processing, memory updates, and knowledge retrieval no longer block the server event loop during vector database operations, so other chats and requests stay responsive while indexing or search is running. [#23706](https://github.com/open-webui/open-webui/pull/23706)
|
||||
- 🗒️ **Notes chat llama.cpp compatibility.** Notes AI chat no longer sends empty assistant prefill messages that can conflict with reasoning-enabled llama.cpp responses, preventing immediate 400 errors in Notes conversations. [Commit](https://github.com/open-webui/open-webui/commit/fd93bd3414a1725219e14561bc5640b62f9fd4a1), [#23703](https://github.com/open-webui/open-webui/issues/23703#issuecomment-4243907629)
|
||||
- 🧩 **Ollama thinking field preservation.** Messages modified by filters now keep the Ollama "thinking" field when sent to the model, so reasoning-aware workflows and custom filter-based passthrough setups work reliably. [Commit](https://github.com/open-webui/open-webui/commit/8bd23b91459914eb7df5b5a66567d3544e0da168), [#22508](https://github.com/open-webui/open-webui/issues/22508)
|
||||
- 🧾 **Reasoning content preservation.** Assistant tool-call messages now retain reasoning content across turns, improving reliability for reasoning-heavy model workflows. [Commit](https://github.com/open-webui/open-webui/commit/3dd8255816898467246c81cba3c9bc48bc18d86d), [#23175](https://github.com/open-webui/open-webui/issues/23175), [#23742](https://github.com/open-webui/open-webui/pull/23742)
|
||||
- 🧭 **Background task scoping for new chats.** Chat title and auto-tag generation now run only for the first message of a new conversation and only once in multi-model responses, preventing duplicate or incorrectly triggered background tasks in follow-up flows. [Commit](https://github.com/open-webui/open-webui/commit/f102060a6d85db4acd3d0bf5c25e976f36cd5533..a4ed16999eec9a654a37c2bb4c15ba5ecd1fa3b7)
|
||||
- 📚 **Channel document context retention.** Channel conversations now preserve and load the correct stored message history so model responses can use uploaded and retrieved document context more reliably. [#23686](https://github.com/open-webui/open-webui/issues/23686), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6)
|
||||
- ⏳ **Interrupted response recovery.** Assistant placeholder messages now start as incomplete and recover more safely after interrupted generations, preventing silent empty replies after refreshes or dropped requests. [#23176](https://github.com/open-webui/open-webui/issues/23176), [Commit](https://github.com/open-webui/open-webui/commit/c8ef7b028931263e8773cb60a7111d80d9572d26), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
|
||||
- 🧰 **Large tool result rendering.** Tool call details now display large result payloads reliably in chat instead of intermittently showing empty output for bigger tool responses. [#18743](https://github.com/open-webui/open-webui/issues/18743), [Commit](https://github.com/open-webui/open-webui/commit/45e49d33e51f7720c00b564215484aff9b48b20c)
|
||||
- 🧼 **Null-byte document sanitization.** PDF and other document ingests now sanitize null bytes and invalid surrogate characters before pgvector writes, preventing PostgreSQL upload failures and allowing affected files to index successfully. [#22992](https://github.com/open-webui/open-webui/issues/22992), [Commit](https://github.com/open-webui/open-webui/commit/8dba798cce9fb1efc5f6acc5f37b152662db78d7)
|
||||
- 📝 **Knowledge text editor stability.** The Knowledge "Add Text Content" modal now uses a plain text editor, avoiding current rich text editor issues and keeping drafting behavior consistent with existing knowledge editing flows. [Commit](https://github.com/open-webui/open-webui/commit/cd55c3e21237e000c13c6f396bb95b261f3bda82)
|
||||
- 🎤 **STT SSL setting consistency.** Speech and related outbound media requests now consistently use shared async HTTP sessions and honor the configured SSL verification setting, improving compatibility with self-signed deployments. [#23672](https://github.com/open-webui/open-webui/issues/23672), [Commit](https://github.com/open-webui/open-webui/commit/2ddcb30b9a519885422ba1f36cc3485a7d897bf8)
|
||||
- 🎙️ **Mistral speech input format.** Mistral speech-to-text requests now use the correct chat-completions audio input format for better compatibility. [Commit](https://github.com/open-webui/open-webui/commit/34d569d564a8ef2702c647dbad83eac840b76b2e), [#23822](https://github.com/open-webui/open-webui/issues/23822)
|
||||
- 🖼️ **Optional image size parameter.** Image generation no longer sends the "size" field when no size is configured, improving compatibility with providers that reject unsupported size arguments. [#23611](https://github.com/open-webui/open-webui/issues/23611), [Commit](https://github.com/open-webui/open-webui/commit/869cf9e848b741705dc058550fa1b3f70db47fe8)
|
||||
- 🔎 **FireCrawl timeout reliability.** FireCrawl web loading now uses direct scrape requests and improved timeout handling for single-URL fetches, reducing empty results and premature timeout failures with local FireCrawl setups. [#23411](https://github.com/open-webui/open-webui/issues/23411), [Commit](https://github.com/open-webui/open-webui/commit/9c64d84ad90804bf7d891e4a5097c03c4d7044c3)
|
||||
- 🖱️ **Custom action icon drag prevention.** Custom user-added action icons in chat responses are no longer accidentally draggable, so clicks and hover interactions behave consistently with built-in action icons. [#23412](https://github.com/open-webui/open-webui/pull/23412)
|
||||
- 🖼️ **Image URL conversion reliability.** Sending image URLs to AI models no longer fails with "cannot pickle 'coroutine' object" errors, so image inputs now convert to base64 reliably during request processing. [#23685](https://github.com/open-webui/open-webui/pull/23685#issuecomment-4240424635)
|
||||
- 📂 **Channel input menu dismissal.** In Workspace Channels, the message input dropdown now closes immediately after selecting "Upload Files" or "Capture", matching normal chat input behavior and preventing the menu from staying open unnecessarily. [#23684](https://github.com/open-webui/open-webui/pull/23684)
|
||||
- 📋 **Clipboard copy scroll stability.** Copying content with the fallback clipboard method no longer triggers unwanted page scrolling during focus, keeping your current reading position stable. [Commit](https://github.com/open-webui/open-webui/commit/fc98000aa8d439bbff21a70370f5e962bf23f4bc)
|
||||
- 🖼️ **Profile image URL validation.** Profile saves now accept valid Open WebUI profile-image paths, trusted external HTTP(S) avatar URLs, and safe raster data-image formats while rejecting unsafe URL patterns that could be abused. [#23389](https://github.com/open-webui/open-webui/pull/23389)
|
||||
- 👤 **Partial user profile updates.** User update API requests can now modify only the fields you provide, so administrators no longer need to resubmit unchanged name, email, and profile image values when changing a single setting like role. [#23424](https://github.com/open-webui/open-webui/issues/23424), [Commit](https://github.com/open-webui/open-webui/commit/3c2c611ba91d794a1e73134ec41b0de2b3927677)
|
||||
- 🚨 **Provider SSE error visibility.** Provider failures returned with streaming content types are now surfaced as proper API errors and logged clearly, so issues like context-window limits no longer fail silently during chat generation. [#23379](https://github.com/open-webui/open-webui/pull/23379)
|
||||
- 🧵 **Queued prompt race prevention.** Chat request queues now prevent overlapping processing for the same chat, avoiding duplicate queue handling when multiple queue-processing triggers fire close together. [#23181](https://github.com/open-webui/open-webui/issues/23181), [Commit](https://github.com/open-webui/open-webui/commit/e10a00132eed54a0108fb6ac120e8229deef3656)
|
||||
- 🛑 **Cancellation event delivery reliability.** Cancelled chat processing now safely emits task-cancel and error events only when an event emitter is available, while provider HTTP errors now also route through task-cancel handling so chats recover from blocked-loading states more reliably. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/51765b619c8584b042af68c3a5c87525a105ccd8), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5)
|
||||
- 🔑 **OIDC key-rotation recovery.** OIDC login now retries token authorization with refreshed provider signing keys after a bad-signature failure, so logins recover automatically after identity-provider key rotation without requiring a service restart. [#23582](https://github.com/open-webui/open-webui/issues/23582), [Commit](https://github.com/open-webui/open-webui/commit/facb194a07486e847f0725a0a839e99b5864d37b)
|
||||
- 🌍 **Non-ASCII tag filtering.** Prompt and model tag filters now handle non-Latin tags more reliably across SQLite and PostgreSQL, so tags like Cyrillic values return the expected items in Workspace lists. [#23381](https://github.com/open-webui/open-webui/issues/23381), [#23427](https://github.com/open-webui/open-webui/pull/23427), [Commit](https://github.com/open-webui/open-webui/commit/57784706e4fee75dec67e20b0d89a97351ac6256)
|
||||
- 🏷️ **Prompt tag query accuracy.** Prompt tag filtering now uses JSON-element-aware queries so tag-based lookups return the correct prompts. [Commit](https://github.com/open-webui/open-webui/commit/e7e752f8e74e7b01fe2e6cb56f06e99312e1afe7), [#23386](https://github.com/open-webui/open-webui/pull/23386)
|
||||
- 🗃️ **SQLite async pool compatibility.** SQLite async database setup no longer forces an explicit queue pool class, avoiding pool configuration conflicts in SQLite deployments. [Commit](https://github.com/open-webui/open-webui/commit/26b8ca5b5eeb144fae3fe6eaeae826150d8af826)
|
||||
- 🧠 **Knowledge embedding deadlock prevention.** Knowledge file processing now runs blocking vector-save work in a worker thread while keeping async status updates reliable, preventing file processing from stalling during long embedding operations. [Commit](https://github.com/open-webui/open-webui/commit/d4b90f93bda2413ec8f040e61959acdb7b242061), [Commit](https://github.com/open-webui/open-webui/commit/22cfb3c673cbfa4a6bce26fde8e2e2754ce4963b)
|
||||
- 🤖 **Automation worker async DB handling.** Automation claiming and run recording now use async database sessions consistently, improving worker stability for scheduled automations. [Commit](https://github.com/open-webui/open-webui/commit/cb6e77be3ec6ce00dd1f5b9ce3a655e6f65bc5da)
|
||||
- 🕒 **Automation timezone scheduling.** Scheduled automations now calculate each user’s next run time using that user’s saved timezone, preventing run drift caused by server-time fallback. [Commit](https://github.com/open-webui/open-webui/commit/a4d62253df55c6307112eb76a6bfa29a7f538e21)
|
||||
- 🔎 **Notes search matching.** Notes search now handles multi-word and hyphenated queries more reliably, so relevant notes and snippets are easier to find from partial phrase searches. [Commit](https://github.com/open-webui/open-webui/commit/a35926261646f8897ba71da1572ed5dff802e3be)
|
||||
- 📐 **Display math rendering.** Chat markdown now correctly recognizes and renders "$$...$$" expressions as display math, improving reliability for multiline and escaped KaTeX content while keeping malformed delimiters from disrupting message rendering. [#23526](https://github.com/open-webui/open-webui/issues/23526), [Commit](https://github.com/open-webui/open-webui/commit/15b89b9218b7d2c7239c579aa3d23c2892227ac6)
|
||||
- 🚫 **LDAP empty-password rejection.** LDAP login now rejects empty or whitespace-only passwords before bind attempts, preventing unauthenticated simple-bind behavior from granting access on permissive LDAP server configurations. [#23633](https://github.com/open-webui/open-webui/pull/23633)
|
||||
- 🌐 **IPv6 SSRF address blocking.** URL validation now uses standard IP address checks for both IPv4 and IPv6, preventing private, loopback, link-local, reserved, and mapped-address SSRF bypasses through IPv6 hostname resolution. [#23453](https://github.com/open-webui/open-webui/pull/23453)
|
||||
- 🔒 **API key endpoint restriction bypass.** API key endpoint restrictions are now enforced regardless of whether the key is sent through Authorization headers, cookies, or "x-api-key", preventing bypass through alternate key transport paths. [#23637](https://github.com/open-webui/open-webui/pull/23637)
|
||||
- 🔐 **Channel sharing permission enforcement.** Channel creation and updates now enforce allowed access grant rules for public sharing, preventing unauthorized wildcard sharing on group channels. [#23638](https://github.com/open-webui/open-webui/pull/23638)
|
||||
- 🛑 **Socket role invalidation.** Socket sessions now disconnect automatically when a user is demoted or deleted, preventing stale admin privileges from persisting until reconnect. [#23642](https://github.com/open-webui/open-webui/pull/23642)
|
||||
- 🛂 **Tool server access checks.** Tool listing now correctly awaits server access checks, preventing users from seeing server-backed tools they do not have permission to use. [Commit](https://github.com/open-webui/open-webui/commit/d40f31982be3eed37e55e3f67b1eea9a5dc8c525)
|
||||
- 🛑 **Task endpoint access control.** Global task listing and direct task stop endpoints are now restricted to administrators, while regular users can stop only their own chat tasks through a scoped chat endpoint. [#23454](https://github.com/open-webui/open-webui/pull/23454)
|
||||
- 🧱 **Redis cache key isolation.** Tool server and terminal server cache entries now include the Redis key prefix, preventing multiple Open WebUI instances that share one Redis database from overwriting each other’s cached connection data. [#23649](https://github.com/open-webui/open-webui/pull/23649)
|
||||
- 🧠 **Client session leak prevention.** Outbound provider requests now use a shared session pool with safer response cleanup and shutdown handling, preventing aiohttp session buildup and reducing memory growth during heavy concurrent API traffic. [#23540](https://github.com/open-webui/open-webui/issues/23540), [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135)
|
||||
- 🧩 **Tool enum value handling.** Tool schema generation now safely handles enum values as strings, preventing failures when OpenAPI parameters include non-string enum entries. [#23597](https://github.com/open-webui/open-webui/issues/23597), [Commit](https://github.com/open-webui/open-webui/commit/4498e6faf2b1bdd1caa0e2c1c15d90a2790cd721)
|
||||
- 🧷 **Responses model access control.** The OpenAI-compatible Responses endpoint now enforces per-model permissions, preventing non-admin users from accessing models they are not allowed to use. [#23481](https://github.com/open-webui/open-webui/pull/23481)
|
||||
- 🛡️ **Collection process endpoint permissions.** Collection processing endpoints now enforce collection ownership checks for web and text processing requests. [Commit](https://github.com/open-webui/open-webui/commit/ba83613ff297bc82db660b5273f04672d744902f), [#23634](https://github.com/open-webui/open-webui/pull/23634)
|
||||
- 📚 **Knowledge query access enforcement.** Knowledge-base collection queries now block unauthorized enumeration and require read access before returning results. [Commit](https://github.com/open-webui/open-webui/commit/860b90fd17d14ba00674621edd294dee150491d2), [#23635](https://github.com/open-webui/open-webui/pull/23635), [#23452](https://github.com/open-webui/open-webui/pull/23452)
|
||||
- 🔍 **RAG collection query permissions.** Vector search collection queries now enforce access checks before retrieval results are returned. [Commit](https://github.com/open-webui/open-webui/commit/f44b7a01f5b854f47c1594a1ab5f72096f736262), [#23627](https://github.com/open-webui/open-webui/pull/23627)
|
||||
- 🔗 **Chained base model access checks.** Chained base model execution now enforces per-model access rules to prevent unauthorized model usage. [Commit](https://github.com/open-webui/open-webui/commit/8acce144f99992b75c25f0e5038b16881ce9f066), [Commit](https://github.com/open-webui/open-webui/commit/50363ba66b19613a2fc0cab6a3f7f724a825135e), [#23647](https://github.com/open-webui/open-webui/pull/23647)
|
||||
- ✍️ **Collaborative document write checks.** Collaborative document updates now require proper write permission before changes are accepted. [Commit](https://github.com/open-webui/open-webui/commit/638c7ab80216452910bdc59a19eb90e6b7244c6c), [Commit](https://github.com/open-webui/open-webui/commit/3271b013a8b30a882364679dcb40ffc9a89f037e), [#23624](https://github.com/open-webui/open-webui/pull/23624)
|
||||
- 📥 **Model import ownership validation.** Model import now enforces ownership and access grant checks to prevent unauthorized imports. [Commit](https://github.com/open-webui/open-webui/commit/499129625bf96b2c03a6d057a2f91fdf07fd1c49), [#23628](https://github.com/open-webui/open-webui/pull/23628)
|
||||
- 🚫 **Inactive member channel access.** Deactivated group members can no longer read or write channel content through direct API calls, so channel permissions now match active membership status. [#23623](https://github.com/open-webui/open-webui/pull/23623)
|
||||
- 🎛️ **Ollama endpoint model permissions.** Restricted models are now protected on Ollama show, generate, embed, and embeddings endpoints, preventing authenticated users from using private models without read access. [#23631](https://github.com/open-webui/open-webui/pull/23631)
|
||||
- 🧭 **Azure deployment path validation.** Azure model names are now validated and safely encoded before request URL construction, preventing path traversal attempts from reaching unintended Azure endpoints. [#23629](https://github.com/open-webui/open-webui/pull/23629)
|
||||
- 👥 **Private channel member list access.** Standard channel member lists now require proper read permission, preventing unauthorized users from enumerating members of private channels by direct API calls. [#23625](https://github.com/open-webui/open-webui/pull/23625)
|
||||
- 🌀 **Tool server schema recursion safety.** Tool server OpenAPI conversion now handles circular request schema references safely, preventing conversion crashes and ensuring one bad tool server spec does not break the full tool server list. [#23588](https://github.com/open-webui/open-webui/pull/23588), [Commit](https://github.com/open-webui/open-webui/commit/d3df8f1f372411314be9121fbf61d107939fa258)
|
||||
- 🧱 **Safer file path handling.** File upload, transcription cache, and model download paths now use safer path construction helpers to reduce path parsing risks and improve cross-platform path safety. [Commit](https://github.com/open-webui/open-webui/commit/15f9a8f3f13f112c96cb1b16f88859f65de58346)
|
||||
- 🧾 **Prompt save error feedback.** Saving prompt edits now shows a clear error toast if the save fails, so failed updates are visible instead of silently failing in the editor flow. [Commit](https://github.com/open-webui/open-webui/commit/36a81ad43b7c0d450079f818a7546eaa517e3d95)
|
||||
- 🧾 **Tool call JSON rendering.** Tool call arguments and structured results now render as plain formatted JSON blocks instead of markdown code fences, preventing formatting quirks and making tool output easier to read consistently. [Commit](https://github.com/open-webui/open-webui/commit/a7d4c53f3adb80768b67e4a410b486b04a581521)
|
||||
- 👥 **First-user admin race protection.** Concurrent first-time LDAP or OAuth registrations can no longer create multiple admin accounts, so only the true first account is promoted during initial setup. [#23626](https://github.com/open-webui/open-webui/pull/23626)
|
||||
- 🔒 **SCIM token checks.** SCIM authentication now compares tokens in a safer way, helping prevent timing-based token guessing attacks. [#23577](https://github.com/open-webui/open-webui/pull/23577)
|
||||
- 🔒 **Safer file access checks.** HTML file previews now treat missing or non-admin owners as inaccessible, preventing accidental access to files that should not be shown. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9)
|
||||
- 🖼️ **ComfyUI request hangs.** Concurrent image generation and editing requests to ComfyUI now complete reliably instead of getting stuck when the same user starts multiple requests at once. [#23592](https://github.com/open-webui/open-webui/pull/23592), [#23591](https://github.com/open-webui/open-webui/issues/23591)
|
||||
- 🧭 **Permission-aware built-in tools.** Built-in tools now consistently respect user feature permissions for memories, web search, image generation, code interpreter, notes, channels, and automations, preventing tools from being exposed to users without access. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
|
||||
- 🛑 **Interrupted MCP cleanup stability.** Interrupted MCP tool calls no longer leave runaway cleanup behavior that can drive container CPU usage to 100%, keeping instances stable after cancellations or dropped connections. [#23143](https://github.com/open-webui/open-webui/issues/23143)
|
||||
- 🚪 **OAuth redirect URI reliability.** OAuth login redirects now use provider client metadata more consistently, preventing incorrect HTTP callback URLs behind reverse proxies and improving sign-in reliability for providers such as Feishu. [#23203](https://github.com/open-webui/open-webui/pull/23203), [#23128](https://github.com/open-webui/open-webui/issues/23128)
|
||||
- 🌐 **OAuth redirect handling.** OAuth provider token exchange now follows redirects automatically, improving sign-in reliability with identity providers that redirect token endpoint requests. [#23409](https://github.com/open-webui/open-webui/issues/23409), [Commit](https://github.com/open-webui/open-webui/commit/498ff8cdc3dd47000cdc60e5adcf36f4adfbe07d)
|
||||
- ☁️ **OneDrive picker redirect handling.** OneDrive file picker authentication now uses the current app origin as the redirect URI, improving sign-in reliability when launching the picker from deployed environments. [#23450](https://github.com/open-webui/open-webui/issues/23450), [Commit](https://github.com/open-webui/open-webui/commit/21cc8281323d505d7d084cc496bd433063315c86)
|
||||
- 🍪 **OAuth session cookie persistence.** OIDC sign-in now correctly sets the "oauth_session_id" cookie, so "system_oauth" connections can forward user OAuth tokens to upstream providers as expected. [#23251](https://github.com/open-webui/open-webui/pull/23251), [#23250](https://github.com/open-webui/open-webui/issues/23250)
|
||||
- 🔑 **OAuth session cookie handling.** OAuth callback processing no longer fails on undefined cookie expiry data, so OAuth session cookies are stored correctly after sign-in. [#23207](https://github.com/open-webui/open-webui/pull/23207), [#23197](https://github.com/open-webui/open-webui/issues/23197)
|
||||
- 🔏 **Ollama SSL handling.** Ollama model management and file uploads now respect the configured SSL verification setting, so self-signed certificates work when SSL verification is disabled. [#23503](https://github.com/open-webui/open-webui/issues/23503), [Commit](https://github.com/open-webui/open-webui/commit/e51b661af0e71a24f041428f328fcc6e97a15262)
|
||||
- 🛡️ **OAuth avatar URL validation.** OAuth sign-in now validates profile picture URLs before fetching them, preventing invalid image links from causing login-time errors. [#23356](https://github.com/open-webui/open-webui/pull/23356)
|
||||
- 🔑 **User invite token expiry.** New user invite logins now respect the configured "JWT_EXPIRES_IN" setting, so signup tokens expire as expected instead of using the default lifetime. [#23576](https://github.com/open-webui/open-webui/pull/23576)
|
||||
- 🚪 **Channel access checks.** Channel actions now verify the current user when checking access, improving permission enforcement across channel views and message actions. [Commit](https://github.com/open-webui/open-webui/commit/4632f200a9ac98c915aee412b34e86c3d3c58bb1)
|
||||
- 📣 **Channel message lookups.** Channel message details and pinning now work more reliably when the sender account is missing, avoiding failures in those views. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9)
|
||||
- 📌 **Pinned webhook message handling.** Viewing pinned webhook messages now works reliably even when webhook profile data is missing, preventing server errors and frontend crashes in channel pinned message dialogs. [#23414](https://github.com/open-webui/open-webui/pull/23414)
|
||||
- 🛡️ **Note edit permission enforcement.** Note saving now requires write access instead of read access, preventing unauthorized users from modifying notes while preserving expected collaboration permissions. [Commit](https://github.com/open-webui/open-webui/commit/584a9a0920d8c8c72fc89ccbac83c970b5a4bd4a)
|
||||
- 🗂️ **Archived chats menu visibility.** The 'Archived Chats' option in the user menu is now shown reliably for all users, so non-admin accounts can consistently access archived conversations. [Commit](https://github.com/open-webui/open-webui/commit/07262fa62c2323fc7948389e5b5b8a5d1b72fade)
|
||||
- 💾 **Error message persistence.** LLM errors that occur during streaming are now saved to the database even if the connection drops, so users can see what went wrong when they reconnect. [#23231](https://github.com/open-webui/open-webui/pull/23231)
|
||||
- 🚫 **Missing message completion guard.** Chat completion finalization now skips invalid requests without a message identifier, preventing unnecessary error toasts caused by rare frontend concurrency timing. [#23184](https://github.com/open-webui/open-webui/pull/23184)
|
||||
- 🧠 **Active message completion accuracy.** Switching chats or refreshing during generation no longer marks the currently streaming assistant message as finished too early, so thinking blocks and action buttons appear at the correct time. [#23171](https://github.com/open-webui/open-webui/issues/23171)
|
||||
- 📞 **Call overlay visibility.** Incoming call events now open the call overlay and controls reliably, preventing cases where the call interface briefly appeared and then disappeared. [Commit](https://github.com/open-webui/open-webui/commit/ee9db91df02120e1e3651e8881734966b710ad52)
|
||||
- 💬 **Prompt submission handling.** Chat messages now preserve attached files more reliably when prompts are sent, including queued messages and shared prompt actions. [Commit](https://github.com/open-webui/open-webui/commit/6d6dfbf02c893d72d85d4490cb41f1665b1f9f95)
|
||||
- 🧾 **Prompt variable form saving.** Prompt variable forms now save reliably without runtime errors or an unresponsive save action, so input values and placeholders work correctly when applying prompt templates with variables. [#23225](https://github.com/open-webui/open-webui/issues/23225), [#23480](https://github.com/open-webui/open-webui/issues/23480)
|
||||
- 🛟 **Task model fallback safety.** Task routing now handles missing default model entries safely, preventing task execution failures when the previously selected model is no longer available. [#23169](https://github.com/open-webui/open-webui/pull/23169)
|
||||
- 📊 **Usage statistic preservation.** Follow-up generation no longer overwrites existing token usage fields, so stored usage statistics remain accurate for the main response. [#23152](https://github.com/open-webui/open-webui/issues/23152)
|
||||
- 📝 **Writing block parsing reliability.** ":::writing" blocks now parse more reliably when headers or extra inline text are present, preventing malformed rendering and duplicate output artifacts. [#23174](https://github.com/open-webui/open-webui/issues/23174)
|
||||
- 🧾 **Code block line break reliability.** Blank lines in submitted code blocks are now preserved more reliably instead of being collapsed. [Commit](https://github.com/open-webui/open-webui/commit/1be9627dd27ffe75957729a4a0d1682a98684f01), [#20302](https://github.com/open-webui/open-webui/issues/20302), [#23451](https://github.com/open-webui/open-webui/pull/23451)
|
||||
- ✂️ **Citation spacing cleanup.** When citations are disabled for a model, citation markers and their leftover spacing are now removed together so punctuation and copied text remain cleanly formatted. [#23141](https://github.com/open-webui/open-webui/issues/23141)
|
||||
- 🧰 **Pipe tool access.** Pipe functions now receive built-in and MCP tools in **tools**, so tools like Web Search and code execution are available when enabled. [#23365](https://github.com/open-webui/open-webui/issues/23365)
|
||||
- 📚 **Batch file processing database handling.** Batch knowledge file processing now consistently uses the active database session, preventing failures caused by missing database context during file ownership checks and update writes. [#23137](https://github.com/open-webui/open-webui/issues/23137)
|
||||
- ⚙️ **Default model parameter loading.** The "DEFAULT_MODEL_PARAMS" environment variable is now parsed and applied correctly, so default generation settings are honored reliably without being ignored at startup. [#23223](https://github.com/open-webui/open-webui/pull/23223)
|
||||
- 🔧 **Web search settings save reliability.** Saving web search configuration now works without server errors, so administrators can update "WEB_FETCH_MAX_CONTENT_LENGTH" and related retrieval settings successfully from the admin interface. [Commit](https://github.com/open-webui/open-webui/commit/36d02aa1477aa1b4e7fb59d022f99693ebfa8667), [#23127](https://github.com/open-webui/open-webui/issues/23127)
|
||||
- 🔍 **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#23488](https://github.com/open-webui/open-webui/pull/23488), [#23485](https://github.com/open-webui/open-webui/issues/23485)
|
||||
- 🖼️ **Open Terminal file response handling.** Open Terminal tool responses now preserve binary content types in user-side connections, so image and non-text file reads work consistently instead of being forced into plain text. [#23125](https://github.com/open-webui/open-webui/issues/23125)
|
||||
- 🖥️ **Terminal label casing.** Terminal names in the chat input now display exactly as stored instead of being automatically capitalized, so domain-style server names appear correctly. [#23518](https://github.com/open-webui/open-webui/pull/23518)
|
||||
- 🖼️ **Gravatar profile photo saving.** Gravatar profile images can now be saved successfully from account settings, with clearer validation and error handling instead of failing with generic object errors. [#23156](https://github.com/open-webui/open-webui/issues/23156)
|
||||
- 🪟 **Details expansion preference.** Tool call detail groups now honor the 'Always Expand Details' chat setting, so they open expanded by default when that preference is enabled. [#23262](https://github.com/open-webui/open-webui/pull/23262), [#23255](https://github.com/open-webui/open-webui/issues/23255)
|
||||
- 🖱️ **Rapid sidebar action protection.** Archive and delete actions in the chat sidebar now ignore repeated clicks while a request is in progress, preventing duplicate requests and stacked error toasts. [#23172](https://github.com/open-webui/open-webui/issues/23172)
|
||||
- 📲 **Mobile model selector positioning.** The mobile model selector dropdown now applies a constrained viewport width and left offset, preventing overflow and making model selection easier on small screens. [#23310](https://github.com/open-webui/open-webui/pull/23310)
|
||||
- 🔽 **Task list toggle icons.** The task list collapse button now shows the correct arrow direction, making task sections easier to expand and collapse at a glance. [Commit](https://github.com/open-webui/open-webui/commit/f66b67c8b86b6f9d896a23c7bb53907c2e6b15d3), [#23354](https://github.com/open-webui/open-webui/issues/23354)
|
||||
- ➕ **Attachment menu auto-close.** The chat attachment menu now closes immediately after selecting upload actions like file upload, camera capture, web attach, Google Drive, or OneDrive, preventing the menu from lingering on screen. [Commit](https://github.com/open-webui/open-webui/commit/4764dd5d3765c22384ed38cbc97a8170daa7a75f), [#23320](https://github.com/open-webui/open-webui/issues/23320)
|
||||
- 🧹 **Per-chat draft clearing.** Sent message drafts are now cleared using the active chat key, so sent text no longer reappears in the input after a refresh. [Commit](https://github.com/open-webui/open-webui/commit/124b7e9154d7f3ca8a16f2b90621209ac8d6b8c1), [#23296](https://github.com/open-webui/open-webui/issues/23296)
|
||||
- ✉️ **Context-aware input action button.** The input now shows the send action when text or files are present during generation, while keeping stop controls for truly empty input states to avoid action confusion. [Commit](https://github.com/open-webui/open-webui/commit/86472bb4453af7ea4e5ddc8d127b14d8e67733bc), [#23306](https://github.com/open-webui/open-webui/issues/23306)
|
||||
- 📉 **Pyodide prompt cache stability.** Pyodide code interpreter context is now appended to the system prompt instead of user messages, preserving stable prefix caching across turns and reducing repeated token costs in long native tool-calling chats. [#23269](https://github.com/open-webui/open-webui/issues/23269)
|
||||
- 🧪 **Temp chat outlet filtering.** Outlet filters now process temporary chats more reliably, preserving assistant output and usage data so local chat responses stay consistent when filter pipelines are enabled. [Commit](https://github.com/open-webui/open-webui/commit/70a6a24f143b221c787bc50b72582ee1e0c2dac0)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
|
||||
- 🧨 **Plugin async migration required.** Custom plugins for Tools, Functions, and Pipelines may require migration to the new async backend signatures after upgrading, so plugin maintainers should update handlers and database call patterns for compatibility and follow the 0.9.0 plugin migration guide. [Migration Guide](https://docs.openwebui.com/features/extensibility/plugin/migration/to-0.9.0)
|
||||
- 🔄 **Automation terminal source.** Automations now use the terminal configured on the selected model instead of a separate per-automation terminal picker, keeping terminal behavior consistent between chat and scheduled runs. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d)
|
||||
- 🚧 **OpenAI passthrough now opt-in.** Direct OpenAI catch-all proxy requests are now disabled by default and require enabling "ENABLE_OPENAI_API_PASSTHROUGH", so deployments relying on passthrough must explicitly turn it on after upgrading. [#23640](https://github.com/open-webui/open-webui/pull/23640)
|
||||
- 🗄️ **SQLite WAL default enabled.** SQLite deployments now default to enabling write-ahead logging, improving concurrent read and write behavior without requiring manual configuration. [Commit](https://github.com/open-webui/open-webui/commit/2f9e326dba3b1087932cb6b8075ed1881bd1c6d6)
|
||||
|
||||
## [0.8.12] - 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
- 🌐 **Translation updates.** Translations for Simplified Chinese, Catalan, Portuguese (Brazil), Finnish, and Lithuanian were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒 **Terminal server connection security.** Terminal server verification and policy saving now proxy through the backend, preventing API key exposure and CORS errors when connecting to in-cluster services. [Commit](https://github.com/open-webui/open-webui/commit/a6413257079a52fa4487eda36543f3955d0fbd53), [Commit](https://github.com/open-webui/open-webui/commit/4567cdc0d9cb7b42b6eba7b676c0ced3f4850d31)
|
||||
- 🛠️ **Terminal tools exception handling.** Exceptions in middleware.py due to invalid return values from get_terminal_tools() have been resolved. [Commit](https://github.com/open-webui/open-webui/commit/52a06bd48aff34fb2211aac2879f0cd028129267)
|
||||
- 📦 **Missing beautifulsoup4 dependency.** Users can now start Open WebUI using uvx without encountering the "bs4 module missing" error. [Commit](https://github.com/open-webui/open-webui/commit/1994d65306bbcc7406584e1bfef82f5d353fc91c)
|
||||
- 🔌 **API files list error.** The /api/v1/files/ endpoint no longer returns a 500 error, fixing a regression that prevented file listing via the API. [Commit](https://github.com/open-webui/open-webui/commit/11f52921dc21c2dc61c03f12bcdf6f19140a350c)
|
||||
- 📜 **License data loading.** License data now loads correctly, displaying the expected color and logo in the interface. [Commit](https://github.com/open-webui/open-webui/commit/16335f866ea4cedf00c4971963622fcc1fe02d82)
|
||||
- 👑 **Admin model visibility.** Administrators can now see models even when no access control is configured yet, allowing them to manage all available models. [Commit](https://github.com/open-webui/open-webui/commit/f3f8f9874f55282603c2650b91801640cb3f69cb)
|
||||
- 📊 **Tool call embed visibility.** Rich UI embeds from tool calls (like visualizations) are now rendered outside collapsed groups and remain visible without requiring manual expansion. [Commit](https://github.com/open-webui/open-webui/commit/4c872a8d128757d4a6f311fb86bc382af2ba5d0d), [Commit](https://github.com/open-webui/open-webui/commit/308fa924a5b2b7e08cd1e8f15b9c8c96e1de8f02)
|
||||
|
||||
## [0.8.11] - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- 🔀 **Responses API streaming improvements.** The OpenAI proxy now properly handles tool call streaming and re-invocations in the Responses API, preventing duplicate tool calls and preserving output during model re-invocations. [Commit](https://github.com/open-webui/open-webui/commit/93415a48e8893139db13d02d0a6d24e8604a2ac5), [Commit](https://github.com/open-webui/open-webui/commit/f8b3a32caf00dad76687fd8fe698b86f304f3997), [Commit](https://github.com/open-webui/open-webui/commit/2ae47cf20057e92a83fd618b938f3ee9bb124e5b), [Commit](https://github.com/open-webui/open-webui/commit/adcbba34f8bbfbab3e4041269a084f2b71c076d9)
|
||||
- 🔀 **Responses API stateful sessions.** Administrators can now enable experimental stateful session support via the ENABLE_RESPONSES_API_STATEFUL environment variable, allowing compatible backends to store responses server-side with previous_response_id anchoring for improved multi-turn conversations. [Commit](https://github.com/open-webui/open-webui/commit/dfc2dc2c0bd298cb4bfcf212ef11223586aa54f1)
|
||||
- 📄 **File viewing pagination.** The view_file and view_knowledge_file tools now support pagination with offset and max_chars parameters, allowing models to read large files in chunks. [Commit](https://github.com/open-webui/open-webui/commit/5d7766e1b6f7ca7749c5a5a780d7b1bb2da28a2f)
|
||||
- 🗺️ **Knowledge search scoping.** The search_knowledge_files tool now respects model-attached knowledge, searching only within attached knowledge bases and files when available. [Commit](https://github.com/open-webui/open-webui/commit/0f0ba7dadd043460d205477fd3b57556aa970847)
|
||||
- 🛠️ **Tool HTML embed context.** Tools can now return custom context alongside HTML embeds by using a tuple format, providing the LLM with actionable information instead of a generic message. [#22691](https://github.com/open-webui/open-webui/pull/22691)
|
||||
- 🔒 **Trusted role header configuration.** Administrators can now configure the WEBUI_AUTH_TRUSTED_ROLE_HEADER environment variable to set user roles (admin, user, or pending) via a trusted header from their identity provider or reverse proxy. [#22523](https://github.com/open-webui/open-webui/pull/22523)
|
||||
- 🔑 **OIDC authorization parameter injection.** Administrators can now inject extra parameters into the OIDC authorization redirect URL via the OAUTH_AUTHORIZE_PARAMS environment variable, enabling IdP pre-selection for brokers like CILogon and Keycloak. [#22863](https://github.com/open-webui/open-webui/issues/22863), [Commit](https://github.com/open-webui/open-webui/commit/69171a4c8bb7f995461b4a2feef194f112b32004)
|
||||
- 🔑 **Google OAuth session persistence.** Administrators can now configure Google OAuth to issue refresh tokens via the GOOGLE_OAUTH_AUTHORIZE_PARAMS environment variable, preventing OAuth sessions from expiring after one hour and ensuring tools and integrations that rely on OAuth tokens remain functional. [#22652](https://github.com/open-webui/open-webui/pull/22652)
|
||||
- 🔌 **Embed prompt confirmation.** Interactive tool embeds can now submit prompts to the chat without requiring same-origin access, showing a confirmation dialog for cross-origin requests to prevent abuse. [#22908](https://github.com/open-webui/open-webui/pull/22908)
|
||||
- 🏮 **Tool binary response handling.** Tool servers can now return binary data such as images, which are properly processed and displayed in chat for both multimodal and non-multimodal models. [Commit](https://github.com/open-webui/open-webui/commit/1c25b06dca83ad491b4dc3d373b1c215a7a8fd3e), [Commit](https://github.com/open-webui/open-webui/commit/108a019cb8e63a533250abe84f2b6f2b7c2131c4)
|
||||
- ⚡ **Svelte upgrade performance.** Page and markdown rendering are now approximately 25% faster across the board, with significantly less memory usage for smoother UI interactions. [#22611](https://github.com/open-webui/open-webui/issues/22611)
|
||||
- 🧩 **Model and filter lookup optimization.** Model and filter membership lookups are now faster thanks to optimized data structure operations during model list loading. [Commit](https://github.com/open-webui/open-webui/commit/7eae377c01f8d2de94a694b72279f769c82658cd)
|
||||
- 💨 **Chat render throttling.** Chat message rendering now uses requestAnimationFrame batching to stay smooth during rapid model responses, preventing dropped frames when fast models send many events per second. [#22947](https://github.com/open-webui/open-webui/pull/22947)
|
||||
- 🚀 **Function list API optimization.** The functions list API now returns only essential metadata without function source code, reducing payload sizes by over 99% and making the Functions admin page load significantly faster. [#22788](https://github.com/open-webui/open-webui/pull/22788)
|
||||
- ✨ **Smoother loading animation.** The loading shimmer animation now looks smoother and more natural, with softer highlight colors. [#22516](https://github.com/open-webui/open-webui/pull/22516)
|
||||
- 🧪 **Terminal connection verification.** Users can now verify their terminal server connection is working before saving the configuration, making setup more reliable. [#22567](https://github.com/open-webui/open-webui/pull/22567)
|
||||
- 📁 **Chat folder emoji reset.** Users can now reset chat folder emojis back to the default icon using a "Reset to Default" button in the emoji picker, making it easier to revert custom icons. [#22554](https://github.com/open-webui/open-webui/pull/22554)
|
||||
- 📊 **Metrics export interval configuration.** Administrators can now control OpenTelemetry metrics export frequency via the OTEL_METRICS_EXPORT_INTERVAL_MILLIS environment variable, enabling cost optimization for metrics services like Grafana Cloud. [#22529](https://github.com/open-webui/open-webui/pull/22529)
|
||||
- 🏥 **Readiness probe endpoint.** A new /ready endpoint is now available for Kubernetes deployments, returning 200 only after startup completes and database/Redis are reachable, enabling more reliable container orchestration. [#22507](https://github.com/open-webui/open-webui/pull/22507)
|
||||
- 🔩 **Tool server timeout configuration.** Administrators can now configure a separate HTTP timeout for tool server requests via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable, enabling fine-tuned control over how long tool calls are allowed to take. [Commit](https://github.com/open-webui/open-webui/commit/a3238aa79f344765f5b62cb64eba71ffd001abaf)
|
||||
- 📌 **Knowledge file previews.** Knowledge base files can now be opened in a new tab directly from the file list, making it easier to view content without downloading. [#22629](https://github.com/open-webui/open-webui/pull/22629)
|
||||
- 🎯 **Knowledge tool hybrid search support.** The built-in query_knowledge_files tool now respects hybrid search and reranking settings, matching the behavior of the middleware RAG pipeline. [Commit](https://github.com/open-webui/open-webui/commit/9a2c60d5954ecbc172d09e9955d52a07d135dcbc)
|
||||
- 🗣️ **Temporary chat folder support.** Temporary chats can now use folder-level system prompts and knowledge files, making them more powerful for quick explorations. [Commit](https://github.com/open-webui/open-webui/commit/adcc50d3370301afd5561e0f58ff6f3ab3750818)
|
||||
- 📡 **Terminal port previews.** Detected ports in the File Navigator can now be previewed inline with a browser-style view, navigation controls, and an address bar, instead of only opening in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/689061822173e561a153290b2bb816f4cb6f4959), [Commit](https://github.com/open-webui/open-webui/commit/1dc647f43b1929f5c4d1af393a90a47f56cb745e)
|
||||
- ✏️ **File renaming.** Files and folders in the File Navigator can now be renamed by double-clicking or using the context menu, with Enter to confirm and Escape to cancel. [Commit](https://github.com/open-webui/open-webui/commit/637cd136c2271baf4787815bc8bc25241626a943)
|
||||
- 🧭 **File Navigator navigation history.** The File Navigator toolbar now includes Back and Forward buttons for navigating through folder and file history, similar to a web browser. [Commit](https://github.com/open-webui/open-webui/commit/3a4b862e818c69fff6f3a3c67b50c51aa00c03e9)
|
||||
- 🗑️ **Delete connection confirmations.** Users are now prompted with a confirmation dialog before deleting connections, preventing accidental deletions. [Commit](https://github.com/open-webui/open-webui/commit/157ff57c40bc40c53bc608828dac3779e95c2ffa)
|
||||
- 📦 **Document loader fallbacks.** Excel and PowerPoint files can now be processed even when the unstructured package is not installed, using pandas and python-pptx as fallback loaders. [Commit](https://github.com/open-webui/open-webui/commit/6862d618ee17f95d3cae78819ed993e7fbc7e632)
|
||||
- 🧠 **Memory management search and sort.** Users can now search and sort their personal memories in the Memory management modal, making it easier to find specific memories. [Commit](https://github.com/open-webui/open-webui/commit/47ab4c71d50fd631b04c95f2febb085dd0a13083)
|
||||
- 📦 **SBOM generation script.** A new script for generating CycloneDX Software Bill of Materials is now available in the scripts directory. [Commit](https://github.com/open-webui/open-webui/commit/39100eca4915e4fe86a6912aa97dde86ed72e015)
|
||||
- ⚙️ **Ruff linter and formatter.** Added Ruff as the Python linter and formatter, replacing the black-based workflow for better code quality with near-instant execution. [#22576](https://github.com/open-webui/open-webui/pull/22576), [#22462](https://github.com/open-webui/open-webui/discussions/22462)
|
||||
- 🖥️ **Offline code formatting support.** The black formatter for Python code editing is now bundled locally in the Docker image, enabling code formatting to work in air-gapped deployments where client browsers cannot reach PyPI. Formatting failures no longer block saves, allowing code to be preserved even when offline. [#22509](https://github.com/open-webui/open-webui/issues/22509), [Commit](https://github.com/open-webui/open-webui/commit/8507e5eb0d18896f1bbf990a00a4361aec171a30)
|
||||
- ✏️ **Markdown file editing.** Users can now edit and save Markdown files directly in the file navigator, with empty files automatically switching to editor mode for immediate editing. [Commit](https://github.com/open-webui/open-webui/commit/47e47e42af682e7f75c8359999f7cdf969bf903e)
|
||||
- 🍔 **Model bulk actions menu.** Users can now quickly enable, disable, show, or hide multiple models at once using a new hamburger menu on the workspace Models page filter bar, with actions respecting the current search and filter settings. [#22484](https://github.com/open-webui/open-webui/pull/22484)
|
||||
- 📂 **Files list pagination.** The files list API now supports pagination, returning paginated results with a total count for easier navigation through large file collections. [Commit](https://github.com/open-webui/open-webui/commit/f9756de693a93e918c037d757afddb7defc847e4)
|
||||
- 🖇 **Web fetch content length config.** Administrators can now configure the maximum characters to return from fetched URLs via WEB_FETCH_MAX_CONTENT_LENGTH environment variable or the admin settings page, instead of the previous hardcoded 50K limit. [Commit](https://github.com/open-webui/open-webui/commit/b171b0216b916745420c7caf513093a315ed9560), [#22774](https://github.com/open-webui/open-webui/issues/22774)
|
||||
- 🤖 **Ollama Anthropic endpoint support.** The Ollama proxy now supports the Anthropic-compatible /v1/messages endpoint, allowing clients using the Anthropic API format to work through Open WebUI with proper authentication and model access controls. [Commit](https://github.com/open-webui/open-webui/commit/f23296b22d3304e5bfcd19151e5802eec55bd98f), [#22861](https://github.com/open-webui/open-webui/issues/22861)
|
||||
- 📝 **Writing block rendering.** Responses from OpenAI models that include :::writing blocks are now rendered as formatted content in a styled container with a copy button, instead of displaying raw marker text. [#22672](https://github.com/open-webui/open-webui/issues/22672), [Commit](https://github.com/open-webui/open-webui/commit/53b8a1f71bd0cb0a0122175ad5210da492018728)
|
||||
- 💡 **Memory deletion confirmation.** Users are now asked to confirm before deleting individual memory entries, with the memory content displayed for review. [#22888](https://github.com/open-webui/open-webui/pull/22888)
|
||||
- 📓 **Multi-artifact HTML rendering.** Code blocks with multiple HTML sections now render as separate artifacts instead of merging into one, allowing models to display distinct interactive components. [Commit](https://github.com/open-webui/open-webui/commit/9a6bf78e14a13864e72db87426da4f5996abe716)
|
||||
- 🚩 **Drag chats as references.** Users can now drag chats from the sidebar and drop them into the message input to add them as Reference Chats. [Commit](https://github.com/open-webui/open-webui/commit/ebb7ce2092efc8d78da4974623647dbd18b6e372)
|
||||
- ⌨️ **Terminal system prompts.** Terminal servers can now provide custom system prompts that are automatically included when their tools are used. [Commit](https://github.com/open-webui/open-webui/commit/6a9d67b5bb4c93fd343b334bee3e37703dff59f6)
|
||||
- 💾 **Terminal state persistence.** The selected terminal server and its enabled state now persist across page loads, making terminal usage more seamless. [Commit](https://github.com/open-webui/open-webui/commit/d577ff1e4af750dda09e558dac7edb8dd2470850)
|
||||
- 💾 **Terminal folder downloads.** Users can now download folders as ZIP archives and bulk-download multiple selected files as a single ZIP directly from the File Navigator toolbar, making file exports faster and more convenient. [Commit](https://github.com/open-webui/open-webui/commit/3841e85abb3ea3e8d8b364dff0102f0124844d22), [Commit](https://github.com/open-webui/open-webui/commit/cf60b1882f1929200649b59f867289dea54e4210)
|
||||
- 🔐 **MCP OAuth 2.1 static credentials.** MCP servers that require static client_id and client_secret can now be connected using a new OAuth 2.1 Static auth type, enabling integration with MCP servers that don't support dynamic client registration. [#22266](https://github.com/open-webui/open-webui/pull/22266), [Commit](https://github.com/open-webui/open-webui/commit/601bb783587a3e965cf88c148e4856b988655b13)
|
||||
- 🎪 **Collapsible tool and thinking groups.** Consecutive tool calls and reasoning blocks are now grouped into a single collapsible summary (e.g., "Explored tool1, tool2"), keeping chat responses clean and readable while preserving full detail on expand. [#21604](https://github.com/open-webui/open-webui/issues/21604), [Commit](https://github.com/open-webui/open-webui/commit/261aec8c864646eb7215be0d5c14a79cad3cb93f)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Finnish, Portuguese (Portugal), Catalan, Turkish, Japanese, Simplified Chinese, Traditional Chinese, Estonian, Spanish, Azerbaijani, and German were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒 **Model access control bypass.** Fixed a security vulnerability where external clients could bypass model access controls by setting a URL parameter, preventing unauthorized access to restricted models. [Commit](https://github.com/open-webui/open-webui/commit/c0385f60ba049da48d2d5452068586d375303c37)
|
||||
- 🛡️ **Terminal proxy path sanitization.** The terminal server proxy now properly sanitizes paths to prevent directory traversal and SSRF attacks, protecting against security vulnerabilities. [Commit](https://github.com/open-webui/open-webui/commit/f9d38a073fae32032ed44073cf2817cba20210bb)
|
||||
- 🛡️ **Tool configuration access control.** Tool configuration endpoints now properly verify user permissions, preventing unauthorized access to tool settings. [Commit](https://github.com/open-webui/open-webui/commit/bc5b3ec6b8ec0fef894eb8046c636ee33688b8c4)
|
||||
- 🗝️ **Tool valves access control.** The tool user valves endpoints now properly verify ownership and access grants before returning or updating configuration, with appropriate 404 responses for missing tools and 401 for unauthorized access. [Commit](https://github.com/open-webui/open-webui/commit/f949d17db1e62e0b79aecbbcbcabe3d57d8d4af6)
|
||||
- 🔐 **Collaborative document authorization.** Fixed a security vulnerability in collaborative documents where authorization could be bypassed using alternative document ID formats, preventing unauthorized access to notes. [Commit](https://github.com/open-webui/open-webui/commit/3107a5363d13c899a995c930cbb1121a80f754f9)
|
||||
- 🔏 **OAuth session persistence.** Users logging in via OAuth or OIDC providers now stay logged in for the configured JWT expiry duration instead of being logged out when closing the browser. [#22809](https://github.com/open-webui/open-webui/pull/22809)
|
||||
- 🚪 **OAuth sub claim configuration crash.** Using the OAUTH_SUB_CLAIM environment variable no longer causes crashes during token exchange requests, fixing a missing configuration registration. [#22865](https://github.com/open-webui/open-webui/pull/22865)
|
||||
- 🔍 **OAuth discovery header parsing.** The OAuth protected resource discovery now correctly handles both quoted and unquoted values in the WWW-Authenticate header, fixing compatibility with MCP servers that return unquoted metadata. [#22646](https://github.com/open-webui/open-webui/discussions/22646), [Commit](https://github.com/open-webui/open-webui/commit/fe7e002fea7283abcf901e22de5c8a7d86e336ea)
|
||||
- 👤 **Admin OAuth group sync.** Admin user group memberships from OAuth and LDAP providers are now properly synced to Open WebUI, fixing a limitation where admin role excluded users from group updates. [#22537](https://github.com/open-webui/open-webui/pull/22537), [Commit](https://github.com/open-webui/open-webui/commit/a1aceb5f879abd130ef83085d98a0d51316a8fc3)
|
||||
- 🎫 **Password change complexity validation.** Password complexity rules are now properly enforced when users change their password, closing a security gap where new passwords could bypass configured complexity requirements. [Commit](https://github.com/open-webui/open-webui/commit/bd8aa3b6a0b6a2320f41b20a51b9842f39aadb7f)
|
||||
- 🔏 **OAuth role enforcement.** OAuth role management now properly denies access when a user's roles don't match any configured OAUTH_ALLOWED_ROLES or OAUTH_ADMIN_ROLES, instead of silently bypassing the restriction. [#13676](https://github.com/open-webui/open-webui/issues/13676), [#15551](https://github.com/open-webui/open-webui/issues/15551), [Commit](https://github.com/open-webui/open-webui/commit/6d7744c21903ec5a9ad951770dea76e9ba19cbcc)
|
||||
- 🔑 **Microsoft Entra ID role claim preservation.** Role claims from Microsoft Entra ID tokens are now preserved during OAuth login, fixing ENABLE_OAUTH_ROLE_MANAGEMENT for Microsoft OAuth which was previously ignored because the userinfo endpoint stripped the roles claim. [#20518](https://github.com/open-webui/open-webui/issues/20518), [Commit](https://github.com/open-webui/open-webui/commit/aa2f7fbe5229c3985ce427602069cdeababda481)
|
||||
- 🔍 **SCIM group filtering.** The SCIM endpoint now properly handles displayName and externalId filters when provisioning groups from identity providers like Microsoft Entra ID, preventing all groups from being returned instead of the filtered subset. [#21543](https://github.com/open-webui/open-webui/pull/21543)
|
||||
- 🔐 **Forwarded allow IPs configuration.** The FORWARDED_ALLOW_IPS environment variable is now properly respected by the startup scripts instead of being hardcoded to '\*', allowing administrators to restrict which proxies are trusted for request forwarding. [#22539](https://github.com/open-webui/open-webui/issues/22539), [Commit](https://github.com/open-webui/open-webui/commit/0aebdd5f83cd1d811009edcbb2bec432a34e7c81)
|
||||
- 🍪 **Model list auth cookie forwarding.** Model list requests to backends that require cookie-based authentication now properly forward auth headers and cookies, preventing "Unauthorized" errors when loading models. [Commit](https://github.com/open-webui/open-webui/commit/76ece4049e96bd6890593f17a946a9af6b082fab)
|
||||
- 🔱 **Model lookup race condition.** Fixed a race condition in Redis model storage that caused intermittent "model not found" errors in multi-replica deployments under heavy load, by eliminating the window between hash deletion and updates. [Commit](https://github.com/open-webui/open-webui/commit/ee901fcd2ca82d7a7dad48170c64df782d3e040a)
|
||||
- 🎚️ **Bulk model action reliability.** Bulk enable, disable, show, and hide operations in the admin Models settings now properly refresh the model list after completion, ensuring changes are reflected immediately and correct toast notifications are shown. [#22962](https://github.com/open-webui/open-webui/pull/22962), [Commit](https://github.com/open-webui/open-webui/commit/75932be880f3b86f78f00b4352b9f1350b8f53fa), [Commit](https://github.com/open-webui/open-webui/commit/15ae3f588b1aa4ddb686ae68afebd6064036a201)
|
||||
- 🔄 **Paginated list duplicates.** Fixed duplicate items appearing in paginated lists when loading more items in chats, knowledge, notes, and search across the UI. [Commit](https://github.com/open-webui/open-webui/commit/58e78e8946fb3644107489fe8e01b17709302b2f)
|
||||
- 🧽 **Duplicate chat list refresh.** Sending messages no longer triggers duplicate sidebar chat list refreshes, eliminating an unnecessary database query that was already handled by the save and completion handlers. [#22982](https://github.com/open-webui/open-webui/pull/22982)
|
||||
- 🧹 **Chat history save optimization.** The chat list is no longer refreshed on every chat history save, branch navigation, or edit — only on meaningful state changes like new chat creation, title generation, and response completion. [#22983](https://github.com/open-webui/open-webui/pull/22983)
|
||||
- 💬 **Message queue responsiveness.** The message queue no longer waits for background tasks like title generation and follow-up suggestions to complete, allowing users to send new messages immediately after a response finishes without unnecessary delays. [Commit](https://github.com/open-webui/open-webui/commit/486c004cbb43f15d5c3e31561f51f22effff1f6c), [#22565](https://github.com/open-webui/open-webui/issues/22565)
|
||||
- 🗄️ **Migration reliability.** Database migrations no longer fail when chat data has unexpected format, making upgrades more reliable. [#22588](https://github.com/open-webui/open-webui/pull/22588), [#22568](https://github.com/open-webui/open-webui/issues/22568)
|
||||
- 🫧 **Memory modal event bubbling.** Fixed an issue where clicking the Delete button in the Memory management modal would also open the Edit Memory modal due to event bubbling. [#22783](https://github.com/open-webui/open-webui/issues/22783)
|
||||
- 🧩 **Memory tool registration.** Models with capabilities.memory: true now correctly have memory tools available for execution, fixing a retry loop where add_memory appeared in the tool schema but was not registered for backend execution. [#22666](https://github.com/open-webui/open-webui/issues/22666), [#22675](https://github.com/open-webui/open-webui/pull/22675), [Commit](https://github.com/open-webui/open-webui/commit/d9339919046c3e977f313f603782d220aab4257f)
|
||||
- 📝 **Input variables modal crash.** Fixed a crash that occurred when selecting custom prompts with prompt variables, causing the Input Variables modal to display an infinite loading spinner instead of the variable input fields. [#22748](https://github.com/open-webui/open-webui/issues/22748), [Commit](https://github.com/open-webui/open-webui/commit/0dcd6ac983bede06b8477179192154467f5b24a2)
|
||||
- 🪛 **Function list API crash fix.** Fixed a 500 error on the functions list API endpoint that was introduced by the recent optimization, by adding proper model configuration for SQLAlchemy ORM objects. [#22924](https://github.com/open-webui/open-webui/pull/22924)
|
||||
- 🗂️ **Sidebar chat menu closure.** Sidebar chat dropdown menus now close properly after clicking "Clone", "Share", "Download", "Rename", "Pin", "Move", "Archive", or "Delete", instead of remaining visible. [#22884](https://github.com/open-webui/open-webui/pull/22884), [#22784](https://github.com/open-webui/open-webui/issues/22784)
|
||||
- 🧭 **Chat deletion and archive redirection.** Users are now redirected to the chat list when deleting or archiving the currently active chat, instead of being left on a stale chat page. [#22755](https://github.com/open-webui/open-webui/pull/22755)
|
||||
- 🚩 **User menu navigation fix.** Clicking Playground or Admin Panel from the user menu now uses client-side routing instead of causing full page reloads, restoring smooth SPA navigation. [Commit](https://github.com/open-webui/open-webui/commit/7ffcd3908ee90f88a4c4684d6cd6e75efd117461)
|
||||
- 🔧 **Tool server connection persistence.** Fixed a bug where tool server connection updates were not being saved to persistent storage, ensuring OAuth client information is now properly preserved. [Commit](https://github.com/open-webui/open-webui/commit/b8ea267f8ec3931de55db7801156b9c07d3ad5f6)
|
||||
- 🔩 **Tool server index bounds checking.** Tool servers with invalid indices no longer crash the application with IndexError after upgrades, preventing tool server configuration loss. [#22490](https://github.com/open-webui/open-webui/issues/22490), [Commit](https://github.com/open-webui/open-webui/commit/8da29566a1f81c38e80009bdea3ce4d9be860605)
|
||||
- 🔌 **Tool server frontend timeout.** Fetch requests to external tool servers now time out after 10 seconds, preventing the UI from hanging indefinitely when a configured tool server is unreachable. [#22543](https://github.com/open-webui/open-webui/issues/22543), [Commit](https://github.com/open-webui/open-webui/commit/adf7af34ff934319a35470c572237d2d08f1de0b)
|
||||
- 🔌 **MCP OAuth tool auto-selection.** MCP tools requiring OAuth authentication are now automatically re-selected after completing the auth flow, instead of leaving users to manually re-enable the tool on return to the chat. [#22994](https://github.com/open-webui/open-webui/issues/22994), [#22995](https://github.com/open-webui/open-webui/pull/22995), [Commit](https://github.com/open-webui/open-webui/commit/4d50001c4192c609b1010626ebb6496692823873)
|
||||
- 🏷️ **Channel @mentions.** Direct connection models no longer appear in channel @mention suggestions, preventing confusion since they don't work in channels. [#22553](https://github.com/open-webui/open-webui/issues/22553), [Commit](https://github.com/open-webui/open-webui/commit/0a87c1ecd078320a08c4cc62d41fe8727fb3b5f7)
|
||||
- 📎 **Channel message attachments.** Users can now press Enter to send messages with only file or image attachments in channels, direct messages, and threads, aligning with the behavior of the Send button. [#22752](https://github.com/open-webui/open-webui/pull/22752)
|
||||
- 🗣️ **Image-only message handling.** Models like Gemini and Claude no longer fail when receiving messages with only file or image attachments and no text, by stripping empty text content blocks before sending to the API. [Commit](https://github.com/open-webui/open-webui/commit/ea515fa26e11faac146c48a5e3a2a284e1792bb3), [#22880](https://github.com/open-webui/open-webui/issues/22880)
|
||||
- 🧹 **Channel thread sidebar cleanup.** The thread sidebar in channels and direct messages now automatically closes when the parent message is deleted, preventing orphaned threads. [#22890](https://github.com/open-webui/open-webui/pull/22890)
|
||||
- 💡 **Chat input suggestion modal.** The suggestion modal for tags, mentions, and commands now correctly reappears when backspacing into a trigger character after it was dismissed. [#22899](https://github.com/open-webui/open-webui/pull/22899)
|
||||
- ⏱️ **Chat action button timing.** Action buttons under assistant messages no longer appear prematurely when switching chats while a response is still streaming. [Commit](https://github.com/open-webui/open-webui/commit/ecba37070d6eb3cb033195a070b6c4ab5f396415), [#22891](https://github.com/open-webui/open-webui/issues/22891)
|
||||
- 💬 **Skill and model mention persistence.** Skills selected via $ and models selected via @ in the chat input are now properly restored after a page refresh, instead of reverting to plain text while losing their interactive state. [#22913](https://github.com/open-webui/open-webui/issues/22913), [Commit](https://github.com/open-webui/open-webui/commit/be21db706993c0db95ac09509dfdb023de64daff)
|
||||
- 🧹 **Webhook profile image errors.** Fixed 404 errors appearing in the browser console when scrolling through channel messages sent by webhooks, by skipping the user profile preview for webhook senders. [#22893](https://github.com/open-webui/open-webui/pull/22893)
|
||||
- 🧮 **Logit bias parameter handling.** Using logit_bias parameters no longer causes errors when the input is already in dictionary format. [#22597](https://github.com/open-webui/open-webui/issues/22597), [Commit](https://github.com/open-webui/open-webui/commit/e34ed72e1e958505e940b74bf1c6a4808640bd17)
|
||||
- 🪛 **Temp chat tool calling.** Temporary chats now properly preserve tool call information, fixing native tool calling with JSON schema that was previously broken. [#22475](https://github.com/open-webui/open-webui/pull/22475), [Commit](https://github.com/open-webui/open-webui/commit/bcd313c363ca50d71aa80bcb2f29c81fad3dff37)
|
||||
- 🔗 **Multi-system message merging.** Models with strict chat templates like Qwen no longer fail when multiple pipeline stages inject separate system messages, as all system messages are now merged into one at the start. [#22505](https://github.com/open-webui/open-webui/issues/22505), [Commit](https://github.com/open-webui/open-webui/commit/631bd20c3537ce85bbaec02f9e0049c88fa8fdd4)
|
||||
- 📜 **Public note access.** Opening public notes via direct share link no longer returns a 500 error caused by a missing function import. [#22680](https://github.com/open-webui/open-webui/issues/22680), [Commit](https://github.com/open-webui/open-webui/commit/566e25569e5e7d9c1e42db840ba4ba578887d208)
|
||||
- 👤 **Terminal access user visibility.** The terminal connection access dialog now shows the currently logged-in user when searching for users to grant access, fixing an issue where users with identical display names were filtered incorrectly. [#22491](https://github.com/open-webui/open-webui/issues/22491), [Commit](https://github.com/open-webui/open-webui/commit/4a8f995c3fd4602ec2aaccc07efc4e8504dda84d)
|
||||
- 👥 **User groups display.** User groups in the admin panel profile preview now wrap properly instead of overflowing horizontally, with a scrollbar when the list is long. [#22547](https://github.com/open-webui/open-webui/pull/22547)
|
||||
- 🔧 **Model list drag-and-drop.** Fixed drag-and-drop reordering of models in admin settings, preventing UI glitches and state synchronization issues. [Commit](https://github.com/open-webui/open-webui/commit/753589e51ccbbe5c4f78a7d13e19c67e6c0000d7)
|
||||
- 🖼️ **Model profile image fallbacks.** Model profile images now display a fallback icon when they fail to load, and model icons no longer disappear on paginated Models pages in admin and workspace settings. [#22485](https://github.com/open-webui/open-webui/pull/22485)
|
||||
- 🖼️ **Profile image fallbacks.** Added fallback handlers for model and user profile images throughout the chat interface, preventing broken image icons when avatars fail to load. [#22486](https://github.com/open-webui/open-webui/pull/22486)
|
||||
- 🧲 **RAG thinking model support.** Knowledge base queries now correctly parse JSON responses from thinking models like GLM-5 and DeepSeek-R1 by stripping their reasoning blocks before JSON extraction. [#22400](https://github.com/open-webui/open-webui/pull/22400)
|
||||
- 🔍 **RAG query generation robustness.** The RAG query generation, web search, and image generation handlers now correctly extract JSON from model responses containing thinking tags by finding the last JSON block instead of the first, preventing "No sources found" errors with thinking models. [#21888](https://github.com/open-webui/open-webui/issues/21888), [Commit](https://github.com/open-webui/open-webui/commit/c0fcbc5b4cb29012e2913983c632edc5d24b9aea)
|
||||
- 🔍 **Ollama embedding robustness.** Ollama embedding requests now include the truncate parameter to handle inputs exceeding the context window, preventing 500 errors when processing long documents. Error messages from failed embedding requests are also now properly surfaced instead of being silently swallowed. [#22671](https://github.com/open-webui/open-webui/issues/22671), [Commit](https://github.com/open-webui/open-webui/commit/d738044f47c70c755bec9bf244aa11878fe98d9c)
|
||||
- 🔄 **Ollama embedding retry logic.** Embedding requests to Ollama now retry with exponential backoff when encountering 503 errors (such as when the model reloads mid-processing), preventing files from being silently dropped from knowledge bases. [#22571](https://github.com/open-webui/open-webui/issues/22571), [Commit](https://github.com/open-webui/open-webui/commit/8b6fa1f4ab6099a305de08706621075c205f65c4)
|
||||
- 🗄️ **Oracle 23AI hybrid search.** Fixed an UnboundLocalError that occurred when using hybrid search with Oracle 23AI as the vector store, preventing knowledge base queries from failing. [Commit](https://github.com/open-webui/open-webui/commit/fcf720835285a4cea10fc1ebed0b454971463b20), [#22616](https://github.com/open-webui/open-webui/issues/22616)
|
||||
- 🌐 **Dynamic HTML language attribute.** The HTML lang attribute now dynamically updates when users change their interface language, preventing browsers from triggering unwanted translation popups. [Commit](https://github.com/open-webui/open-webui/commit/de5e0fbc00e7abcd84e1272c301b0707f8ea5ac6)
|
||||
- 📐 **File upload deduplication.** Attaching files that are already in the chat no longer triggers duplicate uploads. [Commit](https://github.com/open-webui/open-webui/commit/10f06a64fed474e9958b96295a953e0eebf9e4be)
|
||||
- 🕵️ **Serper.dev search results.** Fixed web search results not displaying properly when using the Serper.dev provider by using the correct API response field. [#22869](https://github.com/open-webui/open-webui/pull/22869)
|
||||
- 🔲 **Markdown task list checkbox styling.** Fixed task list checkboxes in markdown rendering to display consistently without shrinking in narrow layouts. [#22886](https://github.com/open-webui/open-webui/pull/22886)
|
||||
- 🎨 **Artifacts sidebar tab background fix.** The Artifacts sidebar now correctly updates and displays when switching back to a browser tab that was in the background, ensuring artifacts are visible without requiring a manual refresh. [#22889](https://github.com/open-webui/open-webui/issues/22889)
|
||||
- 🔃 **Chat input URL indexing fix.** Fixed an issue where URLs could be indexed twice when using multiple triggers followed by backspace and re-entering a URL. [#22749](https://github.com/open-webui/open-webui/issues/22749)
|
||||
- 🔎 **Search modal chat preview avatars.** Fixed assistant profile images not displaying in the chat preview pane of the Search Modal. [#22782](https://github.com/open-webui/open-webui/pull/22782)
|
||||
- 📋 **Prompts search pagination fix.** Fixed a bug where searching prompts from a paginated page would incorrectly use the current page number, resulting in "No prompts found" even when matching results existed. [#22912](https://github.com/open-webui/open-webui/pull/22912)
|
||||
- 🗂️ **Reasoning block copy cleanup.** Copied chat responses no longer include reasoning block content or excess whitespace, ensuring only the intended message text is captured. [#22786](https://github.com/open-webui/open-webui/issues/22786), [Commit](https://github.com/open-webui/open-webui/commit/4f0e57420154800946394bc986b2c691462b2782)
|
||||
- 🔤 **Emoji removal for text normalization.** Fixed the emoji removal function used in search and title generation to correctly handle all emoji types, including those with variation selectors (❤️, ☀️, ✅), keycap sequences (1️⃣), and ZWJ family sequences (👨👩👧👦). [#22915](https://github.com/open-webui/open-webui/pull/22915)
|
||||
- ⏹️ **Task cancellation status tracking.** Cancelled tasks now correctly mark only the affected messages as done instead of clearing all task statuses for the chat, ensuring proper status tracking when multiple messages have pending tasks. [#22743](https://github.com/open-webui/open-webui/pull/22743)
|
||||
- 🎨 **Filter icon display fix.** Fixed filter icons showing the wrong icon after removing one of multiple active filters below the chat input. [#22862](https://github.com/open-webui/open-webui/pull/22862)
|
||||
- 📊 **Channel message data loading.** Fixed redundant 404 API calls that occurred when rendering channel messages, preventing unnecessary requests and console errors. [#22894](https://github.com/open-webui/open-webui/pull/22894)
|
||||
- 👻 **Response message skeleton display.** Fixed an issue where the skeleton loader would incorrectly show or hide based on complex status history conditions, by extracting the visibility logic into a cleaner reactive variable. [Commit](https://github.com/open-webui/open-webui/commit/5df4277216fbb9de603fdf4289f8366292568234)
|
||||
- 🐛 **Shared chat viewing crash.** Shared chats can now be viewed by unauthenticated users without crashing, with proper fallback handling for missing user profile information. [#22751](https://github.com/open-webui/open-webui/pull/22751), [#22742](https://github.com/open-webui/open-webui/issues/22742)
|
||||
- 🛠️ **Plugin ID sanitization.** Creating Functions or Tools with emojis or special characters in their names now generates valid IDs that pass backend validation, instead of failing with an error. [#22695](https://github.com/open-webui/open-webui/pull/22695)
|
||||
- 📋 **Chat title preservation.** Regenerating responses or using branches no longer overwrites user-specified chat titles when auto-naming is disabled, by checking the full chat message count instead of just the current branch. [#22754](https://github.com/open-webui/open-webui/pull/22754)
|
||||
- 🎧 **Read Aloud in chat preview.** The Read Aloud button in the Search Chats modal preview no longer causes crashes, and TTS functionality is now properly hidden in read-only chat contexts. [Commit](https://github.com/open-webui/open-webui/commit/d8fa0f426a88f5c27b3216b7db35e1db47bbba28)
|
||||
- 📡 **Heartbeat event loop blocking.** The WebSocket heartbeat handler no longer blocks the event loop when updating user activity, improving responsiveness under heavy load with many concurrent connections. [#22980](https://github.com/open-webui/open-webui/pull/22980)
|
||||
- 🗝️ **Message upsert API reliability.** The message upsert API endpoint no longer crashes when called, fixing an error where a database session was incorrectly passed to a function that doesn't accept it. [#22959](https://github.com/open-webui/open-webui/issues/22959), [Commit](https://github.com/open-webui/open-webui/commit/70285fb6cad26b50d783583b68be5227ace16055)
|
||||
- 🔓 **Forward auth proxy compatibility.** Fixed error pages that could appear when using authenticating reverse-proxies by properly handling 401 responses from background API requests, allowing the browser to re-authenticate with the identity provider. [#22942](https://github.com/open-webui/open-webui/pull/22942)
|
||||
- 🔃 **Tool call streaming display.** Sequential tool calls are now properly accumulated during streaming, fixing an issue where completed tool calls could disappear from the display before the next tool call finished streaming. [Commit](https://github.com/open-webui/open-webui/commit/a9c5c787b9f6b10491924d38645042064b3c941e)
|
||||
- 🧠 **Reasoning spinner content preservation.** Prior assistant content and tool call blocks no longer disappear during the reasoning spinner when responding after tool execution. [#23001](https://github.com/open-webui/open-webui/pull/23001)
|
||||
- 🖥️ **Pyodide file list refresh.** Files created or modified during manual code execution now appear immediately in the pyodide files list without requiring a browser tab refresh. [Commit](https://github.com/open-webui/open-webui/commit/5c4062c64841974bf193ff321d92d10f28a09746)
|
||||
- 🖱️ **Dropdown submenu hover stability.** Secondary hover menus like Download and Move now remain open while navigating into them, fixing an issue where an 8px gap between the trigger and submenu would cause the menu to disappear before a selection could be made. [#22744](https://github.com/open-webui/open-webui/issues/22744), [Commit](https://github.com/open-webui/open-webui/commit/cffbc3558e911abd6c4780cd028794b2f7282cd7)
|
||||
- 📊 **Model tag normalization.** Model tags from backends that return them as string arrays are now properly normalized to object format, preventing crashes when filtering models by tag in the admin and workspace models pages. [#20819](https://github.com/open-webui/open-webui/issues/20819), [Commit](https://github.com/open-webui/open-webui/commit/90ca2e9b0f15cc9be7cf298fbefacaa45074cae9)
|
||||
- 🎯 **Arena model sub-model settings.** Arena models now properly use the selected sub-model's settings — including RAG knowledge bases, web access, code interpreter, and tool capabilities — instead of the arena wrapper's empty defaults. [#16950](https://github.com/open-webui/open-webui/issues/16950), [Commit](https://github.com/open-webui/open-webui/commit/857d7e6f373d26a7a8989417c3a7fe99cdc03f20)
|
||||
- 🧩 **Model editor default metadata.** The Model Editor now loads admin-configured default model metadata instead of hardcoded values, preventing admin defaults from being silently overwritten when users save models without realizing they were overriding system-wide settings. [#22996](https://github.com/open-webui/open-webui/issues/22996), [Commit](https://github.com/open-webui/open-webui/commit/cdc2b3bf850044051aafcd46f22fb25a1899788c)
|
||||
- ✏️ **Rich text paste sanitization.** Copying and pasting text with HTML characters (like `<` or `>`) no longer corrupts the editor content, as the paste handler now properly escapes HTML entities before processing mentions and special syntax. [Commit](https://github.com/open-webui/open-webui/commit/94f877ff328d410339308ad2c566c9afcdf43014)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🪝 **User webhooks disabled by default.** User webhook notifications are now disabled by default and properly gated by the ENABLE_USER_WEBHOOKS configuration, ensuring webhooks only fire when explicitly enabled. [Commit](https://github.com/open-webui/open-webui/commit/c24a4da17dbaddf47e2e0f865c1d602d0ff36ee6)
|
||||
- 🧩 **MCP integration visibility.** MCP (Streamable HTTP) integrations are now hidden from user-level settings, matching the intended behavior where only administrators can configure MCP connections through the admin panel. User-level connections now show the connection type as read-only. [#22615](https://github.com/open-webui/open-webui/issues/22615), [Commit](https://github.com/open-webui/open-webui/commit/1eef5b4f6a718c0fcf3605f1ed62669aca07b454)
|
||||
- 🧲 **Web search result limit.** The configured web search result count now acts as a maximum limit, preventing models from requesting more results than administrators allow. [#22577](https://github.com/open-webui/open-webui/pull/22577)
|
||||
|
||||
## [0.8.10] - 2026-03-08
|
||||
|
||||
### Added
|
||||
|
||||
- 🔐 **Custom OIDC logout endpoint.** Administrators can now configure a custom OpenID Connect logout URL via OPENID_END_SESSION_ENDPOINT, enabling logout functionality for OIDC providers that require custom endpoints like AWS Cognito. [Commit](https://github.com/open-webui/open-webui/commit/3f350f865920daf2844769a758b2d2e6a7ee3efa)
|
||||
- 🗄️ **MariaDB Vector community support.** Added MariaDB Vector as a new vector database backend, enabling deployments with VECTOR_DB=mariadb-vector; supports cosine and euclidean distance strategies with configurable HNSW indexing. [#21931](https://github.com/open-webui/open-webui/pull/21931)
|
||||
- 📝 **Task message truncation.** Chat messages sent to task models for title and tag generation can now be truncated using a filter in the prompt template, reducing token usage and processing time for long conversations. [#21499](https://github.com/open-webui/open-webui/issues/21499)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Spanish, and Malay were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔗 **Pipeline filter HTTP errors.** Fixed a bug where HTTP errors in pipeline inlet/outlet filters would silently corrupt the user's chat payload; errors are now properly raised before parsing the response. [#22445](https://github.com/open-webui/open-webui/pull/22445)
|
||||
- 📚 **Knowledge file embedding updates.** Fixed a bug where updating knowledge files left old embeddings in the database, causing search results to include duplicate and stale data. [#20558](https://github.com/open-webui/open-webui/issues/20558)
|
||||
- 📁 **Files list stability.** Fixed the files list ordering to use created_at with id as secondary sort, ensuring consistent ordering and preventing page crashes when managing many files. [#21879](https://github.com/open-webui/open-webui/issues/21879)
|
||||
- 📨 **Teams webhook crash.** Fixed a TypeError crash in the Teams webhook handler when user data is missing from the event payload. [#22444](https://github.com/open-webui/open-webui/pull/22444)
|
||||
- 🛠️ **Process shutdown handling.** Fixed bare except clauses in the main process that prevented clean shutdown; replaced with proper exception handling. [#22423](https://github.com/open-webui/open-webui/pull/22423)
|
||||
- 🐳 **Docker deployment startup.** Docker deployments now start correctly; the missing OpenTelemetry system metrics dependency was added. [#22447](https://github.com/open-webui/open-webui/pull/22447), [#22401](https://github.com/open-webui/open-webui/issues/22401)
|
||||
- 🛠️ **Tool access for non-admin users.** Fixed a NameError that prevented non-admin users from viewing tools; the missing has_access function is now properly imported. [#22393](https://github.com/open-webui/open-webui/issues/22393)
|
||||
- 🔐 **OAuth error handling.** Fixed a bug where bare except clauses silently caught SystemExit and KeyboardInterrupt, preventing clean process shutdown during OAuth authentication. [#22420](https://github.com/open-webui/open-webui/pull/22420)
|
||||
- 🛠️ **Exception error messages.** Fixed three locations where incorrect exception raising caused confusing TypeError messages instead of proper error descriptions, making debugging much easier. [#22446](https://github.com/open-webui/open-webui/pull/22446)
|
||||
- 📄 **YAML file processing.** Fixed an error when uploading YAML files with Docling enabled; YAML and YML files are now properly recognized as text files and processed correctly. [#22399](https://github.com/open-webui/open-webui/pull/22399), [#22263](https://github.com/open-webui/open-webui/issues/22263)
|
||||
- 📅 **Time range month names.** Fixed month names in time range labels appearing in the wrong language when OS regional settings differ from browser language; month names now consistently display in English. [#22454](https://github.com/open-webui/open-webui/pull/22454)
|
||||
- 🔐 **OAuth error URL encoding.** Fixed OAuth error messages with special characters causing malformed redirect URLs; error messages are now properly URL-encoded. [#22415](https://github.com/open-webui/open-webui/pull/22415)
|
||||
- 🛠️ **Internal tool method filtering.** Tools no longer expose internal methods starting with underscore to the LLM, reducing clutter and improving accuracy. [#22408](https://github.com/open-webui/open-webui/pull/22408)
|
||||
- 🔊 **Azure TTS locale extraction.** Fixed Azure text-to-speech using incomplete locale codes in SSML; now correctly uses full locale like "en-US" instead of just "en". [#22443](https://github.com/open-webui/open-webui/pull/22443)
|
||||
- 🎤 **Azure speech transcription errors.** Improved Azure AI Speech error handling to display user-friendly messages instead of generic connection errors; empty transcripts, no language identified, and other Azure-specific errors now show clear descriptions. [#20485](https://github.com/open-webui/open-webui/issues/20485)
|
||||
- 📊 **Analytics group filtering.** Fixed token usage analytics not being filtered by user group; the query now properly respects group filters like other analytics metrics. [#22167](https://github.com/open-webui/open-webui/pull/22167)
|
||||
- 🔍 **Web search favicon fallback.** Fixed web search sources showing broken image icons when favicons couldn't be loaded from external sources; now falls back to the default Open WebUI favicon. [#21897](https://github.com/open-webui/open-webui/pull/21897)
|
||||
- 🔄 **Custom model fallback.** Fixed custom model fallback not working when the base model is unavailable; the base model ID is now correctly retrieved from model info instead of empty params. [#22456](https://github.com/open-webui/open-webui/issues/22456)
|
||||
- 🖼️ **Pending message image display.** Fixed images in queued messages appearing blank; image thumbnails are now properly displayed in the pending message queue. [#22256](https://github.com/open-webui/open-webui/issues/22256)
|
||||
- 🛠️ **File metadata sanitization.** Fixed file uploads failing with JSON serialization errors when metadata contained non-serializable objects like callable functions; metadata is now sanitized before database insertion. [#20561](https://github.com/open-webui/open-webui/issues/20561)
|
||||
|
||||
## [0.8.9] - 2026-03-07
|
||||
|
||||
### Added
|
||||
|
||||
- ▶️ **Open Terminal notebook cell execution.** Users can now run Jupyter Notebook code cells directly in the Open Terminal file navigator, execute entire notebooks with a single click, edit and modify cells before running, and control the kernel - bringing full interactive notebook execution to the browser. [Commit](https://github.com/open-webui/open-webui/commit/4b3ed3e802d6f2ec8ee7caf358af810b7d09f789)
|
||||
- 🗃️ **Open Terminal SQLite browser.** Users can now browse SQLite database files directly in the Open Terminal file navigator, viewing tables and running queries without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/a181b4a731a9ec7856be08d0b045a454d1341cf4)
|
||||
- 📉 **Open Terminal Mermaid diagram rendering.** Markdown files with Mermaid code blocks are now rendered as diagrams directly in the Open Terminal file navigator, making it easier to visualize flowcharts and other diagrams. [Commit](https://github.com/open-webui/open-webui/commit/aaa49bdd6d6e5c10e8be554039d3cac673008fc2)
|
||||
- 📓 **Open Terminal Jupyter Notebook previews.** Users can now preview Jupyter Notebook files directly in the Open Terminal file navigator, making it easier to view notebook content without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/b081e33c0a37585a1ee60b6e0e1ea03457f1e5f4)
|
||||
- 🔃 **Open Terminal auto-refresh.** The Open Terminal file navigator now automatically refreshes when the model writes or modifies files, keeping the view in sync without manual refresh. [Commit](https://github.com/open-webui/open-webui/commit/828656b35f04bf486609183799cf8aa2e9850a76)
|
||||
- 📎 **Open Terminal file copy button.** Users can now copy file contents directly to clipboard in the Open Terminal file navigator with a single click, making it easier to quickly grab file content without downloading. [Commit](https://github.com/open-webui/open-webui/commit/f5ea1ce250cb02fbc583c6cb3f52a923912d0178)
|
||||
- 💻 **Code syntax highlighting and XLSX improvements in Open Terminal.** Code files now display with syntax highlighting in the Open Terminal file navigator, and XLSX spreadsheets now show column headers and row numbers for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/f962bae98306ea9264967b78b803397f4821f9b0)
|
||||
- 🌳 **Open Terminal JSON tree view.** JSON, JSONC, JSONL, and JSON5 files now display as interactive collapsible tree views in the Open Terminal file navigator, and SVG files render as preview images with syntax highlighting support. [Commit](https://github.com/open-webui/open-webui/commit/f4c38e6001dd9d4853ed923e0bc5e790c4fd9941)
|
||||
- 🛜 **Open Terminal port viewing.** Users can now view listening ports in the Open Terminal file navigator and open proxy connections to them directly from the UI. [Commit](https://github.com/open-webui/open-webui/commit/e08341dab3bb10e26a64eb44cbebd2d507087b03)
|
||||
- 🎬 **Open Terminal video previews.** Users can now preview video and audio files directly in the Open Terminal file navigator, making it easier to view media without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/c40f26946f2eaeb1587a1f8b0c643b4a5121fc06)
|
||||
- ✏️ **Open Terminal HTML editing.** Users can now edit HTML source files in Open Terminal with CodeMirror editor, and the save button is properly hidden in preview mode. [Commit](https://github.com/open-webui/open-webui/commit/7806cd5aef9fb0505b2c642ef70599a403cf14ba)
|
||||
- 📄 **Open Terminal DOCX preview.** Word documents generated or modified by the AI can now be viewed directly in the file navigator with formatted text, tables, and images rendered inline — no need to download and open in a separate application. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
|
||||
- 📊 **Open Terminal XLSX preview.** Excel spreadsheets in the file navigator now render as interactive tables with column headers and row numbers, making it easy to verify data the AI has generated or processed. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
|
||||
- 📽️ **Open Terminal PPTX preview.** PowerPoint presentations created by the AI can now be viewed slide-by-slide directly in the file navigator, enabling quick review and iteration without leaving the browser. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
|
||||
- 📁 **Pyodide file system support.** Users can now upload files for Python code execution in the code interpreter. Uploaded files are available in the `/mnt/uploads/` directory, and code can write output files there for download. The file system persists across code executions within the same session. The code interpreter now also informs models that pip install is not available in the Pyodide environment, guiding them to use alternative approaches with available modules. [#3583](https://github.com/open-webui/open-webui/issues/3583), [Commit](https://github.com/open-webui/open-webui/commit/ce0ca894fea8a2904bc6f832ff186d5fe53dd0b9), [Commit](https://github.com/open-webui/open-webui/commit/989938856fdb4b4afa584ae2d18c88d5be614ae2)
|
||||
- 🧰 **Tool files access.** Tools can now access the files from the current chat context via the files property in their metadata, enabling more powerful tool integrations. [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0)
|
||||
- ⚡ **Chat performance.** Chat messages now load and display significantly faster thanks to optimized markdown rendering, eliminating delays when viewing messages with mathematical expressions. [#22196](https://github.com/open-webui/open-webui/pull/22196), [#20878](https://github.com/open-webui/open-webui/discussions/20878)
|
||||
- 📜 **Message list performance.** Improved message list rendering performance by optimizing array operations, reducing complexity from O(n²) to O(n). [#22280](https://github.com/open-webui/open-webui/pull/22280)
|
||||
- 🧵 **Streaming markdown performance.** Improved chat responsiveness during streaming by skipping unnecessary markdown re-parsing when the content hasn't changed, eliminating wasted processing during model pauses. [#22183](https://github.com/open-webui/open-webui/pull/22183)
|
||||
- 🏃 **Chat streaming performance.** Chat streaming is now faster for users not using the voice call feature by skipping unnecessary text parsing that was running on every token. [#22195](https://github.com/open-webui/open-webui/pull/22195)
|
||||
- 🔖 **Source list performance.** Source lists in chat now render faster thanks to optimized computation that avoids unnecessary recalculations, including moving sourceIds computation to a reactive variable. [#22279](https://github.com/open-webui/open-webui/pull/22279), [Commit](https://github.com/open-webui/open-webui/commit/88af78c), [Commit](https://github.com/open-webui/open-webui/commit/339ed1d72e100c89d8eb26de761dfefe842ef90c)
|
||||
- 💨 **Chat message tree operations.** Chat message tree operations are now significantly faster, improving overall chat responsiveness. [#22194](https://github.com/open-webui/open-webui/pull/22194)
|
||||
- 🚀 **Initial page load speed.** Page load is now significantly faster thanks to deferred loading of the syntax highlighting library, reducing the initial JavaScript bundle by several megabytes. [#22304](https://github.com/open-webui/open-webui/pull/22304)
|
||||
- 🗓️ **Action priority query optimization.** Improved performance of action priority resolution by fixing an N+1 query pattern, reducing database round-trips when loading model actions. [#22301](https://github.com/open-webui/open-webui/pull/22301)
|
||||
- 🔑 **API key middleware optimization.** The API key restriction middleware was converted to a pure ASGI middleware for improved streaming performance, removing per-chunk call overhead. [#22188](https://github.com/open-webui/open-webui/pull/22188)
|
||||
- 🏎️ **Model list loading performance.** Model lists now load significantly faster thanks to optimized custom model matching that uses dictionary lookups instead of nested loops. [#22299](https://github.com/open-webui/open-webui/pull/22299), [Commit](https://github.com/open-webui/open-webui/commit/29160741a3defa8768a43100cb6e63c56400279c), [Commit](https://github.com/open-webui/open-webui/commit/03c6caac1fc8625f85cf1164f5a977be8005c1bc)
|
||||
- ⏱️ **Event call timeout configuration.** Administrators can now configure the WebSocket event call timeout via the WEBSOCKET_EVENT_CALLER_TIMEOUT environment variable, giving users more time to respond to event_call forms instead of timing out after 60 seconds. [#22222](https://github.com/open-webui/open-webui/pull/22222), [#22220](https://github.com/open-webui/open-webui/issues/22220)
|
||||
- 🔁 **File refresh button visibility.** The refresh button in the chat file navigator now appears when viewing files as well as directories, allowing users to refresh the file view at any time. [Commit](https://github.com/open-webui/open-webui/commit/49a2e5bf573415dae6d4c7e5bd635e499c8de77a)
|
||||
- 📂 **Nested folders support.** Users can now create subfolders within parent folders, improving organization of chats. A new "Create Subfolder" option is available in the folder context menu. [#22073](https://github.com/open-webui/open-webui/pull/22073), [Commit](https://github.com/open-webui/open-webui/commit/8913f37c3d8fde7dea6d54a550357f1d495b3941)
|
||||
- 🔔 **Banner loading on navigation.** Admin-configured banners now load when navigating to the homepage, not just on page refresh, ensuring users see new banners immediately. [#22340](https://github.com/open-webui/open-webui/pull/22340), [#22180](https://github.com/open-webui/open-webui/issues/22180)
|
||||
- 📡 **System metrics via OpenTelemetry.** Administrators can now monitor Python runtime and system metrics including CPU, memory, garbage collection, and thread counts through the existing OpenTelemetry pipeline. [#22265](https://github.com/open-webui/open-webui/pull/22265)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for French, Finnish, Turkish, German, Simplified Chinese, and Traditional Chinese were enhanced and expanded.
|
||||
- 🔍 **Web search tool guidance.** The web search tool description was updated to encourage direct usage without first checking knowledge bases, making it clearer for users who want to search the web immediately. [#22264](https://github.com/open-webui/open-webui/pull/22264)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🗄️ **Migration memory usage.** Database migration on large deployments now processes messages in batches instead of loading everything into memory, preventing out-of-memory errors during upgrades. [#21542](https://github.com/open-webui/open-webui/pull/21542), [#21539](https://github.com/open-webui/open-webui/discussions/21539)
|
||||
- 🔒 **SQLCipher connection stability.** Fixed a crash that occurred when using database encryption with SQLCipher by changing the default connection pool behavior, ensuring stable operation during multi-threaded operations like user signup. [#22273](https://github.com/open-webui/open-webui/pull/22273), [#22258](https://github.com/open-webui/open-webui/issues/22258)
|
||||
- 🛑 **Stop sequence error.** Fixed a bug where setting stop sequences on a model caused the chat to fail with a split error, preventing any responses from being returned. The fix handles both string and array formats for stop tokens. [#22251](https://github.com/open-webui/open-webui/issues/22251), [Commit](https://github.com/open-webui/open-webui/commit/c7d1d1e390a79c6c86d4bfe439fd7de6f5fb060f)
|
||||
- 🔐 **Microsoft OAuth refresh token fix.** Fixed a bug where Microsoft OAuth refresh token requests failed with error AADSTS90009 by adding support for the required scope parameter. Users can now stay logged in reliably with Microsoft OAuth. [#22359](https://github.com/open-webui/open-webui/pull/22359)
|
||||
- 🛠️ **Parameterless tool calls.** Fixed parameterless tool calls failing during streaming by correcting the default arguments initialization, eliminating unnecessary model retries. [#22189](https://github.com/open-webui/open-webui/pull/22189)
|
||||
- 🔧 **Tool call streaming fixes.** Fixed two bugs where streaming tool calls failed silently for models like GPT-5: function names were incorrectly duplicated when sent in multiple delta chunks, and arguments containing multiple JSON objects were not properly split. Tools now execute correctly instead of failing without explanation. [#22177](https://github.com/open-webui/open-webui/issues/22177), [Commit](https://github.com/open-webui/open-webui/commit/d7efdcce2b1cdbe1637a469294bf9d52dbacab53), [Commit](https://github.com/open-webui/open-webui/commit/459a60a24240eab33441ed50f4f68cc27e65a037)
|
||||
- 🔗 **Tool server URL trailing slash.** Fixed tool server connection failures when URLs have trailing slashes by stripping them before path concatenation. Previously, URLs like "http://host:8080/v1/" + "/openapi.json" produced double-slash URLs that some servers rejected. [#22116](https://github.com/open-webui/open-webui/pull/22116), [#21917](https://github.com/open-webui/open-webui/issues/21917)
|
||||
- 🛡️ **Citation parser error handling.** Fixed crashes when tools return error strings instead of expected data structures by adding type guards to the citation parser. The system now returns an empty source list instead of crashing with AttributeError. [#22118](https://github.com/open-webui/open-webui/pull/22118)
|
||||
- 🧠 **Artifacts memory leak.** Fixed a memory leak where Svelte store subscriptions in the Artifacts component were not properly cleaned up when the component unmounted, causing memory to accumulate over time. [#22303](https://github.com/open-webui/open-webui/pull/22303)
|
||||
- ♾️ **Artifacts reactive loop fix.** Fixed an infinite reactive loop in chat when artifacts are present by moving the animation frame logic outside the reactive block, preventing continuous re-rendering and CPU usage. [#22238](https://github.com/open-webui/open-webui/pull/22238), [Commit](https://github.com/open-webui/open-webui/commit/626fcff417afba642f4f71e0498267a21435c524)
|
||||
- 🔀 **Artifact navigation.** Artifact navigation via arrow buttons now works correctly; the selected artifact is no longer reset when content updates. [#22239](https://github.com/open-webui/open-webui/pull/22239)
|
||||
- 🧩 **Artifact thinking block fix.** Fixed a bug where HTML preview rendered code blocks inside thinking blocks for certain models like Mistral and Z.ai, causing stray code with ">" symbols to appear before the actual artifact. The fix strips thinking blocks before extracting code for artifact rendering. [#22267](https://github.com/open-webui/open-webui/issues/22267), [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0)
|
||||
- 💬 **Floating Quick Actions availability.** Fixed an issue where the "Ask" and "Explain" Floating Quick Actions were missing when selecting text in chats that used a model that is no longer available. [#22149](https://github.com/open-webui/open-webui/pull/22149), [#22139](https://github.com/open-webui/open-webui/issues/22139)
|
||||
- 💡 **Follow-up suggestions.** Fixed follow-up suggestions not appearing by correcting contradictory format instructions in the prompt template, ensuring the LLM returns the correct JSON object format. [#22212](https://github.com/open-webui/open-webui/pull/22212)
|
||||
- 🔊 **TTS thinking content.** Fixed TTS playback reading think tags instead of skipping them by handling edge cases where code blocks inside thinking content prevented proper tag removal. [#22237](https://github.com/open-webui/open-webui/pull/22237), [#22197](https://github.com/open-webui/open-webui/issues/22197)
|
||||
- 🎨 **Button spinner alignment.** Button spinners across multiple modals now align correctly and stay on the same line as the button text, fixing layout issues when loading states are displayed. [#22227](https://github.com/open-webui/open-webui/pull/22227)
|
||||
- 📶 **Terminal keepalive.** Terminal connections now stay active without being closed by idle timeouts from proxies or load balancers, and spurious disconnection messages no longer appear. [Commit](https://github.com/open-webui/open-webui/commit/ca2aaf0321c219d041e92e2c0c842a4e424732ef)
|
||||
- 📥 **Chat archive handler.** The archive button in the chat navbar now actually archives the chat and refreshes the chat list, instead of doing nothing. [#22229](https://github.com/open-webui/open-webui/pull/22229)
|
||||
- 🐍 **BeautifulSoup4 dependency.** Added the missing BeautifulSoup4 package to backend requirements, fixing failures when using features that depend on HTML parsing. [#22231](https://github.com/open-webui/open-webui/pull/22231)
|
||||
- 👥 **Group users default sort.** Group members in the admin panel now sort by last active time by default instead of creation date, making it easier to find active users. [#22211](https://github.com/open-webui/open-webui/pull/22211)
|
||||
- 🔓 **Tool access permissions.** Users can now change tool and skill access permissions from private to public without errors. [#22325](https://github.com/open-webui/open-webui/pull/22325), [#22324](https://github.com/open-webui/open-webui/issues/22324)
|
||||
- 🖥️ **Open Terminal permission fix.** Open Terminal is now visible without requiring "Allow Speech to Text" permission, fixing an issue where users without microphone access couldn't access the terminal feature. [#22374](https://github.com/open-webui/open-webui/issues/22374), [Commit](https://github.com/open-webui/open-webui/commit/70a31a9a57bdd0690ac270f31ebd1b46e8fdfa98)
|
||||
- 📌 **Stale pinned models cleanup.** Pinned models that are deleted or hidden are now automatically unpinned, keeping your pinned models list up to date. [Commit](https://github.com/open-webui/open-webui/commit/af4500e5040c8343d339cd88dd1d2fb6138c7a72)
|
||||
- 📏 **OpenTelemetry metric descriptions.** Fixed conflicting metric instrument descriptions that caused warnings in the OpenTelemetry collector, resulting in cleaner telemetry logs for administrators. [#22293](https://github.com/open-webui/open-webui/pull/22293)
|
||||
- 🔢 **Non-streaming token tracking.** Token usage from non-streaming chat responses is now correctly saved to the database, fixing missing token counts in the Admin Panel analytics. Previously, non-streaming responses saved NULL usage data, causing messages to be excluded from token aggregation queries. [#22166](https://github.com/open-webui/open-webui/pull/22166)
|
||||
- ⌨️ **Inline code typing.** Fixed a bug where typing inline code with backticks incorrectly deleted the character immediately before the opening backtick, so text formatted as inline code now correctly produces the full word instead of missing the last character. [#20417](https://github.com/open-webui/open-webui/issues/20417), [Commit](https://github.com/open-webui/open-webui/commit/e303c3da3b174da9e92a79b174f85ba574ca06ef)
|
||||
- 📝 **Variable input newlines.** Fixed a bug where variables containing newlines were not displayed correctly in chat messages, and input values from Windows systems are now properly normalized to use standard line endings. [#21447](https://github.com/open-webui/open-webui/issues/21447), [Commit](https://github.com/open-webui/open-webui/commit/7b2f597b30c77ef300d1966e1c6a3edfdb0c465d)
|
||||
- 📷 **Android photo capture.** Fixed an issue where the first photo taken in chat appeared completely black on some Android devices by using an alternative canvas export method. [#22317](https://github.com/open-webui/open-webui/pull/22317)
|
||||
- 🪟 **Open Terminal Windows path fix.** Fixed a bug where navigating back to parent directories on Windows added an incorrect leading slash, causing directory loads to fail. Paths are now properly normalized for Windows drive letters. [#22352](https://github.com/open-webui/open-webui/issues/22352), [Commit](https://github.com/open-webui/open-webui/commit/044fd1bd15cae06a5c56a321ca79d8362942f66a)
|
||||
- 🖼️ **Chat overview profile image sizing.** Fixed a bug where profile images in the chat overview could shrink incorrectly in tight spaces. The images now maintain their proper size with the flex-shrink-0 property. [#22261](https://github.com/open-webui/open-webui/pull/22261)
|
||||
- 📨 **Queued messages display.** Fixed an issue where queued messages could be cut off or hidden. The queued messages area now scrolls properly when content exceeds the visible area, showing up to 25% of the viewport height. [#22176](https://github.com/open-webui/open-webui/pull/22176)
|
||||
- 🖌️ **Image generation in temporary chats.** Generated images now display correctly in temporary chat mode when using builtin image generation tools. Previously, images were not shown because the code was overwriting the image list with a null database response. [#22330](https://github.com/open-webui/open-webui/pull/22330), [#22309](https://github.com/open-webui/open-webui/issues/22309)
|
||||
- 🤖 **Ollama model unload fix.** Fixed a bug where unloading a model from Ollama via the Open WebUI proxy failed with a "Field required" error for the prompt field. The proxy now correctly allows omitting the prompt when using keep_alive: 0 to unload models. [#22260](https://github.com/open-webui/open-webui/issues/22260), [Commit](https://github.com/open-webui/open-webui/commit/95b65ff751f91131b633cb128ff2decdd87c4a85)
|
||||
- 🏷️ **Banner type dropdown fix.** Fixed a bug where selecting a banner type required two clicks to register, as the first selection was being swallowed due to DOM structure changes. The dropdown now works correctly on the first click. [#22378](https://github.com/open-webui/open-webui/pull/22378)
|
||||
- 📈 **Analytics URL encoding fix.** Fixed a bug where the Analytics page failed to load data for models with slashes in their ID, such as "anthropic/claude-opus-4.6". The frontend now properly URL-encodes forward slashes, allowing model analytics to load correctly. [#22380](https://github.com/open-webui/open-webui/issues/22380), [#22382](https://github.com/open-webui/open-webui/pull/22382)
|
||||
- 📋 **Analytics chat list duplicate fix.** Fixed a bug where the Analytics page chat list threw an "each_key_duplicate" Svelte error when chat IDs were duplicated during pagination. The fix adds deterministic ordering to prevent duplicate entries. [#22383](https://github.com/open-webui/open-webui/pull/22383)
|
||||
- 📂 **Folder knowledge base native tool call fix.** Fixed a bug where folders with attached knowledge bases were querying the knowledge base twice when using native tool call mode. The fix now correctly separates knowledge files from regular attachments, letting the builtin query_knowledge_files tool handle knowledge searches instead of duplicating RAG queries. [#22236](https://github.com/open-webui/open-webui/issues/22236), [Commit](https://github.com/open-webui/open-webui/commit/967b1137dcb7a52615f17d086ee89095bb9b60f3), [Commit](https://github.com/open-webui/open-webui/commit/80b5896b70d07ea868e2010b187430d43c9808f0)
|
||||
|
||||
## [0.8.8] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- 📁 **Open Terminal file moving.** Users can now move files and folders between directories in the Open Terminal file browser by dragging and dropping them. [Commit](https://github.com/open-webui/open-webui/commit/0c42cd2c012f9f49816adac897e2b46573b3cb6c), [Commit](https://github.com/open-webui/open-webui/commit/72951324dfeef64e09f4776898d675bc1c44f040), [Commit](https://github.com/open-webui/open-webui/commit/395098c6f1b7499d37ad55145a5931431d3e72e9), [Commit](https://github.com/open-webui/open-webui/commit/11487d66fc1a2dfafbdaa2b7ef939a86caaf3872)
|
||||
- 📄 **Open Terminal HTML file preview.** Users can now preview HTML files directly in the Open Terminal file browser, with a rendered iframe view and source toggle, enabling iterative AI editing of HTML files. [Commit](https://github.com/open-webui/open-webui/commit/3909b62ffcf49839fa57346ed8487ae759811503), [Commit](https://github.com/open-webui/open-webui/commit/933a3bbbd3f4fc3eeb0ec52c7965e9ac1c4cea39)
|
||||
- 🌐 **Open Terminal WebSocket proxy.** Added a new WebSocket proxy endpoint for interactive terminal sessions, enabling real-time bidirectional terminal communication with the terminal server. [Commit](https://github.com/open-webui/open-webui/commit/4f6cb771f1afded09aad6199cdb244dd8a6c77a6)
|
||||
- ⚙️ **Open Terminal feature toggle.** Administrators can now enable or disable the Interactive Terminal feature for Open Terminal via configuration on the terminal server, controlling access to terminal routes. [Commit](https://github.com/open-webui/open-webui/commit/b5c3395f79bcc7ff5bc1d82bb86a60583bb3b5bd)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Simplified Chinese, Traditional Chinese, Irish, and Catalan were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔧 **Middleware variable shadowing.** Fixed a variable shadowing issue in the middleware that could cause incorrect tool output processing during chat. [#22145](https://github.com/open-webui/open-webui/pull/22145)
|
||||
- ⚡ **ChatControls reactivity fix.** Fixed a Svelte reactivity issue where the active tab state in the ChatControls panel was not properly saved when switching between chats. [#22127](https://github.com/open-webui/open-webui/pull/22127)
|
||||
- 🔧 **ChatControls TypeScript fix.** Fixed a TypeScript syntax error in ChatControls.svelte where the module script block was missing lang="ts", causing esbuild to fail during vite dev. [#22131](https://github.com/open-webui/open-webui/pull/22131)
|
||||
- 🔌 **Open Terminal tools for direct connections.** Fixed an issue where Open Terminal tools were not available to the model when the terminal was configured via direct connection settings, ensuring users can now interact with terminal files and operations through the AI. [#22137](https://github.com/open-webui/open-webui/issues/22137)
|
||||
- 📜 **Chat history pagination.** Fixed an issue where older messages in long chats were not loaded when scrolling to the top. [Commit](https://github.com/open-webui/open-webui/commit/d7147d6cddfd314f0f1be77b15cec406a609ef36), [Commit](https://github.com/open-webui/open-webui/commit/c701ebe07bd152eecb42b0bf6de26071358a5c76)
|
||||
- 🔧 **Terminal tool null parameter handling.** Fixed a bug where null parameters in terminal tool calls were sent as the string "None" instead of being omitted, causing 422 validation errors from the open-terminal server. [#22124](https://github.com/open-webui/open-webui/issues/22124), [#22144](https://github.com/open-webui/open-webui/pull/22144)
|
||||
|
||||
### Changed
|
||||
|
||||
## [0.8.7] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
@@ -3838,7 +4685,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
|
||||
|
||||
- Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
|
||||
- When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
|
||||
|
||||
|
||||
+16
-11
@@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
|
||||
RUN npm run build
|
||||
|
||||
######## WebUI backend ########
|
||||
FROM python:3.11.14-slim-bookworm AS base
|
||||
FROM python:3.11-slim-bookworm AS base
|
||||
|
||||
# Use args
|
||||
ARG USE_CUDA
|
||||
@@ -127,6 +127,7 @@ RUN chown -R $UID:$GID /app $HOME
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||
libmariadb-dev \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -134,29 +135,33 @@ RUN apt-get update && \
|
||||
# install python dependencies
|
||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
|
||||
RUN pip3 install --no-cache-dir uv && \
|
||||
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
RUN set -e; \
|
||||
pip3 install --no-cache-dir uv; \
|
||||
if [ "$USE_CUDA" = "true" ]; then \
|
||||
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
||||
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir; \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir; \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')"; \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
else \
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir; \
|
||||
if [ "$USE_SLIM" != "true" ]; then \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')"; \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
fi; \
|
||||
fi; \
|
||||
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
|
||||
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
# Install Ollama if requested
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
Open WebUI License
|
||||
|
||||
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
|
||||
All rights reserved.
|
||||
|
||||
@@ -15,11 +17,27 @@ modification, are permitted provided that the following conditions are met:
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
|
||||
4. Notwithstanding any other provision of this License, and as a material
|
||||
condition of the rights granted herein, licensees are strictly prohibited
|
||||
from altering, removing, obscuring, or replacing any "Open WebUI"
|
||||
branding, including but not limited to the name, logo, or any visual,
|
||||
textual, or symbolic identifiers that distinguish the software and its
|
||||
interfaces, in any deployment or distribution, except in the following
|
||||
circumstances: (i) deployments or distributions where the total number
|
||||
of end users (defined as individual natural persons with direct access
|
||||
to the application) does not exceed fifty (50) within any rolling
|
||||
thirty (30) day period; (ii) the licensee has obtained specific prior
|
||||
written permission from the copyright holder; or (iii) where the
|
||||
licensee has obtained a duly executed enterprise license expressly
|
||||
permitting such modification. For all other cases, any removal or
|
||||
alteration of the "Open WebUI" branding shall constitute a material
|
||||
breach of license.
|
||||
|
||||
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
|
||||
Materials governed by prior licenses retain those original license
|
||||
terms, as specified in LICENSE_HISTORY.
|
||||
|
||||
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
|
||||
By contributing to this project, you agree to the project's Contributor
|
||||
License Agreement (CONTRIBUTOR_LICENSE_AGREEMENT).
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
|
||||
@@ -47,7 +47,7 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
||||
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
|
||||
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||
|
||||
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
|
||||
|
||||
@@ -172,8 +172,6 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
|
||||
|
||||
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
|
||||
|
||||
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s).
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||
PORT="${PORT:-8080}"
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
import uvicorn
|
||||
from typing import Optional
|
||||
from typing_extensions import Annotated
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
KEY_FILE = Path.cwd() / ".webui_secret_key"
|
||||
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
def version_callback(value: bool) -> None:
|
||||
if value:
|
||||
from open_webui.env import VERSION
|
||||
|
||||
typer.echo(f"Open WebUI version: {VERSION}")
|
||||
typer.echo(f'Open WebUI version: {VERSION}')
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
version: Annotated[
|
||||
Optional[bool], typer.Option("--version", callback=version_callback)
|
||||
] = None,
|
||||
version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: str = "0.0.0.0",
|
||||
host: str = '0.0.0.0',
|
||||
port: int = 8080,
|
||||
):
|
||||
os.environ["FROM_INIT_PY"] = "true"
|
||||
if os.getenv("WEBUI_SECRET_KEY") is None:
|
||||
typer.echo(
|
||||
"Loading WEBUI_SECRET_KEY from file, not provided as an environment variable."
|
||||
)
|
||||
os.environ['FROM_INIT_PY'] = 'true'
|
||||
if os.getenv('WEBUI_SECRET_KEY') is None:
|
||||
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
|
||||
if not KEY_FILE.exists():
|
||||
typer.echo(f"Generating a new secret key and saving it to {KEY_FILE}")
|
||||
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
|
||||
typer.echo(f"Loading WEBUI_SECRET_KEY from {KEY_FILE}")
|
||||
os.environ["WEBUI_SECRET_KEY"] = KEY_FILE.read_text()
|
||||
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
|
||||
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
|
||||
|
||||
if os.getenv("USE_CUDA_DOCKER", "false") == "true":
|
||||
typer.echo(
|
||||
"CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries."
|
||||
)
|
||||
LD_LIBRARY_PATH = os.getenv("LD_LIBRARY_PATH", "").split(":")
|
||||
os.environ["LD_LIBRARY_PATH"] = ":".join(
|
||||
if os.getenv('USE_CUDA_DOCKER', 'false') == 'true':
|
||||
typer.echo('CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries.')
|
||||
LD_LIBRARY_PATH = os.getenv('LD_LIBRARY_PATH', '').split(':')
|
||||
os.environ['LD_LIBRARY_PATH'] = ':'.join(
|
||||
LD_LIBRARY_PATH
|
||||
+ [
|
||||
"/usr/local/lib/python3.11/site-packages/torch/lib",
|
||||
"/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib",
|
||||
'/usr/local/lib/python3.11/site-packages/torch/lib',
|
||||
'/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib',
|
||||
]
|
||||
)
|
||||
try:
|
||||
import torch
|
||||
|
||||
assert torch.cuda.is_available(), "CUDA not available"
|
||||
typer.echo("CUDA seems to be working")
|
||||
assert torch.cuda.is_available(), 'CUDA not available'
|
||||
typer.echo('CUDA seems to be working')
|
||||
except Exception as e:
|
||||
typer.echo(
|
||||
"Error when testing CUDA but USE_CUDA_DOCKER is true. "
|
||||
"Resetting USE_CUDA_DOCKER to false and removing "
|
||||
f"LD_LIBRARY_PATH modifications: {e}"
|
||||
'Error when testing CUDA but USE_CUDA_DOCKER is true. '
|
||||
'Resetting USE_CUDA_DOCKER to false and removing '
|
||||
f'LD_LIBRARY_PATH modifications: {e}'
|
||||
)
|
||||
os.environ["USE_CUDA_DOCKER"] = "false"
|
||||
os.environ["LD_LIBRARY_PATH"] = ":".join(LD_LIBRARY_PATH)
|
||||
os.environ['USE_CUDA_DOCKER'] = 'false'
|
||||
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
|
||||
|
||||
import open_webui.main # we need set environment variables before importing main
|
||||
import open_webui.main # noqa: F401
|
||||
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||
|
||||
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
|
||||
# which is incompatible with psycopg v3 async. Setting loop='none' lets
|
||||
# asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py.
|
||||
loop = 'none' if sys.platform == 'win32' else 'auto'
|
||||
|
||||
uvicorn.run(
|
||||
"open_webui.main:app",
|
||||
'open_webui.main:app',
|
||||
host=host,
|
||||
port=port,
|
||||
forwarded_allow_ips="*",
|
||||
forwarded_allow_ips='*',
|
||||
workers=UVICORN_WORKERS,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def dev(
|
||||
host: str = "0.0.0.0",
|
||||
host: str = '0.0.0.0',
|
||||
port: int = 8080,
|
||||
reload: bool = True,
|
||||
):
|
||||
uvicorn.run(
|
||||
"open_webui.main:app",
|
||||
'open_webui.main:app',
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
forwarded_allow_ips="*",
|
||||
forwarded_allow_ips='*',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
app()
|
||||
|
||||
+2983
-3143
File diff suppressed because it is too large
Load Diff
@@ -1,126 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MESSAGES(str, Enum):
|
||||
DEFAULT = lambda msg="": f"{msg if msg else ''}"
|
||||
MODEL_ADDED = lambda model="": f"The model '{model}' has been added successfully."
|
||||
MODEL_DELETED = (
|
||||
lambda model="": f"The model '{model}' has been deleted successfully."
|
||||
)
|
||||
DEFAULT = lambda msg='': f'{msg if msg else ""}'
|
||||
MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully."
|
||||
MODEL_DELETED = lambda model='': f"The model '{model}' has been deleted successfully."
|
||||
|
||||
|
||||
class WEBHOOK_MESSAGES(str, Enum):
|
||||
DEFAULT = lambda msg="": f"{msg if msg else ''}"
|
||||
USER_SIGNUP = lambda username="": (
|
||||
f"New user signed up: {username}" if username else "New user signed up"
|
||||
)
|
||||
DEFAULT = lambda msg='': f'{msg if msg else ""}'
|
||||
USER_SIGNUP = lambda username='': f'New user signed up: {username}' if username else 'New user signed up'
|
||||
|
||||
|
||||
class ERROR_MESSAGES(str, Enum):
|
||||
def __str__(self) -> str:
|
||||
return super().__str__()
|
||||
|
||||
DEFAULT = (
|
||||
lambda err="": f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
|
||||
DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
|
||||
ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.'
|
||||
CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.'
|
||||
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
|
||||
EMAIL_MISMATCH = 'Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again.'
|
||||
EMAIL_TAKEN = 'Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew.'
|
||||
USERNAME_TAKEN = 'Uh-oh! This username is already registered. Please choose another username.'
|
||||
PASSWORD_TOO_LONG = (
|
||||
'Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long.'
|
||||
)
|
||||
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
|
||||
CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance."
|
||||
DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot."
|
||||
EMAIL_MISMATCH = "Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again."
|
||||
EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew."
|
||||
USERNAME_TAKEN = (
|
||||
"Uh-oh! This username is already registered. Please choose another username."
|
||||
)
|
||||
PASSWORD_TOO_LONG = "Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long."
|
||||
COMMAND_TAKEN = "Uh-oh! This command is already registered. Please choose another command string."
|
||||
FILE_EXISTS = "Uh-oh! This file is already registered. Please choose another file."
|
||||
COMMAND_TAKEN = 'Uh-oh! This command is already registered. Please choose another command string.'
|
||||
FILE_EXISTS = 'Uh-oh! This file is already registered. Please choose another file.'
|
||||
|
||||
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."
|
||||
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."
|
||||
)
|
||||
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
|
||||
INVALID_TOKEN = 'Your session has expired or the token is invalid. Please sign in again.'
|
||||
INVALID_CRED = 'The email or password provided is incorrect. Please check for typos and try logging in again.'
|
||||
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
|
||||
INCORRECT_PASSWORD = (
|
||||
"The password provided is incorrect. Please check for typos and try again."
|
||||
INCORRECT_PASSWORD = 'The password provided is incorrect. Please check for typos and try again.'
|
||||
INVALID_TRUSTED_HEADER = (
|
||||
'Your provider has not provided a trusted header. Please contact your administrator for assistance.'
|
||||
)
|
||||
INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance."
|
||||
|
||||
EXISTING_USERS = "You can't turn off authentication because there are existing users. If you want to disable WEBUI_AUTH, make sure your web interface doesn't have any existing users and is a fresh installation."
|
||||
|
||||
UNAUTHORIZED = "401 Unauthorized"
|
||||
ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance."
|
||||
ACTION_PROHIBITED = (
|
||||
"The requested action has been restricted as a security measure."
|
||||
UNAUTHORIZED = '401 Unauthorized'
|
||||
ACCESS_PROHIBITED = (
|
||||
'You do not have permission to access this resource. Please contact your administrator for assistance.'
|
||||
)
|
||||
ACTION_PROHIBITED = 'The requested action has been restricted as a security measure.'
|
||||
|
||||
FILE_NOT_SENT = "FILE_NOT_SENT"
|
||||
FILE_NOT_SENT = 'FILE_NOT_SENT'
|
||||
FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format and try again."
|
||||
|
||||
NOT_FOUND = "We could not find what you're looking for :/"
|
||||
USER_NOT_FOUND = "We could not find what you're looking for :/"
|
||||
API_KEY_NOT_FOUND = "Oops! It looks like there's a hiccup. The API key is missing. Please make sure to provide a valid API key to access this feature."
|
||||
API_KEY_NOT_ALLOWED = "Use of API key is not enabled in the environment."
|
||||
API_KEY_NOT_ALLOWED = 'Use of API key is not enabled in the environment.'
|
||||
|
||||
MALICIOUS = "Unusual activities detected, please try again in a few minutes."
|
||||
MALICIOUS = 'Unusual activities detected, please try again in a few minutes.'
|
||||
|
||||
PANDOC_NOT_INSTALLED = "Pandoc is not installed on the server. Please contact your administrator for assistance."
|
||||
INCORRECT_FORMAT = (
|
||||
lambda err="": f"Invalid format. Please use the correct format{err}"
|
||||
)
|
||||
RATE_LIMIT_EXCEEDED = "API rate limit exceeded"
|
||||
PANDOC_NOT_INSTALLED = 'Pandoc is not installed on the server. Please contact your administrator for assistance.'
|
||||
INCORRECT_FORMAT = lambda err='': f'Invalid format. Please use the correct format{err}'
|
||||
RATE_LIMIT_EXCEEDED = 'API rate limit exceeded'
|
||||
|
||||
MODEL_NOT_FOUND = lambda name="": f"Model '{name}' was not found"
|
||||
OPENAI_NOT_FOUND = lambda name="": "OpenAI API was not found"
|
||||
OLLAMA_NOT_FOUND = "WebUI could not connect to Ollama"
|
||||
CREATE_API_KEY_ERROR = "Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance."
|
||||
API_KEY_CREATION_NOT_ALLOWED = "API key creation is not allowed in the environment."
|
||||
MODEL_NOT_FOUND = lambda name='': f"Model '{name}' was not found"
|
||||
OPENAI_NOT_FOUND = lambda name='': 'OpenAI API was not found'
|
||||
OLLAMA_NOT_FOUND = 'WebUI could not connect to Ollama'
|
||||
CREATE_API_KEY_ERROR = 'Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance.'
|
||||
API_KEY_CREATION_NOT_ALLOWED = 'API key creation is not allowed in the environment.'
|
||||
|
||||
EMPTY_CONTENT = "The content provided is empty. Please ensure that there is text or data present before proceeding."
|
||||
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
|
||||
|
||||
DB_NOT_SQLITE = "This feature is only available when running with SQLite databases."
|
||||
DB_NOT_SQLITE = 'This feature is only available with SQLite databases.'
|
||||
|
||||
INVALID_URL = (
|
||||
"Oops! The URL you provided is invalid. Please double-check and try again."
|
||||
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
|
||||
|
||||
WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.'
|
||||
|
||||
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
|
||||
|
||||
FILE_TOO_LARGE = lambda size='': (
|
||||
f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
|
||||
)
|
||||
|
||||
WEB_SEARCH_ERROR = (
|
||||
lambda err="": f"{err if err else 'Oops! Something went wrong while searching the web.'}"
|
||||
DUPLICATE_CONTENT = 'Duplicate content detected. Please provide unique content to proceed.'
|
||||
FILE_NOT_PROCESSED = (
|
||||
'Extracted content is not available for this file. Please ensure that the file is processed before proceeding.'
|
||||
)
|
||||
|
||||
OLLAMA_API_DISABLED = (
|
||||
"The Ollama API is disabled. Please enable it to use this feature."
|
||||
)
|
||||
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
|
||||
|
||||
FILE_TOO_LARGE = (
|
||||
lambda size="": f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
|
||||
)
|
||||
AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})'
|
||||
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
|
||||
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
|
||||
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
|
||||
|
||||
DUPLICATE_CONTENT = (
|
||||
"Duplicate content detected. Please provide unique content to proceed."
|
||||
)
|
||||
FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding."
|
||||
|
||||
INVALID_PASSWORD = lambda err="": (
|
||||
err if err else "The password does not meet the required validation criteria."
|
||||
)
|
||||
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
||||
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
|
||||
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
|
||||
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
|
||||
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
|
||||
|
||||
|
||||
class TASKS(str, Enum):
|
||||
def __str__(self) -> str:
|
||||
return super().__str__()
|
||||
|
||||
DEFAULT = lambda task="": f"{task if task else 'generation'}"
|
||||
TITLE_GENERATION = "title_generation"
|
||||
FOLLOW_UP_GENERATION = "follow_up_generation"
|
||||
TAGS_GENERATION = "tags_generation"
|
||||
EMOJI_GENERATION = "emoji_generation"
|
||||
QUERY_GENERATION = "query_generation"
|
||||
IMAGE_PROMPT_GENERATION = "image_prompt_generation"
|
||||
AUTOCOMPLETE_GENERATION = "autocomplete_generation"
|
||||
FUNCTION_CALLING = "function_calling"
|
||||
MOA_RESPONSE_GENERATION = "moa_response_generation"
|
||||
DEFAULT = lambda task='': f'{task if task else "generation"}'
|
||||
TITLE_GENERATION = 'title_generation'
|
||||
FOLLOW_UP_GENERATION = 'follow_up_generation'
|
||||
TAGS_GENERATION = 'tags_generation'
|
||||
EMOJI_GENERATION = 'emoji_generation'
|
||||
QUERY_GENERATION = 'query_generation'
|
||||
IMAGE_PROMPT_GENERATION = 'image_prompt_generation'
|
||||
AUTOCOMPLETE_GENERATION = 'autocomplete_generation'
|
||||
FUNCTION_CALLING = 'function_calling'
|
||||
MOA_RESPONSE_GENERATION = 'moa_response_generation'
|
||||
|
||||
+675
-664
File diff suppressed because it is too large
Load Diff
+127
-136
@@ -1,11 +1,10 @@
|
||||
import logging
|
||||
import sys
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
import sys
|
||||
from typing import AsyncGenerator, Generator, Iterator
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
@@ -16,58 +15,52 @@ from fastapi import (
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import (
|
||||
get_event_call,
|
||||
get_event_emitter,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.tools import get_tools
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
|
||||
from open_webui.utils.access_control import check_model_access
|
||||
from open_webui.utils.misc import (
|
||||
add_or_update_system_message,
|
||||
get_last_user_message,
|
||||
prepend_to_first_user_message_content,
|
||||
openai_chat_chunk_message_template,
|
||||
openai_chat_completion_message_template,
|
||||
prepend_to_first_user_message_content,
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_openai,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.plugin import (
|
||||
get_function_module_from_cache,
|
||||
load_function_module_by_id,
|
||||
)
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_function_module_by_id(request: Request, pipe_id: str):
|
||||
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
|
||||
async def get_function_module_by_id(request: Request, pipe_id: str):
|
||||
function_module, _, _ = await get_function_module_from_cache(request, pipe_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
|
||||
Valves = function_module.Valves
|
||||
valves = Functions.get_function_valves_by_id(pipe_id)
|
||||
valves = await Functions.get_function_valves_by_id(pipe_id)
|
||||
|
||||
if valves:
|
||||
try:
|
||||
function_module.valves = Valves(
|
||||
**{k: v for k, v in valves.items() if v is not None}
|
||||
)
|
||||
function_module.valves = Valves(**{k: v for k, v in valves.items() if v is not None})
|
||||
except Exception as e:
|
||||
log.exception(f"Error loading valves for function {pipe_id}: {e}")
|
||||
log.exception(f'Error loading valves for function {pipe_id}: {e}')
|
||||
raise e
|
||||
else:
|
||||
function_module.valves = Valves()
|
||||
@@ -76,19 +69,19 @@ def get_function_module_by_id(request: Request, pipe_id: str):
|
||||
|
||||
|
||||
async def get_function_models(request):
|
||||
pipes = Functions.get_functions_by_type("pipe", active_only=True)
|
||||
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
||||
pipe_models = []
|
||||
|
||||
for pipe in pipes:
|
||||
try:
|
||||
function_module = get_function_module_by_id(request, pipe.id)
|
||||
function_module = await get_function_module_by_id(request, pipe.id)
|
||||
|
||||
has_user_valves = False
|
||||
if hasattr(function_module, "UserValves"):
|
||||
if hasattr(function_module, 'UserValves'):
|
||||
has_user_valves = True
|
||||
|
||||
# Check if function is a manifold
|
||||
if hasattr(function_module, "pipes"):
|
||||
if hasattr(function_module, 'pipes'):
|
||||
sub_pipes = []
|
||||
|
||||
# Handle pipes being a list, sync function, or async function
|
||||
@@ -104,32 +97,30 @@ async def get_function_models(request):
|
||||
log.exception(e)
|
||||
sub_pipes = []
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
||||
)
|
||||
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
|
||||
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
sub_pipe_name = p["name"]
|
||||
sub_pipe_name = p['name']
|
||||
|
||||
if hasattr(function_module, "name"):
|
||||
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
||||
if hasattr(function_module, 'name'):
|
||||
sub_pipe_name = f'{function_module.name}{sub_pipe_name}'
|
||||
|
||||
pipe_flag = {"type": pipe.type}
|
||||
pipe_flag = {'type': pipe.type}
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": sub_pipe_id,
|
||||
"name": sub_pipe_name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
"has_user_valves": has_user_valves,
|
||||
'id': sub_pipe_id,
|
||||
'name': sub_pipe_name,
|
||||
'object': 'model',
|
||||
'created': pipe.created_at,
|
||||
'owned_by': 'openai',
|
||||
'pipe': pipe_flag,
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
)
|
||||
else:
|
||||
pipe_flag = {"type": "pipe"}
|
||||
pipe_flag = {'type': 'pipe'}
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
@@ -137,13 +128,13 @@ async def get_function_models(request):
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": pipe.id,
|
||||
"name": pipe.name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
"has_user_valves": has_user_valves,
|
||||
'id': pipe.id,
|
||||
'name': pipe.name,
|
||||
'object': 'model',
|
||||
'created': pipe.created_at,
|
||||
'owned_by': 'openai',
|
||||
'pipe': pipe_flag,
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -153,9 +144,7 @@ async def get_function_models(request):
|
||||
return pipe_models
|
||||
|
||||
|
||||
async def generate_function_chat_completion(
|
||||
request, form_data, user, models: dict = {}
|
||||
):
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||
async def execute_pipe(pipe, params):
|
||||
if inspect.iscoroutinefunction(pipe):
|
||||
return await pipe(**params)
|
||||
@@ -166,35 +155,35 @@ async def generate_function_chat_completion(
|
||||
if isinstance(res, str):
|
||||
return res
|
||||
if isinstance(res, Generator):
|
||||
return "".join(map(str, res))
|
||||
return ''.join(map(str, res))
|
||||
if isinstance(res, AsyncGenerator):
|
||||
return "".join([str(stream) async for stream in res])
|
||||
return ''.join([str(stream) async for stream in res])
|
||||
|
||||
def process_line(form_data: dict, line):
|
||||
if isinstance(line, BaseModel):
|
||||
line = line.model_dump_json()
|
||||
line = f"data: {line}"
|
||||
line = f'data: {line}'
|
||||
if isinstance(line, dict):
|
||||
line = f"data: {json.dumps(line)}"
|
||||
line = f'data: {json.dumps(line)}'
|
||||
|
||||
try:
|
||||
line = line.decode("utf-8")
|
||||
line = line.decode('utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if line.startswith("data:"):
|
||||
return f"{line}\n\n"
|
||||
if line.startswith('data:'):
|
||||
return f'{line}\n\n'
|
||||
else:
|
||||
line = openai_chat_chunk_message_template(form_data["model"], line)
|
||||
return f"data: {json.dumps(line)}\n\n"
|
||||
line = openai_chat_chunk_message_template(form_data['model'], line)
|
||||
return f'data: {json.dumps(line)}\n\n'
|
||||
|
||||
def get_pipe_id(form_data: dict) -> str:
|
||||
pipe_id = form_data["model"]
|
||||
if "." in pipe_id:
|
||||
pipe_id, _ = pipe_id.split(".", 1)
|
||||
pipe_id = form_data['model']
|
||||
if '.' in pipe_id:
|
||||
pipe_id, _ = pipe_id.split('.', 1)
|
||||
return pipe_id
|
||||
|
||||
def get_function_params(function_module, form_data, user, extra_params=None):
|
||||
async def get_function_params(function_module, form_data, user, extra_params=None):
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
|
||||
@@ -202,27 +191,25 @@ async def generate_function_chat_completion(
|
||||
|
||||
# Get the signature of the function
|
||||
sig = inspect.signature(function_module.pipe)
|
||||
params = {"body": form_data} | {
|
||||
k: v for k, v in extra_params.items() if k in sig.parameters
|
||||
}
|
||||
params = {'body': form_data} | {k: v for k, v in extra_params.items() if k in sig.parameters}
|
||||
|
||||
if "__user__" in params and hasattr(function_module, "UserValves"):
|
||||
user_valves = Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id)
|
||||
if '__user__' in params and hasattr(function_module, 'UserValves'):
|
||||
user_valves = await Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id)
|
||||
try:
|
||||
params["__user__"]["valves"] = function_module.UserValves(**user_valves)
|
||||
params['__user__']['valves'] = function_module.UserValves(**user_valves)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
params["__user__"]["valves"] = function_module.UserValves()
|
||||
params['__user__']['valves'] = function_module.UserValves()
|
||||
|
||||
return params
|
||||
|
||||
model_id = form_data.get("model")
|
||||
model_info = Models.get_model_by_id(model_id)
|
||||
model_id = form_data.get('model')
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
|
||||
metadata = form_data.pop("metadata", {})
|
||||
metadata = form_data.pop('metadata', {})
|
||||
|
||||
files = metadata.get("files", [])
|
||||
tool_ids = metadata.get("tool_ids", [])
|
||||
files = metadata.get('files', [])
|
||||
tool_ids = metadata.get('tool_ids', [])
|
||||
# Check if tool_ids is None
|
||||
if tool_ids is None:
|
||||
tool_ids = []
|
||||
@@ -233,66 +220,73 @@ async def generate_function_chat_completion(
|
||||
__task_body__ = None
|
||||
|
||||
if metadata:
|
||||
if all(k in metadata for k in ("session_id", "chat_id", "message_id")):
|
||||
__event_emitter__ = get_event_emitter(metadata)
|
||||
__event_call__ = get_event_call(metadata)
|
||||
__task__ = metadata.get("task", None)
|
||||
__task_body__ = metadata.get("task_body", None)
|
||||
if all(k in metadata for k in ('session_id', 'chat_id', 'message_id')):
|
||||
__event_emitter__ = await get_event_emitter(metadata)
|
||||
__event_call__ = await get_event_call(metadata)
|
||||
__task__ = metadata.get('task', None)
|
||||
__task_body__ = metadata.get('task_body', None)
|
||||
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_session_id = request.cookies.get('oauth_session_id', None)
|
||||
if oauth_session_id:
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
oauth_session_id,
|
||||
)
|
||||
|
||||
# Fallback: no cookie (automation, API key, etc.) — use most recent session
|
||||
if oauth_token is None:
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
|
||||
sessions = await OAuthSessions.get_sessions_by_user_id(user.id)
|
||||
if sessions:
|
||||
best = max(sessions, key=lambda s: s.updated_at)
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
best.id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
log.error(f'Error getting OAuth token: {e}')
|
||||
|
||||
extra_params = {
|
||||
"__event_emitter__": __event_emitter__,
|
||||
"__event_call__": __event_call__,
|
||||
"__chat_id__": metadata.get("chat_id", None),
|
||||
"__session_id__": metadata.get("session_id", None),
|
||||
"__message_id__": metadata.get("message_id", None),
|
||||
"__task__": __task__,
|
||||
"__task_body__": __task_body__,
|
||||
"__files__": files,
|
||||
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
|
||||
"__metadata__": metadata,
|
||||
"__oauth_token__": oauth_token,
|
||||
"__request__": request,
|
||||
'__event_emitter__': __event_emitter__,
|
||||
'__event_call__': __event_call__,
|
||||
'__chat_id__': metadata.get('chat_id', None),
|
||||
'__session_id__': metadata.get('session_id', None),
|
||||
'__message_id__': metadata.get('message_id', None),
|
||||
'__task__': __task__,
|
||||
'__task_body__': __task_body__,
|
||||
'__files__': files,
|
||||
'__user__': user.model_dump() if isinstance(user, UserModel) else {},
|
||||
'__metadata__': metadata,
|
||||
'__oauth_token__': oauth_token,
|
||||
'__request__': request,
|
||||
}
|
||||
extra_params["__tools__"] = await get_tools(
|
||||
request,
|
||||
tool_ids,
|
||||
user,
|
||||
{
|
||||
**extra_params,
|
||||
"__model__": models.get(form_data["model"], None),
|
||||
"__messages__": form_data["messages"],
|
||||
"__files__": files,
|
||||
},
|
||||
)
|
||||
extra_params['__tools__'] = metadata.get('tools', {})
|
||||
|
||||
if model_info:
|
||||
if model_info.base_model_id:
|
||||
form_data["model"] = model_info.base_model_id
|
||||
form_data['model'] = model_info.base_model_id
|
||||
|
||||
if not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
|
||||
await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass)
|
||||
|
||||
params = model_info.params.model_dump()
|
||||
|
||||
if params:
|
||||
system = params.pop("system", None)
|
||||
system = params.pop('system', None)
|
||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
|
||||
pipe_id = get_pipe_id(form_data)
|
||||
function_module = get_function_module_by_id(request, pipe_id)
|
||||
function_module = await get_function_module_by_id(request, pipe_id)
|
||||
|
||||
pipe = function_module.pipe
|
||||
params = get_function_params(function_module, form_data, user, extra_params)
|
||||
params = await get_function_params(function_module, form_data, user, extra_params)
|
||||
|
||||
if form_data.get("stream", False):
|
||||
if form_data.get('stream', False):
|
||||
|
||||
async def stream_content():
|
||||
try:
|
||||
@@ -304,17 +298,17 @@ async def generate_function_chat_completion(
|
||||
yield data
|
||||
return
|
||||
if isinstance(res, dict):
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
yield f'data: {json.dumps(res)}\n\n'
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error: {e}")
|
||||
yield f"data: {json.dumps({'error': {'detail':str(e)}})}\n\n"
|
||||
log.error(f'Error: {e}')
|
||||
yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n'
|
||||
return
|
||||
|
||||
if isinstance(res, str):
|
||||
message = openai_chat_chunk_message_template(form_data["model"], res)
|
||||
yield f"data: {json.dumps(message)}\n\n"
|
||||
message = openai_chat_chunk_message_template(form_data['model'], res)
|
||||
yield f'data: {json.dumps(message)}\n\n'
|
||||
|
||||
if isinstance(res, Iterator):
|
||||
for line in res:
|
||||
@@ -324,22 +318,19 @@ async def generate_function_chat_completion(
|
||||
async for line in res:
|
||||
yield process_line(form_data, line)
|
||||
|
||||
if isinstance(res, str) or isinstance(res, Generator):
|
||||
finish_message = openai_chat_chunk_message_template(
|
||||
form_data["model"], ""
|
||||
)
|
||||
finish_message["choices"][0]["finish_reason"] = "stop"
|
||||
yield f"data: {json.dumps(finish_message)}\n\n"
|
||||
yield "data: [DONE]"
|
||||
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
|
||||
finish_message['choices'][0]['finish_reason'] = 'stop'
|
||||
yield f'data: {json.dumps(finish_message)}\n\n'
|
||||
yield 'data: [DONE]'
|
||||
|
||||
return StreamingResponse(stream_content(), media_type="text/event-stream")
|
||||
return StreamingResponse(stream_content(), media_type='text/event-stream')
|
||||
else:
|
||||
try:
|
||||
res = await execute_pipe(pipe, params)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error: {e}")
|
||||
return {"error": {"detail": str(e)}}
|
||||
log.error(f'Error: {e}')
|
||||
return {'error': {'detail': str(e)}}
|
||||
|
||||
if isinstance(res, StreamingResponse) or isinstance(res, dict):
|
||||
return res
|
||||
@@ -347,4 +338,4 @@ async def generate_function_chat_completion(
|
||||
return res.model_dump()
|
||||
|
||||
message = await get_message_content(res)
|
||||
return openai_chat_completion_message_template(form_data["model"], message)
|
||||
return openai_chat_completion_message_template(form_data['model'], message)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Database-backed configuration with environment variable defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import redis
|
||||
from open_webui.internal.db import Base, get_async_db, get_db
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
from sqlalchemy import JSON, Column, DateTime, Integer, func, select
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Model ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConfigTable(Base):
|
||||
__tablename__ = 'config'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
data = Column(JSON, nullable=False)
|
||||
version = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=True, onupdate=func.now())
|
||||
|
||||
|
||||
# ── Blob ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConfigState:
|
||||
"""In-memory mirror of the single-row config JSON blob."""
|
||||
|
||||
__slots__ = ('_data',)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def snapshot(self) -> dict:
|
||||
return self._data
|
||||
|
||||
def read(self, path: str) -> Any:
|
||||
return reduce(
|
||||
lambda n, k: n.get(k) if isinstance(n, dict) else None,
|
||||
path.split('.'),
|
||||
self._data,
|
||||
)
|
||||
|
||||
def write(self, path: str, value: Any) -> None:
|
||||
keys = path.split('.')
|
||||
reduce(lambda d, k: d.setdefault(k, {}), keys[:-1], self._data)[keys[-1]] = value
|
||||
|
||||
def replace(self, data: dict) -> None:
|
||||
self._data = data
|
||||
|
||||
def load(self) -> dict:
|
||||
with get_db() as db:
|
||||
row = db.query(ConfigTable).order_by(ConfigTable.id.desc()).first()
|
||||
self._data = row.data if row else {'version': 0, 'ui': {}}
|
||||
return self._data
|
||||
|
||||
def persist(self, data: dict | None = None) -> None:
|
||||
if data is not None:
|
||||
self._data = data
|
||||
with get_db() as db:
|
||||
row = db.query(ConfigTable).first()
|
||||
if row is None:
|
||||
db.add(ConfigTable(data=self._data, version=0))
|
||||
else:
|
||||
row.data, row.updated_at = self._data, datetime.now()
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
async def persist_async(self, data: dict | None = None) -> None:
|
||||
if data is not None:
|
||||
self._data = data
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(ConfigTable).limit(1))
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
db.add(ConfigTable(data=self._data, version=0))
|
||||
else:
|
||||
row.data, row.updated_at = self._data, datetime.now()
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
|
||||
def clear(self) -> None:
|
||||
with get_db() as db:
|
||||
db.query(ConfigTable).delete()
|
||||
db.commit()
|
||||
|
||||
async def clear_async(self) -> None:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
async with get_async_db() as db:
|
||||
await db.execute(sa_delete(ConfigTable))
|
||||
await db.commit()
|
||||
|
||||
|
||||
STATE = ConfigState()
|
||||
|
||||
|
||||
# ── ConfigVar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_persist_enabled: bool = True
|
||||
_oauth_persist_enabled: bool = False
|
||||
_all_configs: list[ConfigVar] = []
|
||||
|
||||
|
||||
def initialize(*, enable_persistent: bool = True, enable_oauth_persistent: bool = False) -> dict:
|
||||
global _persist_enabled, _oauth_persist_enabled
|
||||
_persist_enabled = enable_persistent
|
||||
_oauth_persist_enabled = enable_oauth_persistent
|
||||
return STATE.load()
|
||||
|
||||
|
||||
class ConfigVar:
|
||||
__slots__ = ('env_name', 'config_path', 'env_value', 'config_value', 'value')
|
||||
|
||||
def __init__(self, env_name: str, config_path: str, env_value: Any) -> None:
|
||||
self.env_name = env_name
|
||||
self.config_path = config_path
|
||||
self.env_value = env_value
|
||||
self.config_value = STATE.read(config_path)
|
||||
|
||||
if self.config_value is not None and _persist_enabled:
|
||||
if config_path.startswith('oauth.') and not _oauth_persist_enabled:
|
||||
log.info("Skipping DB value for '%s' (OAuth persistence disabled)", env_name)
|
||||
self.value = env_value
|
||||
else:
|
||||
log.info("'%s' loaded from database", env_name)
|
||||
self.value = self.config_value
|
||||
else:
|
||||
self.value = env_value
|
||||
|
||||
_all_configs.append(self)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<ConfigVar {self.env_name}={self.value!r}>'
|
||||
|
||||
@property
|
||||
def __dict__(self): # type: ignore[override]
|
||||
raise TypeError(f"ConfigVar('{self.env_name}') cannot be cast to dict; use .value")
|
||||
|
||||
def __getattribute__(self, item: str):
|
||||
if item == '__dict__':
|
||||
raise TypeError('ConfigVar cannot be cast to dict; use .value')
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def refresh(self) -> None:
|
||||
current = STATE.read(self.config_path)
|
||||
if current is not None:
|
||||
self.value = current
|
||||
log.info('Refreshed %s → %s', self.env_name, self.value)
|
||||
|
||||
def commit(self) -> None:
|
||||
log.info("Persisting '%s'", self.env_name)
|
||||
STATE.write(self.config_path, self.value)
|
||||
self.config_value = self.value
|
||||
STATE.persist()
|
||||
|
||||
async def commit_async(self) -> None:
|
||||
log.info("Persisting '%s'", self.env_name)
|
||||
STATE.write(self.config_path, self.value)
|
||||
self.config_value = self.value
|
||||
await STATE.persist_async()
|
||||
|
||||
|
||||
# ── AppConfig ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""Attribute-style container for ConfigVars with optional Redis sync."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis_url: Optional[str] = None,
|
||||
redis_sentinels: Optional[list] = None,
|
||||
redis_cluster: bool = False,
|
||||
redis_key_prefix: str = 'open-webui',
|
||||
) -> None:
|
||||
super().__setattr__('_entries', {})
|
||||
super().__setattr__('_key_prefix', redis_key_prefix)
|
||||
|
||||
# If sentinels weren't explicitly provided, read from env.
|
||||
if redis_sentinels is None:
|
||||
from open_webui.env import REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
|
||||
from open_webui.utils.redis import get_sentinels_from_env
|
||||
|
||||
redis_sentinels = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT)
|
||||
|
||||
rc: Union[redis.Redis, redis.cluster.RedisCluster, None] = None
|
||||
if redis_url:
|
||||
rc = get_redis_connection(redis_url, redis_sentinels or [], redis_cluster, decode_responses=True)
|
||||
super().__setattr__('_rc', rc)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
entries: dict = super().__getattribute__('_entries')
|
||||
|
||||
if isinstance(value, ConfigVar):
|
||||
entries[name] = value
|
||||
return
|
||||
|
||||
entries[name].value = value
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(self._write_async(name))
|
||||
except RuntimeError:
|
||||
entries[name].commit()
|
||||
|
||||
rc = super().__getattribute__('_rc')
|
||||
if rc and _persist_enabled:
|
||||
prefix = super().__getattribute__('_key_prefix')
|
||||
try:
|
||||
rc.set(f'{prefix}:config:{name}', json.dumps(entries[name].value))
|
||||
except Exception as exc:
|
||||
log.error("Redis write failed for '%s': %s", name, exc)
|
||||
|
||||
async def _write_async(self, name: str) -> None:
|
||||
try:
|
||||
await self._entries[name].commit_async()
|
||||
except Exception as exc:
|
||||
log.error("Async persist failed for '%s': %s", name, exc)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
entries = super().__getattribute__('_entries')
|
||||
if name not in entries:
|
||||
raise AttributeError(f"No config key '{name}'")
|
||||
|
||||
rc = super().__getattribute__('_rc')
|
||||
if rc and _persist_enabled:
|
||||
prefix = super().__getattribute__('_key_prefix')
|
||||
try:
|
||||
raw = rc.get(f'{prefix}:config:{name}')
|
||||
if raw is not None:
|
||||
decoded = json.loads(raw)
|
||||
if entries[name].value != decoded:
|
||||
entries[name].value = decoded
|
||||
log.info("Updated '%s' from Redis", name)
|
||||
except Exception as exc:
|
||||
log.error("Redis read failed for '%s': %s", name, exc)
|
||||
|
||||
return entries[name].value
|
||||
|
||||
def _sync_to_redis(self) -> None:
|
||||
rc = super().__getattribute__('_rc')
|
||||
if not rc or not _persist_enabled:
|
||||
return
|
||||
prefix = super().__getattribute__('_key_prefix')
|
||||
for name, s in super().__getattribute__('_entries').items():
|
||||
try:
|
||||
rc.set(f'{prefix}:config:{name}', json.dumps(s.value))
|
||||
except Exception as exc:
|
||||
log.error("Redis sync failed for '%s': %s", name, exc)
|
||||
@@ -1,98 +1,190 @@
|
||||
import os
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from open_webui.internal.wrappers import register_connection
|
||||
from open_webui.env import (
|
||||
OPEN_WEBUI_DIR,
|
||||
DATABASE_URL,
|
||||
DATABASE_SCHEMA,
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
DATABASE_POOL_MAX_OVERFLOW,
|
||||
DATABASE_POOL_RECYCLE,
|
||||
DATABASE_POOL_SIZE,
|
||||
DATABASE_POOL_TIMEOUT,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_SCHEMA,
|
||||
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
|
||||
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
|
||||
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
|
||||
DATABASE_URL,
|
||||
ENABLE_DB_MIGRATIONS,
|
||||
OPEN_WEBUI_DIR,
|
||||
)
|
||||
from peewee_migrate import Router
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
||||
from sqlalchemy import Dialect, MetaData, create_engine, event, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker, Session
|
||||
from sqlalchemy.pool import QueuePool, NullPool
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
from sqlalchemy.sql.type_api import _T
|
||||
from typing_extensions import Self
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JSONField(types.TypeDecorator):
|
||||
impl = types.Text
|
||||
# ── SSL URL normalization (used by sync engine & Alembic migrations) ─
|
||||
#
|
||||
# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does
|
||||
# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers
|
||||
# below strip all SSL-related query params, normalise them, and
|
||||
# reattach them in the canonical libpq form.
|
||||
#
|
||||
# The **async** engine now uses psycopg (v3), which speaks libpq
|
||||
# natively, so it needs no translation at all — the DATABASE_URL is
|
||||
# passed through as-is.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
|
||||
"""Pop a single-valued query param, returning ``None`` if absent."""
|
||||
values = params.pop(key, None)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _is_postgres_url(url: str) -> bool:
|
||||
"""Return True if *url* looks like a PostgreSQL connection string."""
|
||||
return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://'))
|
||||
|
||||
|
||||
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
|
||||
"""Strip SSL query-string parameters from a PostgreSQL URL.
|
||||
|
||||
Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps
|
||||
canonical libpq key names (``sslmode``, ``sslrootcert``, …) to
|
||||
their values. Non-PostgreSQL URLs are returned unchanged with an
|
||||
empty dict.
|
||||
"""
|
||||
if not _is_postgres_url(url):
|
||||
return url, {}
|
||||
|
||||
parsed = urlparse(url)
|
||||
qp = parse_qs(parsed.query, keep_blank_values=True)
|
||||
|
||||
# Prefer sslmode (libpq canonical) over the bare ``ssl`` key.
|
||||
sslmode_val = _pop_first(qp, 'sslmode')
|
||||
ssl_val = _pop_first(qp, 'ssl')
|
||||
ssl_mode = sslmode_val or ssl_val
|
||||
|
||||
ssl_dict: dict[str, str] = {}
|
||||
if ssl_mode:
|
||||
ssl_dict['sslmode'] = ssl_mode
|
||||
for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'):
|
||||
val = _pop_first(qp, key)
|
||||
if val:
|
||||
ssl_dict[key] = val
|
||||
|
||||
if not ssl_dict:
|
||||
return url, ssl_dict
|
||||
|
||||
cleaned_query = urlencode(qp, doseq=True)
|
||||
return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict
|
||||
|
||||
|
||||
def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str:
|
||||
"""Re-append SSL query-string parameters to a cleaned PostgreSQL URL.
|
||||
|
||||
Used for psycopg2/libpq consumers that expect ``sslmode`` and the
|
||||
certificate-file keys in the connection string.
|
||||
"""
|
||||
if not ssl_dict:
|
||||
return url_without_ssl
|
||||
|
||||
parts = [f'{k}={v}' for k, v in ssl_dict.items() if v]
|
||||
if not parts:
|
||||
return url_without_ssl
|
||||
|
||||
sep = '&' if '?' in url_without_ssl else '?'
|
||||
return f'{url_without_ssl}{sep}{"&".join(parts)}'
|
||||
|
||||
|
||||
# Backwards-compatible aliases for external callers.
|
||||
extract_ssl_mode_from_url = extract_ssl_params_from_url
|
||||
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
|
||||
|
||||
|
||||
class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
|
||||
"""Store arbitrary Python objects as JSON-encoded TEXT.
|
||||
|
||||
Used instead of native JSON columns for portability across SQLite and
|
||||
PostgreSQL. Values are serialized with ``json.dumps`` on write and
|
||||
deserialized with ``json.loads`` on read.
|
||||
"""
|
||||
|
||||
impl = types.UnicodeText
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||
return json.dumps(value)
|
||||
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return json.dumps(value) if value is not None else None
|
||||
|
||||
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return json.loads(value) if value is not None else None
|
||||
|
||||
def copy(self, **kw: Any) -> Self:
|
||||
return JSONField(self.impl.length)
|
||||
|
||||
def db_value(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
def python_value(self, value):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
def copy(self, **kwargs: Any) -> Self:
|
||||
return JSONField(length=self.impl.length)
|
||||
|
||||
|
||||
# Workaround to handle the peewee migration
|
||||
# This is required to ensure the peewee migration is handled before the alembic migration
|
||||
def handle_peewee_migration(DATABASE_URL):
|
||||
# db = None
|
||||
try:
|
||||
# Replace the postgresql:// with postgres:// to handle the peewee migration
|
||||
db = register_connection(DATABASE_URL.replace("postgresql://", "postgres://"))
|
||||
migrate_dir = OPEN_WEBUI_DIR / "internal" / "migrations"
|
||||
router = Router(db, logger=log, migrate_dir=migrate_dir)
|
||||
router.run()
|
||||
db.close()
|
||||
# Normalize SSL params from the URL once; the sync engine needs them
|
||||
# reattached in canonical libpq form for psycopg2.
|
||||
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize the database connection: {e}")
|
||||
log.warning(
|
||||
"Hint: If your database password contains special characters, you may need to URL-encode it."
|
||||
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
|
||||
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
||||
|
||||
|
||||
def _make_async_url(url: str) -> str:
|
||||
"""Convert a sync database URL to its async driver equivalent.
|
||||
|
||||
The async engine uses psycopg (v3) which speaks libpq natively,
|
||||
so all standard connection-string parameters (``sslmode``,
|
||||
``options``, ``target_session_attrs``, etc.) are passed through
|
||||
without any translation.
|
||||
"""
|
||||
if url.startswith('sqlite+sqlcipher://'):
|
||||
raise ValueError(
|
||||
'sqlite+sqlcipher:// URLs are not supported with async engine. '
|
||||
'Use standard sqlite:// or postgresql:// instead.'
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Properly closing the database connection
|
||||
if db and not db.is_closed():
|
||||
db.close()
|
||||
|
||||
# Assert if db connection has been closed
|
||||
assert db.is_closed(), "Database connection is still open."
|
||||
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
|
||||
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
|
||||
# psycopg v3 — auto-selects async mode with create_async_engine
|
||||
if url.startswith('postgresql+psycopg2://'):
|
||||
return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1)
|
||||
if url.startswith('postgresql://'):
|
||||
return url.replace('postgresql://', 'postgresql+psycopg://', 1)
|
||||
if url.startswith('postgres://'):
|
||||
return url.replace('postgres://', 'postgresql+psycopg://', 1)
|
||||
# For other dialects, return as-is and let SQLAlchemy handle it
|
||||
return url
|
||||
|
||||
|
||||
if ENABLE_DB_MIGRATIONS:
|
||||
handle_peewee_migration(DATABASE_URL)
|
||||
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = DATABASE_URL
|
||||
# ============================================================
|
||||
# SYNC ENGINE (used only for: startup migrations, config loading,
|
||||
# Alembic, peewee migration, health checks)
|
||||
# ============================================================
|
||||
|
||||
# Handle SQLCipher URLs
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
|
||||
database_password = os.environ.get("DATABASE_PASSWORD")
|
||||
if not database_password or database_password.strip() == "":
|
||||
raise ValueError(
|
||||
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
|
||||
)
|
||||
if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
||||
database_password = os.environ.get('DATABASE_PASSWORD')
|
||||
if not database_password or database_password.strip() == '':
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
|
||||
# Extract database path from SQLCipher URL
|
||||
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
|
||||
db_path = SQLALCHEMY_DATABASE_URL.replace('sqlite+sqlcipher://', '')
|
||||
|
||||
# Create a custom creator function that uses sqlcipher3
|
||||
def create_sqlcipher_connection():
|
||||
@@ -102,28 +194,63 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
|
||||
conn.execute(f"PRAGMA key = '{database_password}'")
|
||||
return conn
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://", # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
)
|
||||
# The dummy "sqlite://" URL would cause SQLAlchemy to auto-select
|
||||
# SingletonThreadPool, which non-deterministically closes in-use
|
||||
# connections when thread count exceeds pool_size, leading to segfaults
|
||||
# in the native sqlcipher3 C library. Use NullPool by default for safety,
|
||||
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
|
||||
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
'sqlite://',
|
||||
creator=create_sqlcipher_connection,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
poolclass=QueuePool,
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
'sqlite://',
|
||||
creator=create_sqlcipher_connection,
|
||||
poolclass=NullPool,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
log.info("Connected to encrypted SQLite database using SQLCipher")
|
||||
log.info('Connected to encrypted SQLite database using SQLCipher')
|
||||
|
||||
elif "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
|
||||
|
||||
def on_connect(dbapi_connection, connection_record):
|
||||
def _apply_sqlite_pragmas(dbapi_connection):
|
||||
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
else:
|
||||
cursor.execute("PRAGMA journal_mode=DELETE")
|
||||
cursor.execute('PRAGMA journal_mode=DELETE')
|
||||
|
||||
# Each PRAGMA is skipped when its env var is empty, allowing opt-out.
|
||||
if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS:
|
||||
cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}')
|
||||
if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT:
|
||||
cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}')
|
||||
if DATABASE_SQLITE_PRAGMA_CACHE_SIZE:
|
||||
cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}')
|
||||
if DATABASE_SQLITE_PRAGMA_TEMP_STORE:
|
||||
cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}')
|
||||
if DATABASE_SQLITE_PRAGMA_MMAP_SIZE:
|
||||
cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}')
|
||||
if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT:
|
||||
cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}')
|
||||
cursor.close()
|
||||
|
||||
event.listen(engine, "connect", on_connect)
|
||||
def on_connect(dbapi_connection, connection_record):
|
||||
_apply_sqlite_pragmas(dbapi_connection)
|
||||
|
||||
event.listen(engine, 'connect', on_connect)
|
||||
else:
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
@@ -137,22 +264,20 @@ else:
|
||||
poolclass=QueuePool,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
||||
)
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
|
||||
else:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
||||
)
|
||||
# Sync session — used ONLY for startup config loading (config.py runs at import time)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
||||
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
|
||||
Base = declarative_base(metadata=metadata_obj)
|
||||
ScopedSession = scoped_session(SessionLocal)
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Sync session generator — used ONLY for startup/config operations."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
@@ -163,10 +288,96 @@ def get_session():
|
||||
get_db = contextmanager(get_session)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_context(db: Optional[Session] = None):
|
||||
if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING:
|
||||
# ============================================================
|
||||
# ASYNC ENGINE (used for ALL runtime database operations)
|
||||
# ============================================================
|
||||
|
||||
# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed
|
||||
# through as-is. SSL params, ``options``, ``target_session_attrs``, etc.
|
||||
# all work without any stripping or translation.
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
# psycopg v3 cannot run in async mode under Windows' default
|
||||
# ProactorEventLoop — switch to SelectorEventLoop before creating
|
||||
# the async engine. This runs at import time, which is early enough
|
||||
# to cover every entry point (workers, reload, direct invocations).
|
||||
if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||
# Generous default — async coroutines + no session sharing = high connection demand.
|
||||
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={'check_same_thread': False},
|
||||
pool_size=_sqlite_pool_size,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||
_apply_sqlite_pragmas(dbapi_connection)
|
||||
else:
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
else:
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_async_session():
|
||||
"""Async session generator for FastAPI Depends()."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_async_db():
|
||||
"""Async context manager for use outside of FastAPI dependency injection."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_async_db_context(db: AsyncSession | None = None):
|
||||
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
|
||||
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
|
||||
yield db
|
||||
else:
|
||||
with get_db() as session:
|
||||
async with get_async_db() as session:
|
||||
yield session
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
"""Peewee migrations -- 001_initial_schema.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# We perform different migrations for SQLite and other databases
|
||||
# This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite
|
||||
# will require per-database SQL queries.
|
||||
# Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base
|
||||
# schema instead of trying to migrate from an older schema.
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
migrate_sqlite(migrator, database, fake=fake)
|
||||
else:
|
||||
migrate_external(migrator, database, fake=fake)
|
||||
|
||||
|
||||
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
@migrator.create_model
|
||||
class Auth(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
email = pw.CharField(max_length=255)
|
||||
password = pw.CharField(max_length=255)
|
||||
active = pw.BooleanField()
|
||||
|
||||
class Meta:
|
||||
table_name = "auth"
|
||||
|
||||
@migrator.create_model
|
||||
class Chat(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.CharField()
|
||||
chat = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "chat"
|
||||
|
||||
@migrator.create_model
|
||||
class ChatIdTag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
tag_name = pw.CharField(max_length=255)
|
||||
chat_id = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "chatidtag"
|
||||
|
||||
@migrator.create_model
|
||||
class Document(pw.Model):
|
||||
id = pw.AutoField()
|
||||
collection_name = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255, unique=True)
|
||||
title = pw.CharField()
|
||||
filename = pw.CharField()
|
||||
content = pw.TextField(null=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "document"
|
||||
|
||||
@migrator.create_model
|
||||
class Modelfile(pw.Model):
|
||||
id = pw.AutoField()
|
||||
tag_name = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
modelfile = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "modelfile"
|
||||
|
||||
@migrator.create_model
|
||||
class Prompt(pw.Model):
|
||||
id = pw.AutoField()
|
||||
command = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.CharField()
|
||||
content = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "prompt"
|
||||
|
||||
@migrator.create_model
|
||||
class Tag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
data = pw.TextField(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = "tag"
|
||||
|
||||
@migrator.create_model
|
||||
class User(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
email = pw.CharField(max_length=255)
|
||||
role = pw.CharField(max_length=255)
|
||||
profile_image_url = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "user"
|
||||
|
||||
|
||||
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
@migrator.create_model
|
||||
class Auth(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
email = pw.CharField(max_length=255)
|
||||
password = pw.TextField()
|
||||
active = pw.BooleanField()
|
||||
|
||||
class Meta:
|
||||
table_name = "auth"
|
||||
|
||||
@migrator.create_model
|
||||
class Chat(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.TextField()
|
||||
chat = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "chat"
|
||||
|
||||
@migrator.create_model
|
||||
class ChatIdTag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
tag_name = pw.CharField(max_length=255)
|
||||
chat_id = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "chatidtag"
|
||||
|
||||
@migrator.create_model
|
||||
class Document(pw.Model):
|
||||
id = pw.AutoField()
|
||||
collection_name = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255, unique=True)
|
||||
title = pw.TextField()
|
||||
filename = pw.TextField()
|
||||
content = pw.TextField(null=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "document"
|
||||
|
||||
@migrator.create_model
|
||||
class Modelfile(pw.Model):
|
||||
id = pw.AutoField()
|
||||
tag_name = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
modelfile = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "modelfile"
|
||||
|
||||
@migrator.create_model
|
||||
class Prompt(pw.Model):
|
||||
id = pw.AutoField()
|
||||
command = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
title = pw.TextField()
|
||||
content = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "prompt"
|
||||
|
||||
@migrator.create_model
|
||||
class Tag(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
data = pw.TextField(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = "tag"
|
||||
|
||||
@migrator.create_model
|
||||
class User(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
name = pw.CharField(max_length=255)
|
||||
email = pw.CharField(max_length=255)
|
||||
role = pw.CharField(max_length=255)
|
||||
profile_image_url = pw.TextField()
|
||||
timestamp = pw.BigIntegerField()
|
||||
|
||||
class Meta:
|
||||
table_name = "user"
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model("user")
|
||||
|
||||
migrator.remove_model("tag")
|
||||
|
||||
migrator.remove_model("prompt")
|
||||
|
||||
migrator.remove_model("modelfile")
|
||||
|
||||
migrator.remove_model("document")
|
||||
|
||||
migrator.remove_model("chatidtag")
|
||||
|
||||
migrator.remove_model("chat")
|
||||
|
||||
migrator.remove_model("auth")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields(
|
||||
"chat", share_id=pw.CharField(max_length=255, null=True, unique=True)
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields("chat", "share_id")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields(
|
||||
"user", api_key=pw.CharField(max_length=255, null=True, unique=True)
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields("user", "api_key")
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields("chat", archived=pw.BooleanField(default=False))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields("chat", "archived")
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
migrate_sqlite(migrator, database, fake=fake)
|
||||
else:
|
||||
migrate_external(migrator, database, fake=fake)
|
||||
|
||||
|
||||
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Adding fields created_at and updated_at to the 'chat' table
|
||||
migrator.add_fields(
|
||||
"chat",
|
||||
created_at=pw.DateTimeField(null=True), # Allow null for transition
|
||||
updated_at=pw.DateTimeField(null=True), # Allow null for transition
|
||||
)
|
||||
|
||||
# Populate the new fields from an existing 'timestamp' field
|
||||
migrator.sql(
|
||||
"UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
|
||||
)
|
||||
|
||||
# Now that the data has been copied, remove the original 'timestamp' field
|
||||
migrator.remove_fields("chat", "timestamp")
|
||||
|
||||
# Update the fields to be not null now that they are populated
|
||||
migrator.change_fields(
|
||||
"chat",
|
||||
created_at=pw.DateTimeField(null=False),
|
||||
updated_at=pw.DateTimeField(null=False),
|
||||
)
|
||||
|
||||
|
||||
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Adding fields created_at and updated_at to the 'chat' table
|
||||
migrator.add_fields(
|
||||
"chat",
|
||||
created_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
)
|
||||
|
||||
# Populate the new fields from an existing 'timestamp' field
|
||||
migrator.sql(
|
||||
"UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
|
||||
)
|
||||
|
||||
# Now that the data has been copied, remove the original 'timestamp' field
|
||||
migrator.remove_fields("chat", "timestamp")
|
||||
|
||||
# Update the fields to be not null now that they are populated
|
||||
migrator.change_fields(
|
||||
"chat",
|
||||
created_at=pw.BigIntegerField(null=False),
|
||||
updated_at=pw.BigIntegerField(null=False),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
rollback_sqlite(migrator, database, fake=fake)
|
||||
else:
|
||||
rollback_external(migrator, database, fake=fake)
|
||||
|
||||
|
||||
def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Recreate the timestamp field initially allowing null values for safe transition
|
||||
migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True))
|
||||
|
||||
# Copy the earliest created_at date back into the new timestamp field
|
||||
# This assumes created_at was originally a copy of timestamp
|
||||
migrator.sql("UPDATE chat SET timestamp = created_at")
|
||||
|
||||
# Remove the created_at and updated_at fields
|
||||
migrator.remove_fields("chat", "created_at", "updated_at")
|
||||
|
||||
# Finally, alter the timestamp field to not allow nulls if that was the original setting
|
||||
migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False))
|
||||
|
||||
|
||||
def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
# Recreate the timestamp field initially allowing null values for safe transition
|
||||
migrator.add_fields("chat", timestamp=pw.BigIntegerField(null=True))
|
||||
|
||||
# Copy the earliest created_at date back into the new timestamp field
|
||||
# This assumes created_at was originally a copy of timestamp
|
||||
migrator.sql("UPDATE chat SET timestamp = created_at")
|
||||
|
||||
# Remove the created_at and updated_at fields
|
||||
migrator.remove_fields("chat", "created_at", "updated_at")
|
||||
|
||||
# Finally, alter the timestamp field to not allow nulls if that was the original setting
|
||||
migrator.change_fields("chat", timestamp=pw.BigIntegerField(null=False))
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Peewee migrations -- 006_migrate_timestamps_and_charfields.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Alter the tables with timestamps
|
||||
migrator.change_fields(
|
||||
"chatidtag",
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"document",
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"modelfile",
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"prompt",
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"user",
|
||||
timestamp=pw.BigIntegerField(),
|
||||
)
|
||||
# Alter the tables with varchar to text where necessary
|
||||
migrator.change_fields(
|
||||
"auth",
|
||||
password=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"chat",
|
||||
title=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"document",
|
||||
title=pw.TextField(),
|
||||
filename=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"prompt",
|
||||
title=pw.TextField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"user",
|
||||
profile_image_url=pw.TextField(),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
if isinstance(database, pw.SqliteDatabase):
|
||||
# Alter the tables with timestamps
|
||||
migrator.change_fields(
|
||||
"chatidtag",
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"document",
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"modelfile",
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"prompt",
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"user",
|
||||
timestamp=pw.DateField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"auth",
|
||||
password=pw.CharField(max_length=255),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"chat",
|
||||
title=pw.CharField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"document",
|
||||
title=pw.CharField(),
|
||||
filename=pw.CharField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"prompt",
|
||||
title=pw.CharField(),
|
||||
)
|
||||
migrator.change_fields(
|
||||
"user",
|
||||
profile_image_url=pw.CharField(),
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Adding fields created_at and updated_at to the 'user' table
|
||||
migrator.add_fields(
|
||||
"user",
|
||||
created_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
|
||||
)
|
||||
|
||||
# Populate the new fields from an existing 'timestamp' field
|
||||
migrator.sql(
|
||||
'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL'
|
||||
)
|
||||
|
||||
# Now that the data has been copied, remove the original 'timestamp' field
|
||||
migrator.remove_fields("user", "timestamp")
|
||||
|
||||
# Update the fields to be not null now that they are populated
|
||||
migrator.change_fields(
|
||||
"user",
|
||||
created_at=pw.BigIntegerField(null=False),
|
||||
updated_at=pw.BigIntegerField(null=False),
|
||||
last_active_at=pw.BigIntegerField(null=False),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
# Recreate the timestamp field initially allowing null values for safe transition
|
||||
migrator.add_fields("user", timestamp=pw.BigIntegerField(null=True))
|
||||
|
||||
# Copy the earliest created_at date back into the new timestamp field
|
||||
# This assumes created_at was originally a copy of timestamp
|
||||
migrator.sql('UPDATE "user" SET timestamp = created_at')
|
||||
|
||||
# Remove the created_at and updated_at fields
|
||||
migrator.remove_fields("user", "created_at", "updated_at", "last_active_at")
|
||||
|
||||
# Finally, alter the timestamp field to not allow nulls if that was the original setting
|
||||
migrator.change_fields("user", timestamp=pw.BigIntegerField(null=False))
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
@migrator.create_model
|
||||
class Memory(pw.Model):
|
||||
id = pw.CharField(max_length=255, unique=True)
|
||||
user_id = pw.CharField(max_length=255)
|
||||
content = pw.TextField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = "memory"
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model("memory")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class Model(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
base_model_id = pw.TextField(null=True)
|
||||
|
||||
name = pw.TextField()
|
||||
|
||||
meta = pw.TextField()
|
||||
params = pw.TextField()
|
||||
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = "model"
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model("model")
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
import json
|
||||
|
||||
from open_webui.utils.misc import parse_ollama_modelfile
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Fetch data from 'modelfile' table and insert into 'model' table
|
||||
migrate_modelfile_to_model(migrator, database)
|
||||
# Drop the 'modelfile' table
|
||||
migrator.remove_model("modelfile")
|
||||
|
||||
|
||||
def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
|
||||
ModelFile = migrator.orm["modelfile"]
|
||||
Model = migrator.orm["model"]
|
||||
|
||||
modelfiles = ModelFile.select()
|
||||
|
||||
for modelfile in modelfiles:
|
||||
# Extract and transform data in Python
|
||||
|
||||
modelfile.modelfile = json.loads(modelfile.modelfile)
|
||||
meta = json.dumps(
|
||||
{
|
||||
"description": modelfile.modelfile.get("desc"),
|
||||
"profile_image_url": modelfile.modelfile.get("imageUrl"),
|
||||
"ollama": {"modelfile": modelfile.modelfile.get("content")},
|
||||
"suggestion_prompts": modelfile.modelfile.get("suggestionPrompts"),
|
||||
"categories": modelfile.modelfile.get("categories"),
|
||||
"user": {**modelfile.modelfile.get("user", {}), "community": True},
|
||||
}
|
||||
)
|
||||
|
||||
info = parse_ollama_modelfile(modelfile.modelfile.get("content"))
|
||||
|
||||
# Insert the processed data into the 'model' table
|
||||
Model.create(
|
||||
id=f"ollama-{modelfile.tag_name}",
|
||||
user_id=modelfile.user_id,
|
||||
base_model_id=info.get("base_model_id"),
|
||||
name=modelfile.modelfile.get("title"),
|
||||
meta=meta,
|
||||
params=json.dumps(info.get("params", {})),
|
||||
created_at=modelfile.timestamp,
|
||||
updated_at=modelfile.timestamp,
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
recreate_modelfile_table(migrator, database)
|
||||
move_data_back_to_modelfile(migrator, database)
|
||||
migrator.remove_model("model")
|
||||
|
||||
|
||||
def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
|
||||
query = """
|
||||
CREATE TABLE IF NOT EXISTS modelfile (
|
||||
user_id TEXT,
|
||||
tag_name TEXT,
|
||||
modelfile JSON,
|
||||
timestamp BIGINT
|
||||
)
|
||||
"""
|
||||
migrator.sql(query)
|
||||
|
||||
|
||||
def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
|
||||
Model = migrator.orm["model"]
|
||||
Modelfile = migrator.orm["modelfile"]
|
||||
|
||||
models = Model.select()
|
||||
|
||||
for model in models:
|
||||
# Extract and transform data in Python
|
||||
meta = json.loads(model.meta)
|
||||
|
||||
modelfile_data = {
|
||||
"title": model.name,
|
||||
"desc": meta.get("description"),
|
||||
"imageUrl": meta.get("profile_image_url"),
|
||||
"content": meta.get("ollama", {}).get("modelfile"),
|
||||
"suggestionPrompts": meta.get("suggestion_prompts"),
|
||||
"categories": meta.get("categories"),
|
||||
"user": {k: v for k, v in meta.get("user", {}).items() if k != "community"},
|
||||
}
|
||||
|
||||
# Insert the processed data back into the 'modelfile' table
|
||||
Modelfile.create(
|
||||
user_id=model.user_id,
|
||||
tag_name=model.id,
|
||||
modelfile=modelfile_data,
|
||||
timestamp=model.created_at,
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Adding fields settings to the 'user' table
|
||||
migrator.add_fields("user", settings=pw.TextField(null=True))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
# Remove the settings field
|
||||
migrator.remove_fields("user", "settings")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class Tool(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
|
||||
name = pw.TextField()
|
||||
content = pw.TextField()
|
||||
specs = pw.TextField()
|
||||
|
||||
meta = pw.TextField()
|
||||
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = "tool"
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model("tool")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Peewee migrations -- 002_add_local_sharing.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
# Adding fields info to the 'user' table
|
||||
migrator.add_fields("user", info=pw.TextField(null=True))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
# Remove the settings field
|
||||
migrator.remove_fields("user", "info")
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class File(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
filename = pw.TextField()
|
||||
meta = pw.TextField()
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = "file"
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model("file")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
@migrator.create_model
|
||||
class Function(pw.Model):
|
||||
id = pw.TextField(unique=True)
|
||||
user_id = pw.TextField()
|
||||
|
||||
name = pw.TextField()
|
||||
type = pw.TextField()
|
||||
|
||||
content = pw.TextField()
|
||||
meta = pw.TextField()
|
||||
|
||||
created_at = pw.BigIntegerField(null=False)
|
||||
updated_at = pw.BigIntegerField(null=False)
|
||||
|
||||
class Meta:
|
||||
table_name = "function"
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_model("function")
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Peewee migrations -- 009_add_models.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields("tool", valves=pw.TextField(null=True))
|
||||
migrator.add_fields("function", valves=pw.TextField(null=True))
|
||||
migrator.add_fields("function", is_active=pw.BooleanField(default=False))
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields("tool", "valves")
|
||||
migrator.remove_fields("function", "valves")
|
||||
migrator.remove_fields("function", "is_active")
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Peewee migrations -- 017_add_user_oauth_sub.py.
|
||||
Some examples (model - class or model name)::
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields(
|
||||
"user",
|
||||
oauth_sub=pw.TextField(null=True, unique=True),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields("user", "oauth_sub")
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Peewee migrations -- 017_add_user_oauth_sub.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['table_name'] # Return model in current state by name
|
||||
> Model = migrator.ModelClass # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.run(func, *args, **kwargs) # Run python function with the given args
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
> migrator.add_constraint(model, name, sql)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.drop_constraints(model, *constraints)
|
||||
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your migrations here."""
|
||||
|
||||
migrator.add_fields(
|
||||
"function",
|
||||
is_global=pw.BooleanField(default=False),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
|
||||
"""Write your rollback migrations here."""
|
||||
|
||||
migrator.remove_fields("function", "is_global")
|
||||
@@ -1,86 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
|
||||
from peewee import *
|
||||
from peewee import InterfaceError as PeeWeeInterfaceError
|
||||
from peewee import PostgresqlDatabase
|
||||
from playhouse.db_url import connect, parse
|
||||
from playhouse.shortcuts import ReconnectMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None}
|
||||
db_state = ContextVar("db_state", default=db_state_default.copy())
|
||||
|
||||
|
||||
class PeeweeConnectionState(object):
|
||||
def __init__(self, **kwargs):
|
||||
super().__setattr__("_state", db_state)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
self._state.get()[name] = value
|
||||
|
||||
def __getattr__(self, name):
|
||||
value = self._state.get()[name]
|
||||
return value
|
||||
|
||||
|
||||
class CustomReconnectMixin(ReconnectMixin):
|
||||
reconnect_errors = (
|
||||
# psycopg2
|
||||
(OperationalError, "termin"),
|
||||
(InterfaceError, "closed"),
|
||||
# peewee
|
||||
(PeeWeeInterfaceError, "closed"),
|
||||
)
|
||||
|
||||
|
||||
class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
|
||||
pass
|
||||
|
||||
|
||||
def register_connection(db_url):
|
||||
# Check if using SQLCipher protocol
|
||||
if db_url.startswith("sqlite+sqlcipher://"):
|
||||
database_password = os.environ.get("DATABASE_PASSWORD")
|
||||
if not database_password or database_password.strip() == "":
|
||||
raise ValueError(
|
||||
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
|
||||
)
|
||||
from playhouse.sqlcipher_ext import SqlCipherDatabase
|
||||
|
||||
# Parse the database path from SQLCipher URL
|
||||
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
|
||||
db_path = db_url.replace("sqlite+sqlcipher://", "")
|
||||
|
||||
# Use Peewee's native SqlCipherDatabase with encryption
|
||||
db = SqlCipherDatabase(db_path, passphrase=database_password)
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to encrypted SQLite database using SQLCipher")
|
||||
|
||||
else:
|
||||
# Standard database connection (existing logic)
|
||||
db = connect(db_url, unquote_user=True, unquote_password=True)
|
||||
if isinstance(db, PostgresqlDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to PostgreSQL database")
|
||||
|
||||
# Get the connection details
|
||||
connection = parse(db_url, unquote_user=True, unquote_password=True)
|
||||
|
||||
# Use our custom database class that supports reconnection
|
||||
db = ReconnectingPostgresqlDatabase(**connection)
|
||||
db.connect(reuse_if_open=True)
|
||||
elif isinstance(db, SqliteDatabase):
|
||||
# Enable autoconnect for SQLite databases, managed by Peewee
|
||||
db.autoconnect = True
|
||||
db.reuse_if_open = True
|
||||
log.info("Connected to SQLite database")
|
||||
else:
|
||||
raise ValueError("Unsupported database connection")
|
||||
return db
|
||||
+1635
-1247
File diff suppressed because it is too large
Load Diff
@@ -1,116 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Alembic environment configuration runner.
|
||||
# Coordinates database migrations in both offline and online execution modes.
|
||||
import logging.config
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
import alembic.context
|
||||
from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
|
||||
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
|
||||
from sqlalchemy import engine_from_config, pool, create_engine
|
||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||
from sqlalchemy import create_engine, engine_from_config, pool
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# Re-apply JSON formatter after fileConfig replaces handlers.
|
||||
if LOG_FORMAT == "json":
|
||||
alembic_config = alembic.context.config
|
||||
if alembic_config.config_file_name:
|
||||
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
|
||||
if LOG_FORMAT == 'json':
|
||||
from open_webui.env import JSONFormatter
|
||||
|
||||
for handler in logging.root.handlers:
|
||||
handler.setFormatter(JSONFormatter())
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Auth.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
DB_URL = DATABASE_URL
|
||||
|
||||
if DB_URL:
|
||||
config.set_main_option("sqlalchemy.url", DB_URL.replace("%", "%%"))
|
||||
for log_handler in logging.root.handlers:
|
||||
log_handler.setFormatter(JSONFormatter())
|
||||
migration_metadata = Auth.metadata
|
||||
target_db_url = DATABASE_URL
|
||||
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
|
||||
if ssl_query_params:
|
||||
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
|
||||
if target_db_url:
|
||||
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
|
||||
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
|
||||
alembic.context.configure(
|
||||
url=db_connection_url,
|
||||
target_metadata=migration_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
with alembic.context.begin_transaction():
|
||||
alembic.context.run_migrations()
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def _get_engine_connectable():
|
||||
"""Build the database engine based on target URL and authentication credentials."""
|
||||
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
|
||||
if not DATABASE_PASSWORD or not DATABASE_PASSWORD.strip():
|
||||
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
||||
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
|
||||
if raw_db_path.startswith('/'):
|
||||
raw_db_path = raw_db_path[1:]
|
||||
|
||||
def _sqlite_cipher_creator():
|
||||
import sqlcipher3
|
||||
|
||||
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
|
||||
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return cipher_conn
|
||||
|
||||
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
|
||||
return engine_from_config(
|
||||
alembic_config.get_section(alembic_config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Handle SQLCipher URLs
|
||||
if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
|
||||
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
|
||||
raise ValueError(
|
||||
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
|
||||
)
|
||||
|
||||
# Extract database path from SQLCipher URL
|
||||
db_path = DB_URL.replace("sqlite+sqlcipher://", "")
|
||||
if db_path.startswith("/"):
|
||||
db_path = db_path[1:] # Remove leading slash for relative paths
|
||||
|
||||
# Create a custom creator function that uses sqlcipher3
|
||||
def create_sqlcipher_connection():
|
||||
import sqlcipher3
|
||||
|
||||
conn = sqlcipher3.connect(db_path, check_same_thread=False)
|
||||
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
|
||||
return conn
|
||||
|
||||
connectable = create_engine(
|
||||
"sqlite://", # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
"""Execute migrations against a live database connection."""
|
||||
live_connectable = _get_engine_connectable()
|
||||
with live_connectable.connect() as live_connection:
|
||||
alembic.context.configure(
|
||||
connection=live_connection,
|
||||
target_metadata=migration_metadata,
|
||||
)
|
||||
else:
|
||||
# Standard database connection (existing logic)
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
with alembic.context.begin_transaction():
|
||||
alembic.context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
# Alembic execution entrypoint branch
|
||||
if alembic.context.is_offline_mode():
|
||||
run_migrations_offline() # run in offline mode
|
||||
if not alembic.context.is_offline_mode():
|
||||
run_migrations_online() # run in online mode
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
from alembic import op
|
||||
from sqlalchemy import Inspector
|
||||
from __future__ import annotations
|
||||
|
||||
"""Alembic migration utilities."""
|
||||
|
||||
from alembic import op # noqa: E402 — alembic runtime context
|
||||
from sqlalchemy import inspect # metadata inspection
|
||||
|
||||
|
||||
def get_existing_tables():
|
||||
con = op.get_bind()
|
||||
inspector = Inspector.from_engine(con)
|
||||
tables = set(inspector.get_table_names())
|
||||
return tables
|
||||
# --- database helper functions ---
|
||||
def get_existing_tables() -> set[str]:
|
||||
"""Return table names already present in the database."""
|
||||
conn = op.get_bind()
|
||||
return set(inspect(conn).get_table_names())
|
||||
|
||||
|
||||
def get_revision_id():
|
||||
def get_revision_id() -> str:
|
||||
"""Generate a short random revision identifier."""
|
||||
import uuid
|
||||
|
||||
return str(uuid.uuid4()).replace("-", "")[:12]
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
@@ -6,41 +6,57 @@ Create Date: 2025-08-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "018012973d35"
|
||||
down_revision = "d31026856c01"
|
||||
revision = '018012973d35'
|
||||
down_revision = 'd31026856c01'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
def _idx_exists(table, idx_name):
|
||||
return any(i['name'] == idx_name for i in inspector.get_indexes(table))
|
||||
|
||||
# Chat table indexes
|
||||
op.create_index("folder_id_idx", "chat", ["folder_id"])
|
||||
op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"])
|
||||
op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"])
|
||||
op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"])
|
||||
op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"])
|
||||
if not _idx_exists('chat', 'folder_id_idx'):
|
||||
op.create_index('folder_id_idx', 'chat', ['folder_id'])
|
||||
if not _idx_exists('chat', 'user_id_pinned_idx'):
|
||||
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
|
||||
if not _idx_exists('chat', 'user_id_archived_idx'):
|
||||
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
|
||||
if not _idx_exists('chat', 'updated_at_user_id_idx'):
|
||||
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
|
||||
if not _idx_exists('chat', 'folder_id_user_id_idx'):
|
||||
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
|
||||
|
||||
# Tag table index
|
||||
op.create_index("user_id_idx", "tag", ["user_id"])
|
||||
if not _idx_exists('tag', 'user_id_idx'):
|
||||
op.create_index('user_id_idx', 'tag', ['user_id'])
|
||||
|
||||
# Function table index
|
||||
op.create_index("is_global_idx", "function", ["is_global"])
|
||||
# Function table index (only if is_global column exists — added by a later migration)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
func_cols = {c['name'] for c in inspector.get_columns('function')}
|
||||
if 'is_global' in func_cols and not _idx_exists('function', 'is_global_idx'):
|
||||
op.create_index('is_global_idx', 'function', ['is_global'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Chat table indexes
|
||||
op.drop_index("folder_id_idx", table_name="chat")
|
||||
op.drop_index("user_id_pinned_idx", table_name="chat")
|
||||
op.drop_index("user_id_archived_idx", table_name="chat")
|
||||
op.drop_index("updated_at_user_id_idx", table_name="chat")
|
||||
op.drop_index("folder_id_user_id_idx", table_name="chat")
|
||||
op.drop_index('folder_id_idx', table_name='chat')
|
||||
op.drop_index('user_id_pinned_idx', table_name='chat')
|
||||
op.drop_index('user_id_archived_idx', table_name='chat')
|
||||
op.drop_index('updated_at_user_id_idx', table_name='chat')
|
||||
op.drop_index('folder_id_user_id_idx', table_name='chat')
|
||||
|
||||
# Tag table index
|
||||
op.drop_index("user_id_idx", table_name="tag")
|
||||
op.drop_index('user_id_idx', table_name='tag')
|
||||
|
||||
# Function table index
|
||||
|
||||
op.drop_index("is_global_idx", table_name="function")
|
||||
op.drop_index('is_global_idx', table_name='function')
|
||||
|
||||
@@ -6,15 +6,15 @@ Create Date: 2024-10-09 21:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update, column
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
import json
|
||||
|
||||
revision = "1af9b942657b"
|
||||
down_revision = "242a2047eae0"
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql import column, select, table, update
|
||||
|
||||
revision = '1af9b942657b'
|
||||
down_revision = '242a2047eae0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -25,43 +25,40 @@ def upgrade():
|
||||
inspector = Inspector.from_engine(conn)
|
||||
|
||||
# Clean up potential leftover temp table from previous failures
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS _alembic_tmp_tag"))
|
||||
conn.execute(sa.text('DROP TABLE IF EXISTS _alembic_tmp_tag'))
|
||||
|
||||
# Check if the 'tag' table exists
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# Step 1: Modify Tag table using batch mode for SQLite support
|
||||
if "tag" in tables:
|
||||
if 'tag' in tables:
|
||||
# Get the current columns in the 'tag' table
|
||||
columns = [col["name"] for col in inspector.get_columns("tag")]
|
||||
columns = [col['name'] for col in inspector.get_columns('tag')]
|
||||
|
||||
# Get any existing unique constraints on the 'tag' table
|
||||
current_constraints = inspector.get_unique_constraints("tag")
|
||||
current_constraints = inspector.get_unique_constraints('tag')
|
||||
|
||||
with op.batch_alter_table("tag", schema=None) as batch_op:
|
||||
with op.batch_alter_table('tag', schema=None) as batch_op:
|
||||
# Check if the unique constraint already exists
|
||||
if not any(
|
||||
constraint["name"] == "uq_id_user_id"
|
||||
for constraint in current_constraints
|
||||
):
|
||||
if not any(constraint['name'] == 'uq_id_user_id' for constraint in current_constraints):
|
||||
# Create unique constraint if it doesn't exist
|
||||
batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"])
|
||||
batch_op.create_unique_constraint('uq_id_user_id', ['id', 'user_id'])
|
||||
|
||||
# Check if the 'data' column exists before trying to drop it
|
||||
if "data" in columns:
|
||||
batch_op.drop_column("data")
|
||||
if 'data' in columns:
|
||||
batch_op.drop_column('data')
|
||||
|
||||
# Check if the 'meta' column needs to be created
|
||||
if "meta" not in columns:
|
||||
if 'meta' not in columns:
|
||||
# Add the 'meta' column if it doesn't already exist
|
||||
batch_op.add_column(sa.Column("meta", sa.JSON(), nullable=True))
|
||||
batch_op.add_column(sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
tag = table(
|
||||
"tag",
|
||||
column("id", sa.String()),
|
||||
column("name", sa.String()),
|
||||
column("user_id", sa.String()),
|
||||
column("meta", sa.JSON()),
|
||||
'tag',
|
||||
column('id', sa.String()),
|
||||
column('name', sa.String()),
|
||||
column('user_id', sa.String()),
|
||||
column('meta', sa.JSON()),
|
||||
)
|
||||
|
||||
# Step 2: Migrate tags
|
||||
@@ -70,12 +67,12 @@ def upgrade():
|
||||
|
||||
tag_updates = {}
|
||||
for row in result:
|
||||
new_id = row.name.replace(" ", "_").lower()
|
||||
new_id = row.name.replace(' ', '_').lower()
|
||||
tag_updates[row.id] = new_id
|
||||
|
||||
for tag_id, new_tag_id in tag_updates.items():
|
||||
print(f"Updating tag {tag_id} to {new_tag_id}")
|
||||
if new_tag_id == "pinned":
|
||||
print(f'Updating tag {tag_id} to {new_tag_id}')
|
||||
if new_tag_id == 'pinned':
|
||||
# delete tag
|
||||
delete_stmt = sa.delete(tag).where(tag.c.id == tag_id)
|
||||
conn.execute(delete_stmt)
|
||||
@@ -86,9 +83,7 @@ def upgrade():
|
||||
|
||||
if existing_tag_result:
|
||||
# Handle duplicate case: the new_tag_id already exists
|
||||
print(
|
||||
f"Tag {new_tag_id} already exists. Removing current tag with ID {tag_id} to avoid duplicates."
|
||||
)
|
||||
print(f'Tag {new_tag_id} already exists. Removing current tag with ID {tag_id} to avoid duplicates.')
|
||||
# Option 1: Delete the current tag if an update to new_tag_id would cause duplication
|
||||
delete_stmt = sa.delete(tag).where(tag.c.id == tag_id)
|
||||
conn.execute(delete_stmt)
|
||||
@@ -98,19 +93,18 @@ def upgrade():
|
||||
conn.execute(update_stmt)
|
||||
|
||||
# Add columns `pinned` and `meta` to 'chat'
|
||||
op.add_column("chat", sa.Column("pinned", sa.Boolean(), nullable=True))
|
||||
op.add_column(
|
||||
"chat", sa.Column("meta", sa.JSON(), nullable=False, server_default="{}")
|
||||
)
|
||||
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'pinned' not in chat_columns:
|
||||
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
|
||||
if 'meta' not in chat_columns:
|
||||
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
|
||||
|
||||
chatidtag = table(
|
||||
"chatidtag", column("chat_id", sa.String()), column("tag_name", sa.String())
|
||||
)
|
||||
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
|
||||
chat = table(
|
||||
"chat",
|
||||
column("id", sa.String()),
|
||||
column("pinned", sa.Boolean()),
|
||||
column("meta", sa.JSON()),
|
||||
'chat',
|
||||
column('id', sa.String()),
|
||||
column('pinned', sa.Boolean()),
|
||||
column('meta', sa.JSON()),
|
||||
)
|
||||
|
||||
# Fetch existing tags
|
||||
@@ -120,29 +114,27 @@ def upgrade():
|
||||
chat_updates = {}
|
||||
for row in result:
|
||||
chat_id = row.chat_id
|
||||
tag_name = row.tag_name.replace(" ", "_").lower()
|
||||
tag_name = row.tag_name.replace(' ', '_').lower()
|
||||
|
||||
if tag_name == "pinned":
|
||||
if tag_name == 'pinned':
|
||||
# Specifically handle 'pinned' tag
|
||||
if chat_id not in chat_updates:
|
||||
chat_updates[chat_id] = {"pinned": True, "meta": {}}
|
||||
chat_updates[chat_id] = {'pinned': True, 'meta': {}}
|
||||
else:
|
||||
chat_updates[chat_id]["pinned"] = True
|
||||
chat_updates[chat_id]['pinned'] = True
|
||||
else:
|
||||
if chat_id not in chat_updates:
|
||||
chat_updates[chat_id] = {"pinned": False, "meta": {"tags": [tag_name]}}
|
||||
chat_updates[chat_id] = {'pinned': False, 'meta': {'tags': [tag_name]}}
|
||||
else:
|
||||
tags = chat_updates[chat_id]["meta"].get("tags", [])
|
||||
tags = chat_updates[chat_id]['meta'].get('tags', [])
|
||||
tags.append(tag_name)
|
||||
|
||||
chat_updates[chat_id]["meta"]["tags"] = list(set(tags))
|
||||
chat_updates[chat_id]['meta']['tags'] = list(set(tags))
|
||||
|
||||
# Update chats based on accumulated changes
|
||||
for chat_id, updates in chat_updates.items():
|
||||
update_stmt = sa.update(chat).where(chat.c.id == chat_id)
|
||||
update_stmt = update_stmt.values(
|
||||
meta=updates.get("meta", {}), pinned=updates.get("pinned", False)
|
||||
)
|
||||
update_stmt = update_stmt.values(meta=updates.get('meta', {}), pinned=updates.get('pinned', False))
|
||||
conn.execute(update_stmt)
|
||||
pass
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ Create Date: 2024-10-09 21:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update
|
||||
|
||||
import json
|
||||
|
||||
revision = "242a2047eae0"
|
||||
down_revision = "6a39f3d8e55c"
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import select, table, update
|
||||
|
||||
revision = '242a2047eae0'
|
||||
down_revision = '6a39f3d8e55c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -22,86 +22,79 @@ def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
columns = inspector.get_columns("chat")
|
||||
column_dict = {col["name"]: col for col in columns}
|
||||
columns = inspector.get_columns('chat')
|
||||
column_dict = {col['name']: col for col in columns}
|
||||
|
||||
chat_column = column_dict.get("chat")
|
||||
old_chat_exists = "old_chat" in column_dict
|
||||
chat_column = column_dict.get('chat')
|
||||
old_chat_exists = 'old_chat' in column_dict
|
||||
|
||||
if chat_column:
|
||||
if isinstance(chat_column["type"], sa.Text):
|
||||
if isinstance(chat_column['type'], sa.Text):
|
||||
print("Converting 'chat' column to JSON")
|
||||
|
||||
if old_chat_exists:
|
||||
print("Dropping old 'old_chat' column")
|
||||
op.drop_column("chat", "old_chat")
|
||||
op.drop_column('chat', 'old_chat')
|
||||
|
||||
# Step 1: Rename current 'chat' column to 'old_chat'
|
||||
print("Renaming 'chat' column to 'old_chat'")
|
||||
op.alter_column(
|
||||
"chat", "chat", new_column_name="old_chat", existing_type=sa.Text()
|
||||
)
|
||||
op.alter_column('chat', 'chat', new_column_name='old_chat', existing_type=sa.Text())
|
||||
|
||||
# Step 2: Add new 'chat' column of type JSON
|
||||
print("Adding new 'chat' column of type JSON")
|
||||
op.add_column("chat", sa.Column("chat", sa.JSON(), nullable=True))
|
||||
op.add_column('chat', sa.Column('chat', sa.JSON(), nullable=True))
|
||||
else:
|
||||
# If the column is already JSON, no need to do anything
|
||||
pass
|
||||
|
||||
# Step 3: Migrate data from 'old_chat' to 'chat'
|
||||
chat_table = table(
|
||||
"chat",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("old_chat", sa.Text()),
|
||||
sa.Column("chat", sa.JSON()),
|
||||
)
|
||||
|
||||
# - Selecting all data from the table
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||
for row in results:
|
||||
try:
|
||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||
json_data = json.loads(row.old_chat)
|
||||
except json.JSONDecodeError:
|
||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
||||
|
||||
connection.execute(
|
||||
sa.update(chat_table)
|
||||
.where(chat_table.c.id == row.id)
|
||||
.values(chat=json_data)
|
||||
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
|
||||
# Re-check columns after potential rename above
|
||||
current_cols = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'old_chat' in current_cols:
|
||||
chat_table = table(
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('old_chat', sa.Text()),
|
||||
sa.Column('chat', sa.JSON()),
|
||||
)
|
||||
|
||||
# Step 4: Drop 'old_chat' column
|
||||
print("Dropping 'old_chat' column")
|
||||
op.drop_column("chat", "old_chat")
|
||||
# - Selecting all data from the table
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
|
||||
for row in results:
|
||||
try:
|
||||
# Convert text JSON to actual JSON object, assuming the text is in JSON format
|
||||
json_data = json.loads(row.old_chat)
|
||||
except json.JSONDecodeError:
|
||||
json_data = None # Handle cases where the text cannot be converted to JSON
|
||||
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
|
||||
|
||||
# Step 4: Drop 'old_chat' column
|
||||
print("Dropping 'old_chat' column")
|
||||
op.drop_column('chat', 'old_chat')
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Step 1: Add 'old_chat' column back as Text
|
||||
op.add_column("chat", sa.Column("old_chat", sa.Text(), nullable=True))
|
||||
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
|
||||
|
||||
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
|
||||
chat_table = table(
|
||||
"chat",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("chat", sa.JSON()),
|
||||
sa.Column("old_chat", sa.Text()),
|
||||
'chat',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('chat', sa.JSON()),
|
||||
sa.Column('old_chat', sa.Text()),
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
|
||||
for row in results:
|
||||
text_data = json.dumps(row.chat) if row.chat is not None else None
|
||||
connection.execute(
|
||||
sa.update(chat_table)
|
||||
.where(chat_table.c.id == row.id)
|
||||
.values(old_chat=text_data)
|
||||
)
|
||||
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
|
||||
|
||||
# Step 3: Remove the new 'chat' JSON column
|
||||
op.drop_column("chat", "chat")
|
||||
op.drop_column('chat', 'chat')
|
||||
|
||||
# Step 4: Rename 'old_chat' back to 'chat'
|
||||
op.alter_column("chat", "old_chat", new_column_name="chat", existing_type=sa.Text())
|
||||
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())
|
||||
|
||||
+80
-75
@@ -8,95 +8,100 @@ Create Date: 2025-11-27 03:07:56.200231
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2f1211949ecc"
|
||||
down_revision: Union[str, None] = "37f288994c47"
|
||||
revision: str = '2f1211949ecc'
|
||||
down_revision: Union[str, None] = '37f288994c47'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# New columns to be added to channel_member table
|
||||
op.add_column("channel_member", sa.Column("status", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"channel_member",
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"channel_member",
|
||||
sa.Column(
|
||||
"is_channel_muted",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"channel_member",
|
||||
sa.Column(
|
||||
"is_channel_pinned",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column("channel_member", sa.Column("data", sa.JSON(), nullable=True))
|
||||
op.add_column("channel_member", sa.Column("meta", sa.JSON(), nullable=True))
|
||||
|
||||
op.add_column(
|
||||
"channel_member", sa.Column("joined_at", sa.BigInteger(), nullable=False)
|
||||
)
|
||||
op.add_column(
|
||||
"channel_member", sa.Column("left_at", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"channel_member", sa.Column("last_read_at", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"channel_member", sa.Column("updated_at", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
||||
if 'status' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
|
||||
if 'is_active' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_active',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
if 'is_channel_muted' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_muted',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'is_channel_pinned' not in cm_cols:
|
||||
op.add_column(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'is_channel_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'data' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
|
||||
if 'meta' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
if 'joined_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
|
||||
if 'left_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
|
||||
if 'last_read_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
if 'updated_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
# New columns to be added to message table
|
||||
op.add_column(
|
||||
"message",
|
||||
sa.Column(
|
||||
"is_pinned",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
op.add_column("message", sa.Column("pinned_at", sa.BigInteger(), nullable=True))
|
||||
op.add_column("message", sa.Column("pinned_by", sa.Text(), nullable=True))
|
||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
if 'is_pinned' not in msg_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column(
|
||||
'is_pinned',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.sql.expression.false(),
|
||||
),
|
||||
)
|
||||
if 'pinned_at' not in msg_cols:
|
||||
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
|
||||
if 'pinned_by' not in msg_cols:
|
||||
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("channel_member", "updated_at")
|
||||
op.drop_column("channel_member", "last_read_at")
|
||||
op.drop_column('channel_member', 'updated_at')
|
||||
op.drop_column('channel_member', 'last_read_at')
|
||||
|
||||
op.drop_column("channel_member", "meta")
|
||||
op.drop_column("channel_member", "data")
|
||||
op.drop_column('channel_member', 'meta')
|
||||
op.drop_column('channel_member', 'data')
|
||||
|
||||
op.drop_column("channel_member", "is_channel_pinned")
|
||||
op.drop_column("channel_member", "is_channel_muted")
|
||||
op.drop_column('channel_member', 'is_channel_pinned')
|
||||
op.drop_column('channel_member', 'is_channel_muted')
|
||||
|
||||
op.drop_column("message", "pinned_by")
|
||||
op.drop_column("message", "pinned_at")
|
||||
op.drop_column("message", "is_pinned")
|
||||
op.drop_column('message', 'pinned_by')
|
||||
op.drop_column('message', 'pinned_at')
|
||||
op.drop_column('message', 'is_pinned')
|
||||
|
||||
@@ -6,164 +6,181 @@ Create Date: 2026-01-23 17:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "374d2f66af06"
|
||||
down_revision: Union[str, None] = "c440947495f3"
|
||||
revision: str = '374d2f66af06'
|
||||
down_revision: Union[str, None] = 'c440947495f3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Step 1: Read existing data from OLD table (schema likely command as PK)
|
||||
# We use batch_alter previously, but we want to move to new table.
|
||||
# We need to assume the OLD structure.
|
||||
# If the final state already exists (prompt has 'id' PK + prompt_history exists),
|
||||
# the migration completed successfully on a prior run — nothing to do.
|
||||
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
|
||||
# prompt_history exists and prompt_new was already renamed → done
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'id' in prompt_cols and 'version_id' in prompt_cols:
|
||||
return
|
||||
|
||||
old_prompt_table = sa.table(
|
||||
"prompt",
|
||||
sa.column("command", sa.Text()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("title", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("timestamp", sa.BigInteger()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
)
|
||||
|
||||
# Check if table exists/read data
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
# Step 1: Read existing data from OLD table (schema: command as PK)
|
||||
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
|
||||
existing_prompts = []
|
||||
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
|
||||
old_prompt_table = sa.table(
|
||||
'prompt',
|
||||
sa.column('command', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('title', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('timestamp', sa.BigInteger()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
# Fallback if table doesn't exist (new install)
|
||||
existing_prompts = []
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
existing_prompts = []
|
||||
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
|
||||
op.create_table(
|
||||
"prompt_new",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("command", sa.String(), unique=True, index=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("version_id", sa.Text(), nullable=True),
|
||||
sa.Column("tags", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 3: Create prompt_history table
|
||||
op.create_table(
|
||||
"prompt_history",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("prompt_id", sa.Text(), nullable=False, index=True),
|
||||
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||
sa.Column("snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("commit_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 4: Migrate data
|
||||
prompt_new_table = sa.table(
|
||||
"prompt_new",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("command", sa.String()),
|
||||
sa.column("user_id", sa.String()),
|
||||
sa.column("name", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("data", sa.JSON()),
|
||||
sa.column("meta", sa.JSON()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
sa.column("is_active", sa.Boolean()),
|
||||
sa.column("version_id", sa.Text()),
|
||||
sa.column("tags", sa.JSON()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
sa.column("updated_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
prompt_history_table = sa.table(
|
||||
"prompt_history",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("prompt_id", sa.Text()),
|
||||
sa.column("parent_id", sa.Text()),
|
||||
sa.column("snapshot", sa.JSON()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("commit_message", sa.Text()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith("/") else command
|
||||
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
|
||||
if 'prompt_new' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt_new',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('command', sa.String(), unique=True, index=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('version_id', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
"name": title,
|
||||
"content": content,
|
||||
"command": clean_command,
|
||||
"data": {},
|
||||
"meta": {},
|
||||
"access_control": access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
# Step 3: Create prompt_history table (if not already created)
|
||||
if 'prompt_history' not in existing_tables:
|
||||
op.create_table(
|
||||
'prompt_history',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('commit_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 5: Replace old table with new one
|
||||
op.drop_table("prompt")
|
||||
op.rename_table("prompt_new", "prompt")
|
||||
# Step 4: Migrate data (only if we have old data to migrate)
|
||||
if existing_prompts:
|
||||
prompt_new_table = sa.table(
|
||||
'prompt_new',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('user_id', sa.String()),
|
||||
sa.column('name', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('data', sa.JSON()),
|
||||
sa.column('meta', sa.JSON()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
sa.column('is_active', sa.Boolean()),
|
||||
sa.column('version_id', sa.Text()),
|
||||
sa.column('tags', sa.JSON()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
prompt_history_table = sa.table(
|
||||
'prompt_history',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('prompt_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('snapshot', sa.JSON()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('commit_message', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith('/') else command
|
||||
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
'name': title,
|
||||
'content': content,
|
||||
'command': clean_command,
|
||||
'data': {},
|
||||
'meta': {},
|
||||
'access_control': access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Step 5: Replace old table with new one (only if prompt_new exists)
|
||||
# Re-check tables after potential creation above
|
||||
inspector.clear_cache()
|
||||
current_tables = set(inspector.get_table_names())
|
||||
if 'prompt_new' in current_tables:
|
||||
if 'prompt' in current_tables:
|
||||
op.drop_table('prompt')
|
||||
op.rename_table('prompt_new', 'prompt')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
@@ -171,13 +188,13 @@ def downgrade() -> None:
|
||||
|
||||
# Step 1: Read new data
|
||||
prompt_table = sa.table(
|
||||
"prompt",
|
||||
sa.column("command", sa.String()),
|
||||
sa.column("name", sa.Text()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
'prompt',
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('name', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -195,31 +212,31 @@ def downgrade() -> None:
|
||||
current_data = []
|
||||
|
||||
# Step 2: Drop history and table
|
||||
op.drop_table("prompt_history")
|
||||
op.drop_table("prompt")
|
||||
op.drop_table('prompt_history')
|
||||
op.drop_table('prompt')
|
||||
|
||||
# Step 3: Recreate old table (command as PK?)
|
||||
# Assuming old schema:
|
||||
op.create_table(
|
||||
"prompt",
|
||||
sa.Column("command", sa.String(), primary_key=True),
|
||||
sa.Column("user_id", sa.String()),
|
||||
sa.Column("title", sa.Text()),
|
||||
sa.Column("content", sa.Text()),
|
||||
sa.Column("timestamp", sa.BigInteger()),
|
||||
sa.Column("access_control", sa.JSON()),
|
||||
sa.Column("id", sa.Integer(), nullable=True),
|
||||
'prompt',
|
||||
sa.Column('command', sa.String(), primary_key=True),
|
||||
sa.Column('user_id', sa.String()),
|
||||
sa.Column('title', sa.Text()),
|
||||
sa.Column('content', sa.Text()),
|
||||
sa.Column('timestamp', sa.BigInteger()),
|
||||
sa.Column('access_control', sa.JSON()),
|
||||
sa.Column('id', sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
# Step 4: Restore data
|
||||
old_prompt_table = sa.table(
|
||||
"prompt",
|
||||
sa.column("command", sa.String()),
|
||||
sa.column("user_id", sa.String()),
|
||||
sa.column("title", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("timestamp", sa.BigInteger()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
'prompt',
|
||||
sa.column('command', sa.String()),
|
||||
sa.column('user_id', sa.String()),
|
||||
sa.column('title', sa.Text()),
|
||||
sa.column('content', sa.Text()),
|
||||
sa.column('timestamp', sa.BigInteger()),
|
||||
sa.column('access_control', sa.JSON()),
|
||||
)
|
||||
|
||||
for row in current_data:
|
||||
@@ -231,9 +248,7 @@ def downgrade() -> None:
|
||||
access_control = row[5]
|
||||
|
||||
# Restore leading /
|
||||
old_command = (
|
||||
"/" + command if command and not command.startswith("/") else command
|
||||
)
|
||||
old_command = '/' + command if command and not command.startswith('/') else command
|
||||
|
||||
conn.execute(
|
||||
sa.insert(old_prompt_table).values(
|
||||
|
||||
@@ -6,65 +6,65 @@ Create Date: 2024-12-30 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "3781e22d8b01"
|
||||
down_revision = "7826ab40b532"
|
||||
revision = '3781e22d8b01'
|
||||
down_revision = '7826ab40b532'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Add 'type' column to the 'channel' table
|
||||
op.add_column(
|
||||
"channel",
|
||||
sa.Column(
|
||||
"type",
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
||||
if 'type' not in channel_cols:
|
||||
op.add_column(
|
||||
'channel',
|
||||
sa.Column(
|
||||
'type',
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Add 'parent_id' column to the 'message' table for threads
|
||||
op.add_column(
|
||||
"message",
|
||||
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||
)
|
||||
message_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
if 'parent_id' not in message_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"message_reaction",
|
||||
sa.Column(
|
||||
"id", sa.Text(), nullable=False, primary_key=True, unique=True
|
||||
), # Unique reaction ID
|
||||
sa.Column("user_id", sa.Text(), nullable=False), # User who reacted
|
||||
sa.Column(
|
||||
"message_id", sa.Text(), nullable=False
|
||||
), # Message that was reacted to
|
||||
sa.Column(
|
||||
"name", sa.Text(), nullable=False
|
||||
), # Reaction name (e.g. "thumbs_up")
|
||||
sa.Column(
|
||||
"created_at", sa.BigInteger(), nullable=True
|
||||
), # Timestamp of when the reaction was added
|
||||
)
|
||||
if 'message_reaction' not in existing_tables:
|
||||
op.create_table(
|
||||
'message_reaction',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
|
||||
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
|
||||
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"channel_member",
|
||||
sa.Column(
|
||||
"id", sa.Text(), nullable=False, primary_key=True, unique=True
|
||||
), # Record ID for the membership row
|
||||
sa.Column("channel_id", sa.Text(), nullable=False), # Associated channel
|
||||
sa.Column("user_id", sa.Text(), nullable=False), # Associated user
|
||||
sa.Column(
|
||||
"created_at", sa.BigInteger(), nullable=True
|
||||
), # Timestamp of when the user joined the channel
|
||||
)
|
||||
if 'channel_member' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_member',
|
||||
sa.Column(
|
||||
'id', sa.Text(), nullable=False, primary_key=True, unique=True
|
||||
), # Record ID for the membership row
|
||||
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
|
||||
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Revert 'type' column addition to the 'channel' table
|
||||
op.drop_column("channel", "type")
|
||||
op.drop_column("message", "parent_id")
|
||||
op.drop_table("message_reaction")
|
||||
op.drop_table("channel_member")
|
||||
op.drop_column('channel', 'type')
|
||||
op.drop_column('message', 'parent_id')
|
||||
op.drop_table('message_reaction')
|
||||
op.drop_table('channel_member')
|
||||
|
||||
@@ -6,68 +6,73 @@ Create Date: 2025-11-17 03:45:25.123939
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "37f288994c47"
|
||||
down_revision: Union[str, None] = "a5c220713937"
|
||||
revision: str = '37f288994c47'
|
||||
down_revision: Union[str, None] = 'a5c220713937'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'group_member' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
# 1. Create new table
|
||||
op.create_table(
|
||||
"group_member",
|
||||
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
'group_member',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column(
|
||||
"group_id",
|
||||
'group_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey("group.id", ondelete="CASCADE"),
|
||||
sa.ForeignKey('group.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
'user_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.UniqueConstraint('group_id', 'user_id', name='uq_group_member_group_user'),
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
# 2. Read existing group with user_ids JSON column
|
||||
group_table = sa.Table(
|
||||
"group",
|
||||
'group',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('user_ids', sa.JSON()), # JSON stored as text in SQLite + PG
|
||||
)
|
||||
|
||||
results = connection.execute(
|
||||
sa.select(group_table.c.id, group_table.c.user_ids)
|
||||
).fetchall()
|
||||
results = connection.execute(sa.select(group_table.c.id, group_table.c.user_ids)).fetchall()
|
||||
|
||||
print(results)
|
||||
|
||||
# 3. Insert members into group_member table
|
||||
gm_table = sa.Table(
|
||||
"group_member",
|
||||
'group_member',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("group_id", sa.Text()),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("created_at", sa.BigInteger()),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('group_id', sa.Text()),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('created_at', sa.BigInteger()),
|
||||
sa.Column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
@@ -86,11 +91,11 @@ def upgrade() -> None:
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"group_id": group_id,
|
||||
"user_id": uid,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
'id': str(uuid.uuid4()),
|
||||
'group_id': group_id,
|
||||
'user_id': uid,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}
|
||||
for uid in user_ids
|
||||
]
|
||||
@@ -99,47 +104,41 @@ def upgrade() -> None:
|
||||
connection.execute(gm_table.insert(), rows)
|
||||
|
||||
# 4. Optionally drop the old column
|
||||
with op.batch_alter_table("group") as batch:
|
||||
batch.drop_column("user_ids")
|
||||
with op.batch_alter_table('group') as batch:
|
||||
batch.drop_column('user_ids')
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Reverse: restore user_ids column
|
||||
with op.batch_alter_table("group") as batch:
|
||||
batch.add_column(sa.Column("user_ids", sa.JSON()))
|
||||
with op.batch_alter_table('group') as batch:
|
||||
batch.add_column(sa.Column('user_ids', sa.JSON()))
|
||||
|
||||
connection = op.get_bind()
|
||||
gm_table = sa.Table(
|
||||
"group_member",
|
||||
'group_member',
|
||||
sa.MetaData(),
|
||||
sa.Column("group_id", sa.Text()),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("created_at", sa.BigInteger()),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
sa.Column('group_id', sa.Text()),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('created_at', sa.BigInteger()),
|
||||
sa.Column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
group_table = sa.Table(
|
||||
"group",
|
||||
'group',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("user_ids", sa.JSON()),
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('user_ids', sa.JSON()),
|
||||
)
|
||||
|
||||
# Build JSON arrays again
|
||||
results = connection.execute(sa.select(group_table.c.id)).fetchall()
|
||||
|
||||
for (group_id,) in results:
|
||||
members = connection.execute(
|
||||
sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)
|
||||
).fetchall()
|
||||
members = connection.execute(sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)).fetchall()
|
||||
|
||||
member_ids = [m[0] for m in members]
|
||||
|
||||
connection.execute(
|
||||
group_table.update()
|
||||
.where(group_table.c.id == group_id)
|
||||
.values(user_ids=member_ids)
|
||||
)
|
||||
connection.execute(group_table.update().where(group_table.c.id == group_id).values(user_ids=member_ids))
|
||||
|
||||
# Drop the new table
|
||||
op.drop_table("group_member")
|
||||
op.drop_table('group_member')
|
||||
|
||||
@@ -8,72 +8,57 @@ Create Date: 2025-09-08 14:19:59.583921
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "38d63c18f30f"
|
||||
down_revision: Union[str, None] = "3af16a1c9fb6"
|
||||
revision: str = '38d63c18f30f'
|
||||
down_revision: Union[str, None] = '3af16a1c9fb6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = inspector.get_columns("user")
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
pk_columns = inspector.get_pk_constraint("user")["constrained_columns"]
|
||||
id_column = next((col for col in columns if col["name"] == "id"), None)
|
||||
# ── Create oauth_session table (idempotent) ───────────────────────
|
||||
if 'oauth_session' not in existing_tables:
|
||||
op.create_table(
|
||||
'oauth_session',
|
||||
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider', sa.Text(), nullable=False),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
if id_column and not id_column.get("unique", False):
|
||||
unique_constraints = inspector.get_unique_constraints("user")
|
||||
unique_columns = {tuple(u["column_names"]) for u in unique_constraints}
|
||||
|
||||
with op.batch_alter_table("user") as batch_op:
|
||||
# If primary key is wrong, drop it
|
||||
if pk_columns and pk_columns != ["id"]:
|
||||
batch_op.drop_constraint(
|
||||
inspector.get_pk_constraint("user")["name"], type_="primary"
|
||||
)
|
||||
|
||||
# Add unique constraint if missing
|
||||
if ("id",) not in unique_columns:
|
||||
batch_op.create_unique_constraint("uq_user_id", ["id"])
|
||||
|
||||
# Re-create correct primary key
|
||||
batch_op.create_primary_key("pk_user_id", ["id"])
|
||||
|
||||
# Create oauth_session table
|
||||
op.create_table(
|
||||
"oauth_session",
|
||||
sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("provider", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
# Create indexes (idempotent — no-ops when table was just created
|
||||
# with the columns above, and safe to call if indexes already exist).
|
||||
existing_indexes = (
|
||||
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
|
||||
)
|
||||
|
||||
# Create indexes for better performance
|
||||
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
|
||||
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
|
||||
op.create_index(
|
||||
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
|
||||
)
|
||||
if 'idx_oauth_session_user_id' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
|
||||
if 'idx_oauth_session_expires_at' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
|
||||
if 'idx_oauth_session_user_provider' not in existing_indexes:
|
||||
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes first
|
||||
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
|
||||
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
|
||||
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
|
||||
op.drop_index('idx_oauth_session_user_provider', table_name='oauth_session')
|
||||
op.drop_index('idx_oauth_session_expires_at', table_name='oauth_session')
|
||||
op.drop_index('idx_oauth_session_user_id', table_name='oauth_session')
|
||||
|
||||
# Drop the table
|
||||
op.drop_table("oauth_session")
|
||||
op.drop_table('oauth_session')
|
||||
|
||||
@@ -6,15 +6,15 @@ Create Date: 2024-10-09 21:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, select, update, column
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
import json
|
||||
|
||||
revision = "3ab32c4b8f59"
|
||||
down_revision = "1af9b942657b"
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.sql import column, select, table, update
|
||||
|
||||
revision = '3ab32c4b8f59'
|
||||
down_revision = '1af9b942657b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -24,58 +24,55 @@ def upgrade():
|
||||
inspector = Inspector.from_engine(conn)
|
||||
|
||||
# Inspecting the 'tag' table constraints and structure
|
||||
existing_pk = inspector.get_pk_constraint("tag")
|
||||
unique_constraints = inspector.get_unique_constraints("tag")
|
||||
existing_indexes = inspector.get_indexes("tag")
|
||||
existing_pk = inspector.get_pk_constraint('tag')
|
||||
unique_constraints = inspector.get_unique_constraints('tag')
|
||||
existing_indexes = inspector.get_indexes('tag')
|
||||
|
||||
print(f"Primary Key: {existing_pk}")
|
||||
print(f"Unique Constraints: {unique_constraints}")
|
||||
print(f"Indexes: {existing_indexes}")
|
||||
print(f'Primary Key: {existing_pk}')
|
||||
print(f'Unique Constraints: {unique_constraints}')
|
||||
print(f'Indexes: {existing_indexes}')
|
||||
|
||||
with op.batch_alter_table("tag", schema=None) as batch_op:
|
||||
with op.batch_alter_table('tag', schema=None) as batch_op:
|
||||
# Drop existing primary key constraint if it exists
|
||||
if existing_pk and existing_pk.get("constrained_columns"):
|
||||
pk_name = existing_pk.get("name")
|
||||
if existing_pk and existing_pk.get('constrained_columns'):
|
||||
pk_name = existing_pk.get('name')
|
||||
if pk_name:
|
||||
print(f"Dropping primary key constraint: {pk_name}")
|
||||
batch_op.drop_constraint(pk_name, type_="primary")
|
||||
print(f'Dropping primary key constraint: {pk_name}')
|
||||
batch_op.drop_constraint(pk_name, type_='primary')
|
||||
|
||||
# Now create the new primary key with the combination of 'id' and 'user_id'
|
||||
print("Creating new primary key with 'id' and 'user_id'.")
|
||||
batch_op.create_primary_key("pk_id_user_id", ["id", "user_id"])
|
||||
batch_op.create_primary_key('pk_id_user_id', ['id', 'user_id'])
|
||||
|
||||
# Drop unique constraints that could conflict with the new primary key
|
||||
for constraint in unique_constraints:
|
||||
if (
|
||||
constraint["name"] == "uq_id_user_id"
|
||||
constraint['name'] == 'uq_id_user_id'
|
||||
): # Adjust this name according to what is actually returned by the inspector
|
||||
print(f"Dropping unique constraint: {constraint['name']}")
|
||||
batch_op.drop_constraint(constraint["name"], type_="unique")
|
||||
print(f'Dropping unique constraint: {constraint["name"]}')
|
||||
batch_op.drop_constraint(constraint['name'], type_='unique')
|
||||
|
||||
for index in existing_indexes:
|
||||
if index["unique"]:
|
||||
if not any(
|
||||
constraint["name"] == index["name"]
|
||||
for constraint in unique_constraints
|
||||
):
|
||||
if index['unique']:
|
||||
if not any(constraint['name'] == index['name'] for constraint in unique_constraints):
|
||||
# You are attempting to drop unique indexes
|
||||
print(f"Dropping unique index: {index['name']}")
|
||||
batch_op.drop_index(index["name"])
|
||||
print(f'Dropping unique index: {index["name"]}')
|
||||
batch_op.drop_index(index['name'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = Inspector.from_engine(conn)
|
||||
|
||||
current_pk = inspector.get_pk_constraint("tag")
|
||||
current_pk = inspector.get_pk_constraint('tag')
|
||||
|
||||
with op.batch_alter_table("tag", schema=None) as batch_op:
|
||||
with op.batch_alter_table('tag', schema=None) as batch_op:
|
||||
# Drop the current primary key first, if it matches the one we know we added in upgrade
|
||||
if current_pk and "pk_id_user_id" == current_pk.get("name"):
|
||||
batch_op.drop_constraint("pk_id_user_id", type_="primary")
|
||||
if current_pk and 'pk_id_user_id' == current_pk.get('name'):
|
||||
batch_op.drop_constraint('pk_id_user_id', type_='primary')
|
||||
|
||||
# Restore the original primary key
|
||||
batch_op.create_primary_key("pk_id", ["id"])
|
||||
batch_op.create_primary_key('pk_id', ['id'])
|
||||
|
||||
# Since primary key on just 'id' is restored, we now add back any unique constraints if necessary
|
||||
batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"])
|
||||
batch_op.create_unique_constraint('uq_id_user_id', ['id', 'user_id'])
|
||||
|
||||
@@ -8,25 +8,33 @@ Create Date: 2025-08-21 02:07:18.078283
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3af16a1c9fb6"
|
||||
down_revision: Union[str, None] = "018012973d35"
|
||||
revision: str = '3af16a1c9fb6'
|
||||
down_revision: Union[str, None] = '018012973d35'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user", sa.Column("username", sa.String(length=50), nullable=True))
|
||||
op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
|
||||
op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
|
||||
op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
if 'username' not in user_cols:
|
||||
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
|
||||
if 'bio' not in user_cols:
|
||||
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
|
||||
if 'gender' not in user_cols:
|
||||
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
|
||||
if 'date_of_birth' not in user_cols:
|
||||
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "username")
|
||||
op.drop_column("user", "bio")
|
||||
op.drop_column("user", "gender")
|
||||
op.drop_column("user", "date_of_birth")
|
||||
op.drop_column('user', 'username')
|
||||
op.drop_column('user', 'bio')
|
||||
op.drop_column('user', 'gender')
|
||||
op.drop_column('user', 'date_of_birth')
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add knowledge_directory table
|
||||
|
||||
Revision ID: 3c9b0ca343fd
|
||||
Revises: a0b1c2d3e4f5
|
||||
Create Date: 2026-05-13 21:58:40.832482
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3c9b0ca343fd'
|
||||
down_revision: Union[str, None] = 'a0b1c2d3e4f5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'knowledge_directory' not in existing_tables:
|
||||
# Create knowledge_directory table
|
||||
op.create_table(
|
||||
'knowledge_directory',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('knowledge_id', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint(
|
||||
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
|
||||
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
|
||||
|
||||
# Add directory_id column to knowledge_file
|
||||
kf_cols = {c['name'] for c in inspector.get_columns('knowledge_file')}
|
||||
if 'directory_id' not in kf_cols:
|
||||
with op.batch_alter_table('knowledge_file') as batch:
|
||||
batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
'fk_knowledge_file_directory_id',
|
||||
'knowledge_directory',
|
||||
['directory_id'],
|
||||
['id'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
batch.create_index('ix_knowledge_file_directory_id', ['directory_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove directory_id from knowledge_file
|
||||
with op.batch_alter_table('knowledge_file') as batch:
|
||||
batch.drop_index('ix_knowledge_file_directory_id')
|
||||
batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey')
|
||||
batch.drop_column('directory_id')
|
||||
|
||||
# Drop knowledge_directory table
|
||||
op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory')
|
||||
op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory')
|
||||
op.drop_table('knowledge_directory')
|
||||
@@ -6,50 +6,56 @@ Create Date: 2025-12-02 06:54:19.401334
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
import open_webui.internal.db
|
||||
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3e0e00844bb0"
|
||||
down_revision: Union[str, None] = "90ef40d4714e"
|
||||
revision: str = '3e0e00844bb0'
|
||||
down_revision: Union[str, None] = '90ef40d4714e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'knowledge_file' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
op.create_table(
|
||||
"knowledge_file",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
'knowledge_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"knowledge_id",
|
||||
'knowledge_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey("knowledge.id", ondelete="CASCADE"),
|
||||
sa.ForeignKey('knowledge.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"file_id",
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey("file.id", ondelete="CASCADE"),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index("ix_knowledge_file_knowledge_id", "knowledge_id"),
|
||||
sa.Index("ix_knowledge_file_file_id", "file_id"),
|
||||
sa.Index("ix_knowledge_file_user_id", "user_id"),
|
||||
sa.Index('ix_knowledge_file_knowledge_id', 'knowledge_id'),
|
||||
sa.Index('ix_knowledge_file_file_id', 'file_id'),
|
||||
sa.Index('ix_knowledge_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint(
|
||||
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
|
||||
'knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file'
|
||||
), # prevent duplicate entries
|
||||
)
|
||||
|
||||
@@ -57,35 +63,33 @@ def upgrade() -> None:
|
||||
|
||||
# 2. Read existing group with user_ids JSON column
|
||||
knowledge_table = sa.Table(
|
||||
"knowledge",
|
||||
'knowledge',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("data", sa.JSON()), # JSON stored as text in SQLite + PG
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('data', sa.JSON()), # JSON stored as text in SQLite + PG
|
||||
)
|
||||
|
||||
results = connection.execute(
|
||||
sa.select(
|
||||
knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data
|
||||
)
|
||||
sa.select(knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data)
|
||||
).fetchall()
|
||||
|
||||
# 3. Insert members into group_member table
|
||||
kf_table = sa.Table(
|
||||
"knowledge_file",
|
||||
'knowledge_file',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("knowledge_id", sa.Text()),
|
||||
sa.Column("file_id", sa.Text()),
|
||||
sa.Column("created_at", sa.BigInteger()),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('knowledge_id', sa.Text()),
|
||||
sa.Column('file_id', sa.Text()),
|
||||
sa.Column('created_at', sa.BigInteger()),
|
||||
sa.Column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
file_table = sa.Table(
|
||||
"file",
|
||||
'file',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column('id', sa.Text()),
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
@@ -102,50 +106,48 @@ def upgrade() -> None:
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
file_ids = data.get("file_ids", [])
|
||||
file_ids = data.get('file_ids', [])
|
||||
|
||||
for file_id in file_ids:
|
||||
file_exists = connection.execute(
|
||||
sa.select(file_table.c.id).where(file_table.c.id == file_id)
|
||||
).fetchone()
|
||||
file_exists = connection.execute(sa.select(file_table.c.id).where(file_table.c.id == file_id)).fetchone()
|
||||
|
||||
if not file_exists:
|
||||
continue # skip non-existing files
|
||||
|
||||
row = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"knowledge_id": knowledge_id,
|
||||
"file_id": file_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
'id': str(uuid.uuid4()),
|
||||
'user_id': user_id,
|
||||
'knowledge_id': knowledge_id,
|
||||
'file_id': file_id,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}
|
||||
connection.execute(kf_table.insert().values(**row))
|
||||
|
||||
with op.batch_alter_table("knowledge") as batch:
|
||||
batch.drop_column("data")
|
||||
with op.batch_alter_table('knowledge') as batch:
|
||||
batch.drop_column('data')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Add back the old data column
|
||||
op.add_column("knowledge", sa.Column("data", sa.JSON(), nullable=True))
|
||||
op.add_column('knowledge', sa.Column('data', sa.JSON(), nullable=True))
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
# 2. Read knowledge_file entries and reconstruct data JSON
|
||||
knowledge_table = sa.Table(
|
||||
"knowledge",
|
||||
'knowledge',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("data", sa.JSON()),
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('data', sa.JSON()),
|
||||
)
|
||||
|
||||
kf_table = sa.Table(
|
||||
"knowledge_file",
|
||||
'knowledge_file',
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("knowledge_id", sa.Text()),
|
||||
sa.Column("file_id", sa.Text()),
|
||||
sa.Column('id', sa.Text()),
|
||||
sa.Column('knowledge_id', sa.Text()),
|
||||
sa.Column('file_id', sa.Text()),
|
||||
)
|
||||
|
||||
results = connection.execute(sa.select(knowledge_table.c.id)).fetchall()
|
||||
@@ -157,13 +159,9 @@ def downgrade() -> None:
|
||||
|
||||
file_ids_list = [fid for (fid,) in file_ids]
|
||||
|
||||
data_json = {"file_ids": file_ids_list}
|
||||
data_json = {'file_ids': file_ids_list}
|
||||
|
||||
connection.execute(
|
||||
knowledge_table.update()
|
||||
.where(knowledge_table.c.id == knowledge_id)
|
||||
.values(data=data_json)
|
||||
)
|
||||
connection.execute(knowledge_table.update().where(knowledge_table.c.id == knowledge_id).values(data=data_json))
|
||||
|
||||
# 3. Drop the knowledge_file table
|
||||
op.drop_table("knowledge_file")
|
||||
op.drop_table('knowledge_file')
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""add missing primary keys to legacy peewee tables
|
||||
|
||||
Revision ID: 461111b60977
|
||||
Revises: 3c9b0ca343fd
|
||||
Create Date: 2026-05-14 04:38:14.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '461111b60977'
|
||||
down_revision: Union[str, None] = '3c9b0ca343fd'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Tables bootstrapped by the old Peewee migration layer that may have
|
||||
# UNIQUE(id) but no PRIMARY KEY constraint. Fresh Alembic installs
|
||||
# already have correct PKs from 7e5b5dc7342b_init.py.
|
||||
# 'tag' uses a composite PK since the same tag name can exist for multiple users.
|
||||
LEGACY_TABLES = {
|
||||
'auth': ['id'],
|
||||
'chat': ['id'],
|
||||
'chatidtag': ['id'],
|
||||
'document': ['id'],
|
||||
'file': ['id'],
|
||||
'function': ['id'],
|
||||
'memory': ['id'],
|
||||
'model': ['id'],
|
||||
'prompt': ['id'],
|
||||
'tag': ['id', 'user_id'],
|
||||
'tool': ['id'],
|
||||
'user': ['id'],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
for table_name, pk_columns in LEGACY_TABLES.items():
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
|
||||
pk = inspector.get_pk_constraint(table_name)
|
||||
pk_cols = pk.get('constrained_columns', [])
|
||||
|
||||
# Already has the correct PK — nothing to do
|
||||
if sorted(pk_cols) == sorted(pk_columns):
|
||||
continue
|
||||
|
||||
# Check that all PK columns exist
|
||||
columns = {c['name'] for c in inspector.get_columns(table_name)}
|
||||
if not all(c in columns for c in pk_columns):
|
||||
continue
|
||||
|
||||
print(f"Promoting UNIQUE(id) -> PRIMARY KEY({', '.join(pk_columns)}) for '{table_name}'")
|
||||
|
||||
conn.execute(sa.text(f'DROP TABLE IF EXISTS _alembic_tmp_{table_name}'))
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
# Drop existing PK if any (e.g. on wrong column)
|
||||
if pk_cols and pk.get('name'):
|
||||
batch_op.drop_constraint(pk['name'], type_='primary')
|
||||
|
||||
batch_op.create_primary_key(f'pk_{table_name}', pk_columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Downgrade is a no-op — we don't want to remove PKs
|
||||
pass
|
||||
+33
-17
@@ -6,62 +6,78 @@ Create Date: 2024-10-23 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "4ace53fd72c8"
|
||||
down_revision = "af906e964978"
|
||||
revision = '4ace53fd72c8'
|
||||
down_revision = 'af906e964978'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {c['name']: c for c in inspector.get_columns('folder')}
|
||||
|
||||
created_at_col = columns.get('created_at')
|
||||
if not created_at_col:
|
||||
return
|
||||
|
||||
# Only convert if still DateTime — skip if already BigInteger
|
||||
if isinstance(created_at_col['type'], sa.BigInteger):
|
||||
return
|
||||
|
||||
# Perform safe alterations using batch operation
|
||||
with op.batch_alter_table("folder", schema=None) as batch_op:
|
||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||
# Step 1: Remove server defaults for created_at and updated_at
|
||||
batch_op.alter_column(
|
||||
"created_at",
|
||||
'created_at',
|
||||
server_default=None, # Removing server default
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"updated_at",
|
||||
'updated_at',
|
||||
server_default=None, # Removing server default
|
||||
)
|
||||
|
||||
# Step 2: Change the column types to BigInteger for created_at
|
||||
batch_op.alter_column(
|
||||
"created_at",
|
||||
'created_at',
|
||||
type_=sa.BigInteger(),
|
||||
existing_type=sa.DateTime(),
|
||||
existing_nullable=False,
|
||||
postgresql_using="extract(epoch from created_at)::bigint", # Conversion for PostgreSQL
|
||||
postgresql_using='extract(epoch from created_at)::bigint', # Conversion for PostgreSQL
|
||||
)
|
||||
|
||||
# Change the column types to BigInteger for updated_at
|
||||
batch_op.alter_column(
|
||||
"updated_at",
|
||||
'updated_at',
|
||||
type_=sa.BigInteger(),
|
||||
existing_type=sa.DateTime(),
|
||||
existing_nullable=False,
|
||||
postgresql_using="extract(epoch from updated_at)::bigint", # Conversion for PostgreSQL
|
||||
postgresql_using='extract(epoch from updated_at)::bigint', # Conversion for PostgreSQL
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Downgrade: Convert columns back to DateTime and restore defaults
|
||||
with op.batch_alter_table("folder", schema=None) as batch_op:
|
||||
# Convert columns back to DateTime and restore defaults. Mirrors the
|
||||
# upgrade's postgresql_using cast — without it, Postgres can't
|
||||
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
|
||||
with op.batch_alter_table('folder', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"created_at",
|
||||
'created_at',
|
||||
type_=sa.DateTime(),
|
||||
existing_type=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||
server_default=sa.func.now(),
|
||||
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"updated_at",
|
||||
'updated_at',
|
||||
type_=sa.DateTime(),
|
||||
existing_type=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.func.now(), # Restoring server default on downgrade
|
||||
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
|
||||
)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add pinned_note table
|
||||
|
||||
Revision ID: 4de81c2a3af1
|
||||
Revises: 56359461a091
|
||||
Create Date: 2026-05-09 04:29:27.651341
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4de81c2a3af1'
|
||||
down_revision: Union[str, None] = '56359461a091'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import insert, select, update
|
||||
from sqlalchemy.sql import column, table
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'pinned_note' not in existing_tables:
|
||||
op.create_table(
|
||||
'pinned_note',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
|
||||
)
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table(
|
||||
'pinned_note',
|
||||
column('id', sa.Text),
|
||||
column('user_id', sa.Text),
|
||||
column('note_id', sa.Text),
|
||||
column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
notes = conn.execute(
|
||||
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
|
||||
).fetchall()
|
||||
|
||||
if notes:
|
||||
now = int(time.time_ns())
|
||||
conn.execute(
|
||||
insert(pinned_note_table),
|
||||
[
|
||||
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
|
||||
for note in notes
|
||||
],
|
||||
)
|
||||
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_pinned')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table('pinned_note', column('note_id', sa.Text))
|
||||
|
||||
notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall()
|
||||
|
||||
for note in notes:
|
||||
conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True))
|
||||
|
||||
op.drop_table('pinned_note')
|
||||
@@ -0,0 +1,110 @@
|
||||
"""add calendar tables
|
||||
|
||||
Revision ID: 56359461a091
|
||||
Revises: c1d2e3f4a5b6
|
||||
Create Date: 2026-04-19 16:20:58.162045
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '56359461a091'
|
||||
down_revision: Union[str, None] = 'c1d2e3f4a5b6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _index_exists(inspector, index_name, table_name):
|
||||
"""Check if an index already exists on the given table."""
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'calendar' not in tables:
|
||||
op.create_table(
|
||||
'calendar',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
|
||||
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
|
||||
|
||||
if 'calendar_event' not in tables:
|
||||
op.create_table(
|
||||
'calendar_event',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('calendar_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('end_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('all_day', sa.Boolean(), nullable=False),
|
||||
sa.Column('rrule', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar_event' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_event_calendar', 'calendar_event'):
|
||||
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
|
||||
if not _index_exists(inspector, 'ix_calendar_event_user_date', 'calendar_event'):
|
||||
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
|
||||
|
||||
if 'calendar_event_attendee' not in tables:
|
||||
op.create_table(
|
||||
'calendar_event_attendee',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('event_id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'calendar_event_attendee' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
|
||||
op.create_index(
|
||||
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_calendar_event_attendee_user', table_name='calendar_event_attendee')
|
||||
op.drop_table('calendar_event_attendee')
|
||||
op.drop_index('ix_calendar_event_user_date', table_name='calendar_event')
|
||||
op.drop_index('ix_calendar_event_calendar', table_name='calendar_event')
|
||||
op.drop_table('calendar_event')
|
||||
op.drop_index('ix_calendar_user', table_name='calendar')
|
||||
op.drop_table('calendar')
|
||||
@@ -6,43 +6,49 @@ Create Date: 2024-12-22 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "57c599a3cb57"
|
||||
down_revision = "922e7a387820"
|
||||
revision = '57c599a3cb57'
|
||||
down_revision = '922e7a387820'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"channel",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("name", sa.Text()),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
op.create_table(
|
||||
"message",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("channel_id", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text()),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
if 'channel' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('name', sa.Text()),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if 'message' not in existing_tables:
|
||||
op.create_table(
|
||||
'message',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text()),
|
||||
sa.Column('channel_id', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text()),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("channel")
|
||||
op.drop_table('channel')
|
||||
|
||||
op.drop_table("message")
|
||||
op.drop_table('message')
|
||||
|
||||
@@ -8,46 +8,51 @@ Create Date: 2025-12-10 15:11:39.424601
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6283dc0e4d8d"
|
||||
down_revision: Union[str, None] = "3e0e00844bb0"
|
||||
revision: str = '6283dc0e4d8d'
|
||||
down_revision: Union[str, None] = '3e0e00844bb0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"channel_file",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("channel.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"file_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("file.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index("ix_channel_file_channel_id", "channel_id"),
|
||||
sa.Index("ix_channel_file_file_id", "file_id"),
|
||||
sa.Index("ix_channel_file_user_id", "user_id"),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint(
|
||||
"channel_id", "file_id", name="uq_channel_file_channel_file"
|
||||
), # prevent duplicate entries
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'channel_file' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_channel_file_channel_id', 'channel_id'),
|
||||
sa.Index('ix_channel_file_file_id', 'file_id'),
|
||||
sa.Index('ix_channel_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint(
|
||||
'channel_id', 'file_id', name='uq_channel_file_channel_file'
|
||||
), # prevent duplicate entries
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("channel_file")
|
||||
op.drop_table('channel_file')
|
||||
|
||||
@@ -6,74 +6,80 @@ Create Date: 2024-10-01 14:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column, select
|
||||
import json
|
||||
|
||||
revision = "6a39f3d8e55c"
|
||||
down_revision = "c0fbf31ca0db"
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import column, select, table
|
||||
|
||||
revision = '6a39f3d8e55c'
|
||||
down_revision = 'c0fbf31ca0db'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Creating the 'knowledge' table
|
||||
print("Creating knowledge table")
|
||||
knowledge_table = op.create_table(
|
||||
"knowledge",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
print("Migrating data from document table to knowledge table")
|
||||
# Representation of the existing 'document' table
|
||||
document_table = table(
|
||||
"document",
|
||||
column("collection_name", sa.String()),
|
||||
column("user_id", sa.String()),
|
||||
column("name", sa.String()),
|
||||
column("title", sa.Text()),
|
||||
column("content", sa.Text()),
|
||||
column("timestamp", sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Select all from existing document table
|
||||
documents = op.get_bind().execute(
|
||||
select(
|
||||
document_table.c.collection_name,
|
||||
document_table.c.user_id,
|
||||
document_table.c.name,
|
||||
document_table.c.title,
|
||||
document_table.c.content,
|
||||
document_table.c.timestamp,
|
||||
if 'knowledge' not in existing_tables:
|
||||
# Creating the 'knowledge' table
|
||||
print('Creating knowledge table')
|
||||
knowledge_table = op.create_table(
|
||||
'knowledge',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Insert data into knowledge table from document table
|
||||
for doc in documents:
|
||||
op.get_bind().execute(
|
||||
knowledge_table.insert().values(
|
||||
id=doc.collection_name,
|
||||
user_id=doc.user_id,
|
||||
description=doc.name,
|
||||
meta={
|
||||
"legacy": True,
|
||||
"document": True,
|
||||
"tags": json.loads(doc.content or "{}").get("tags", []),
|
||||
},
|
||||
name=doc.title,
|
||||
created_at=doc.timestamp,
|
||||
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
|
||||
print('Migrating data from document table to knowledge table')
|
||||
# Representation of the existing 'document' table
|
||||
document_table = table(
|
||||
'document',
|
||||
column('collection_name', sa.String()),
|
||||
column('user_id', sa.String()),
|
||||
column('name', sa.String()),
|
||||
column('title', sa.Text()),
|
||||
column('content', sa.Text()),
|
||||
column('timestamp', sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Select all from existing document table
|
||||
documents = conn.execute(
|
||||
select(
|
||||
document_table.c.collection_name,
|
||||
document_table.c.user_id,
|
||||
document_table.c.name,
|
||||
document_table.c.title,
|
||||
document_table.c.content,
|
||||
document_table.c.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Insert data into knowledge table from document table
|
||||
for doc in documents:
|
||||
conn.execute(
|
||||
knowledge_table.insert().values(
|
||||
id=doc.collection_name,
|
||||
user_id=doc.user_id,
|
||||
description=doc.name,
|
||||
meta={
|
||||
'legacy': True,
|
||||
'document': True,
|
||||
'tags': json.loads(doc.content or '{}').get('tags', []),
|
||||
},
|
||||
name=doc.title,
|
||||
created_at=doc.timestamp,
|
||||
updated_at=doc.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("knowledge")
|
||||
op.drop_table('knowledge')
|
||||
|
||||
@@ -6,21 +6,26 @@ Create Date: 2024-12-23 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "7826ab40b532"
|
||||
down_revision = "57c599a3cb57"
|
||||
revision = '7826ab40b532'
|
||||
down_revision = '57c599a3cb57'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"file",
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
if 'access_control' not in file_cols:
|
||||
op.add_column(
|
||||
'file',
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("file", "access_control")
|
||||
op.drop_column('file', 'access_control')
|
||||
|
||||
@@ -1,204 +1,192 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 7e5b5dc7342b
|
||||
Revises:
|
||||
Create Date: 2024-06-24 13:15:33.808998
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
# Initial bootstrap migration version.
|
||||
# Revision ID: 7e5b5dc7342b
|
||||
# Revises: (none)
|
||||
# Created on: 2024-06-24 13:15:33.808998
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
import open_webui.internal.db # noqa: F401
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import open_webui.internal.db
|
||||
from open_webui.internal.db import JSONField
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7e5b5dc7342b"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = '7e5b5dc7342b'
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
# Initial schema table declarations
|
||||
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
|
||||
(
|
||||
'auth',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('password', sa.Text(), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'chat',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('share_id', sa.Text(), nullable=True),
|
||||
sa.Column('archived', sa.Boolean(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
|
||||
),
|
||||
(
|
||||
'chatidtag',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('tag_name', sa.String(), nullable=True),
|
||||
sa.Column('chat_id', sa.String(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'document',
|
||||
[
|
||||
sa.Column('collection_name', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('filename', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
|
||||
),
|
||||
(
|
||||
'file',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('filename', sa.Text(), nullable=True),
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'function',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('type', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('valves', JSONField(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_global', sa.Boolean(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'memory',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'model',
|
||||
[
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('base_model_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('params', JSONField(), nullable=True),
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'prompt',
|
||||
[
|
||||
sa.Column('command', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('command')],
|
||||
),
|
||||
(
|
||||
'tag',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('data', sa.Text(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'tool',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('specs', JSONField(), nullable=True),
|
||||
sa.Column('meta', JSONField(), nullable=True),
|
||||
sa.Column('valves', JSONField(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
],
|
||||
[sa.PrimaryKeyConstraint('id')],
|
||||
),
|
||||
(
|
||||
'user',
|
||||
[
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('role', sa.String(), nullable=True),
|
||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||
sa.Column('last_active_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('api_key', sa.String(), nullable=True),
|
||||
sa.Column('settings', JSONField(), nullable=True),
|
||||
sa.Column('info', JSONField(), nullable=True),
|
||||
sa.Column('oauth_sub', sa.Text(), nullable=True),
|
||||
],
|
||||
[
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('api_key'),
|
||||
sa.UniqueConstraint('oauth_sub'),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- migration execution ---
|
||||
def upgrade() -> None: # deploy initial schema tables
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if "auth" not in existing_tables:
|
||||
op.create_table(
|
||||
"auth",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("email", sa.String(), nullable=True),
|
||||
sa.Column("password", sa.Text(), nullable=True),
|
||||
sa.Column("active", sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "chat" not in existing_tables:
|
||||
op.create_table(
|
||||
"chat",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("chat", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("share_id", sa.Text(), nullable=True),
|
||||
sa.Column("archived", sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("share_id"),
|
||||
)
|
||||
|
||||
if "chatidtag" not in existing_tables:
|
||||
op.create_table(
|
||||
"chatidtag",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("tag_name", sa.String(), nullable=True),
|
||||
sa.Column("chat_id", sa.String(), nullable=True),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("timestamp", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "document" not in existing_tables:
|
||||
op.create_table(
|
||||
"document",
|
||||
sa.Column("collection_name", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("filename", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("timestamp", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("collection_name"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
|
||||
if "file" not in existing_tables:
|
||||
op.create_table(
|
||||
"file",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("filename", sa.Text(), nullable=True),
|
||||
sa.Column("meta", JSONField(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "function" not in existing_tables:
|
||||
op.create_table(
|
||||
"function",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("meta", JSONField(), nullable=True),
|
||||
sa.Column("valves", JSONField(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("is_global", sa.Boolean(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "memory" not in existing_tables:
|
||||
op.create_table(
|
||||
"memory",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "model" not in existing_tables:
|
||||
op.create_table(
|
||||
"model",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=True),
|
||||
sa.Column("base_model_id", sa.Text(), nullable=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("params", JSONField(), nullable=True),
|
||||
sa.Column("meta", JSONField(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "prompt" not in existing_tables:
|
||||
op.create_table(
|
||||
"prompt",
|
||||
sa.Column("command", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("timestamp", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("command"),
|
||||
)
|
||||
|
||||
if "tag" not in existing_tables:
|
||||
op.create_table(
|
||||
"tag",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=True),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("data", sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "tool" not in existing_tables:
|
||||
op.create_table(
|
||||
"tool",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("specs", JSONField(), nullable=True),
|
||||
sa.Column("meta", JSONField(), nullable=True),
|
||||
sa.Column("valves", JSONField(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "user" not in existing_tables:
|
||||
op.create_table(
|
||||
"user",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=True),
|
||||
sa.Column("email", sa.String(), nullable=True),
|
||||
sa.Column("role", sa.String(), nullable=True),
|
||||
sa.Column("profile_image_url", sa.Text(), nullable=True),
|
||||
sa.Column("last_active_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("api_key", sa.String(), nullable=True),
|
||||
sa.Column("settings", JSONField(), nullable=True),
|
||||
sa.Column("info", JSONField(), nullable=True),
|
||||
sa.Column("oauth_sub", sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("api_key"),
|
||||
sa.UniqueConstraint("oauth_sub"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
for name, columns, constraints in _INITIAL_TABLES:
|
||||
if name not in existing_tables:
|
||||
op.create_table(name, *columns, *constraints)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("user")
|
||||
op.drop_table("tool")
|
||||
op.drop_table("tag")
|
||||
op.drop_table("prompt")
|
||||
op.drop_table("model")
|
||||
op.drop_table("memory")
|
||||
op.drop_table("function")
|
||||
op.drop_table("file")
|
||||
op.drop_table("document")
|
||||
op.drop_table("chatidtag")
|
||||
op.drop_table("chat")
|
||||
op.drop_table("auth")
|
||||
# ### end Alembic commands ###
|
||||
# --- rollback function ---
|
||||
def downgrade() -> None: # rollback initial schema tables
|
||||
for table_name, _, _ in reversed(_INITIAL_TABLES):
|
||||
op.drop_table(table_name)
|
||||
|
||||
+25
-20
@@ -8,41 +8,46 @@ Create Date: 2025-12-10 16:07:58.001282
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "81cc2ce44d79"
|
||||
down_revision: Union[str, None] = "6283dc0e4d8d"
|
||||
revision: str = '81cc2ce44d79'
|
||||
down_revision: Union[str, None] = '6283dc0e4d8d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Add message_id column to channel_file table
|
||||
with op.batch_alter_table("channel_file", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"message_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey(
|
||||
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
|
||||
),
|
||||
nullable=True,
|
||||
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
|
||||
if 'message_id' not in cf_cols:
|
||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'message_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Add data column to knowledge table
|
||||
with op.batch_alter_table("knowledge", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
|
||||
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
||||
if 'data' not in k_cols:
|
||||
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove message_id column from channel_file table
|
||||
with op.batch_alter_table("channel_file", schema=None) as batch_op:
|
||||
batch_op.drop_column("message_id")
|
||||
with op.batch_alter_table('channel_file', schema=None) as batch_op:
|
||||
batch_op.drop_column('message_id')
|
||||
|
||||
# Remove data column from knowledge table
|
||||
with op.batch_alter_table("knowledge", schema=None) as batch_op:
|
||||
batch_op.drop_column("data")
|
||||
with op.batch_alter_table('knowledge', schema=None) as batch_op:
|
||||
batch_op.drop_column('data')
|
||||
|
||||
@@ -6,100 +6,135 @@ Create Date: 2026-02-01 04:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
revision: str = "8452d01d26d7"
|
||||
down_revision: Union[str, None] = "374d2f66af06"
|
||||
revision: str = '8452d01d26d7'
|
||||
down_revision: Union[str, None] = '374d2f66af06'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BATCH_SIZE = 5000
|
||||
|
||||
|
||||
def _flush_batch(conn, table, batch):
|
||||
"""
|
||||
Insert a batch of messages, falling back to row-by-row on error.
|
||||
|
||||
Tries a single bulk insert first (fast path). If that fails (e.g. due to
|
||||
a duplicate key), falls back to individual inserts wrapped in savepoints
|
||||
so the rest of the batch can still succeed.
|
||||
"""
|
||||
savepoint = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(sa.insert(table), batch)
|
||||
savepoint.commit()
|
||||
return len(batch), 0
|
||||
except Exception:
|
||||
savepoint.rollback()
|
||||
# Batch failed - insert one-by-one to isolate the bad row(s)
|
||||
inserted = 0
|
||||
failed = 0
|
||||
for msg in batch:
|
||||
sp = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(sa.insert(table).values(**msg))
|
||||
sp.commit()
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
sp.rollback()
|
||||
failed += 1
|
||||
log.warning(f'Failed to insert message {msg["id"]}: {e}')
|
||||
return inserted, failed
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'chat_message' in existing_tables:
|
||||
return # Already created — skip everything
|
||||
|
||||
# Step 1: Create table
|
||||
op.create_table(
|
||||
"chat_message",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("chat_id", sa.Text(), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.Text(), index=True),
|
||||
sa.Column("role", sa.Text(), nullable=False),
|
||||
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.JSON(), nullable=True),
|
||||
sa.Column("output", sa.JSON(), nullable=True),
|
||||
sa.Column("model_id", sa.Text(), nullable=True, index=True),
|
||||
sa.Column("files", sa.JSON(), nullable=True),
|
||||
sa.Column("sources", sa.JSON(), nullable=True),
|
||||
sa.Column("embeds", sa.JSON(), nullable=True),
|
||||
sa.Column("done", sa.Boolean(), default=True),
|
||||
sa.Column("status_history", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.JSON(), nullable=True),
|
||||
sa.Column("usage", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), index=True),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"),
|
||||
'chat_message',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), nullable=False, index=True),
|
||||
sa.Column('user_id', sa.Text(), index=True),
|
||||
sa.Column('role', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.JSON(), nullable=True),
|
||||
sa.Column('output', sa.JSON(), nullable=True),
|
||||
sa.Column('model_id', sa.Text(), nullable=True, index=True),
|
||||
sa.Column('files', sa.JSON(), nullable=True),
|
||||
sa.Column('sources', sa.JSON(), nullable=True),
|
||||
sa.Column('embeds', sa.JSON(), nullable=True),
|
||||
sa.Column('done', sa.Boolean(), default=True),
|
||||
sa.Column('status_history', sa.JSON(), nullable=True),
|
||||
sa.Column('error', sa.JSON(), nullable=True),
|
||||
sa.Column('usage', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), index=True),
|
||||
sa.Column('updated_at', sa.BigInteger()),
|
||||
sa.ForeignKeyConstraint(['chat_id'], ['chat.id'], ondelete='CASCADE'),
|
||||
)
|
||||
|
||||
# Create composite indexes
|
||||
op.create_index(
|
||||
"chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"chat_message_model_created_idx", "chat_message", ["model_id", "created_at"]
|
||||
)
|
||||
op.create_index(
|
||||
"chat_message_user_created_idx", "chat_message", ["user_id", "created_at"]
|
||||
)
|
||||
op.create_index('chat_message_chat_parent_idx', 'chat_message', ['chat_id', 'parent_id'])
|
||||
op.create_index('chat_message_model_created_idx', 'chat_message', ['model_id', 'created_at'])
|
||||
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
|
||||
|
||||
# Step 2: Backfill from existing chats
|
||||
conn = op.get_bind()
|
||||
|
||||
chat_table = sa.table(
|
||||
"chat",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("chat", sa.JSON()),
|
||||
'chat',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('chat', sa.JSON()),
|
||||
)
|
||||
|
||||
chat_message_table = sa.table(
|
||||
"chat_message",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("chat_id", sa.Text()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("role", sa.Text()),
|
||||
sa.column("parent_id", sa.Text()),
|
||||
sa.column("content", sa.JSON()),
|
||||
sa.column("output", sa.JSON()),
|
||||
sa.column("model_id", sa.Text()),
|
||||
sa.column("files", sa.JSON()),
|
||||
sa.column("sources", sa.JSON()),
|
||||
sa.column("embeds", sa.JSON()),
|
||||
sa.column("done", sa.Boolean()),
|
||||
sa.column("status_history", sa.JSON()),
|
||||
sa.column("error", sa.JSON()),
|
||||
sa.column("usage", sa.JSON()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
sa.column("updated_at", sa.BigInteger()),
|
||||
'chat_message',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('chat_id', sa.Text()),
|
||||
sa.column('user_id', sa.Text()),
|
||||
sa.column('role', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('content', sa.JSON()),
|
||||
sa.column('output', sa.JSON()),
|
||||
sa.column('model_id', sa.Text()),
|
||||
sa.column('files', sa.JSON()),
|
||||
sa.column('sources', sa.JSON()),
|
||||
sa.column('embeds', sa.JSON()),
|
||||
sa.column('done', sa.Boolean()),
|
||||
sa.column('status_history', sa.JSON()),
|
||||
sa.column('error', sa.JSON()),
|
||||
sa.column('usage', sa.JSON()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
sa.column('updated_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
|
||||
chats = conn.execute(
|
||||
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
|
||||
~chat_table.c.user_id.like("shared-%")
|
||||
)
|
||||
).fetchall()
|
||||
# Stream rows instead of loading all into memory:
|
||||
# - yield_per: fetches rows in chunks via cursor.fetchmany() (all backends)
|
||||
# - stream_results: enables server-side cursors on PostgreSQL (no-op on SQLite)
|
||||
result = conn.execute(
|
||||
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat)
|
||||
.where(~chat_table.c.user_id.like('shared-%'))
|
||||
.execution_options(yield_per=1000, stream_results=True)
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
messages_inserted = 0
|
||||
messages_failed = 0
|
||||
messages_batch = []
|
||||
total_inserted = 0
|
||||
total_failed = 0
|
||||
|
||||
for chat_row in chats:
|
||||
for chat_row in result:
|
||||
chat_id = chat_row[0]
|
||||
user_id = chat_row[1]
|
||||
chat_data = chat_row[2]
|
||||
@@ -114,18 +149,23 @@ def upgrade() -> None:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
history = chat_data.get("history", {})
|
||||
messages = history.get("messages", {})
|
||||
history = chat_data.get('history', {})
|
||||
if not isinstance(history, dict):
|
||||
continue
|
||||
|
||||
messages = history.get('messages', {})
|
||||
if not isinstance(messages, dict):
|
||||
continue
|
||||
|
||||
for message_id, message in messages.items():
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
role = message.get("role")
|
||||
role = message.get('role')
|
||||
if not role:
|
||||
continue
|
||||
|
||||
timestamp = message.get("timestamp", now)
|
||||
timestamp = message.get('timestamp', now)
|
||||
|
||||
try:
|
||||
timestamp = int(float(timestamp))
|
||||
@@ -139,44 +179,48 @@ def upgrade() -> None:
|
||||
if timestamp < 1577836800 or timestamp > now + 86400:
|
||||
timestamp = now
|
||||
|
||||
# Use savepoint to allow individual insert failures without aborting transaction
|
||||
savepoint = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(
|
||||
sa.insert(chat_message_table).values(
|
||||
id=f"{chat_id}-{message_id}",
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
parent_id=message.get("parentId"),
|
||||
content=message.get("content"),
|
||||
output=message.get("output"),
|
||||
model_id=message.get("model"),
|
||||
files=message.get("files"),
|
||||
sources=message.get("sources"),
|
||||
embeds=message.get("embeds"),
|
||||
done=message.get("done", True),
|
||||
status_history=message.get("statusHistory"),
|
||||
error=message.get("error"),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
savepoint.commit()
|
||||
messages_inserted += 1
|
||||
except Exception as e:
|
||||
savepoint.rollback()
|
||||
messages_failed += 1
|
||||
log.warning(f"Failed to insert message {message_id}: {e}")
|
||||
continue
|
||||
messages_batch.append(
|
||||
{
|
||||
'id': f'{chat_id}-{message_id}',
|
||||
'chat_id': chat_id,
|
||||
'user_id': user_id,
|
||||
'role': role,
|
||||
'parent_id': message.get('parentId'),
|
||||
'content': message.get('content'),
|
||||
'output': message.get('output'),
|
||||
'model_id': message.get('model'),
|
||||
'files': message.get('files'),
|
||||
'sources': message.get('sources'),
|
||||
'embeds': message.get('embeds'),
|
||||
'done': message.get('done', True),
|
||||
'status_history': message.get('statusHistory'),
|
||||
'error': message.get('error'),
|
||||
'usage': message.get('usage'),
|
||||
'created_at': timestamp,
|
||||
'updated_at': timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
|
||||
)
|
||||
# Flush batch when full
|
||||
if len(messages_batch) >= BATCH_SIZE:
|
||||
inserted, failed = _flush_batch(conn, chat_message_table, messages_batch)
|
||||
total_inserted += inserted
|
||||
total_failed += failed
|
||||
if total_inserted % 50000 < BATCH_SIZE:
|
||||
log.info(f'Migration progress: {total_inserted} messages inserted...')
|
||||
messages_batch.clear()
|
||||
|
||||
# Flush remaining messages
|
||||
if messages_batch:
|
||||
inserted, failed = _flush_batch(conn, chat_message_table, messages_batch)
|
||||
total_inserted += inserted
|
||||
total_failed += failed
|
||||
|
||||
log.info(f'Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("chat_message_user_created_idx", table_name="chat_message")
|
||||
op.drop_index("chat_message_model_created_idx", table_name="chat_message")
|
||||
op.drop_index("chat_message_chat_parent_idx", table_name="chat_message")
|
||||
op.drop_table("chat_message")
|
||||
op.drop_index('chat_message_user_created_idx', table_name='chat_message')
|
||||
op.drop_index('chat_message_model_created_idx', table_name='chat_message')
|
||||
op.drop_index('chat_message_chat_parent_idx', table_name='chat_message')
|
||||
op.drop_table('chat_message')
|
||||
|
||||
+56
-49
@@ -8,73 +8,80 @@ Create Date: 2025-11-30 06:33:38.790341
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "90ef40d4714e"
|
||||
down_revision: Union[str, None] = "b10670c03dd5"
|
||||
revision: str = '90ef40d4714e'
|
||||
down_revision: Union[str, None] = 'b10670c03dd5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Update 'channel' table
|
||||
op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True))
|
||||
|
||||
op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True))
|
||||
op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True))
|
||||
|
||||
op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True))
|
||||
op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True))
|
||||
|
||||
op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True))
|
||||
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
|
||||
if 'is_private' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
|
||||
if 'archived_at' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
|
||||
if 'archived_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
|
||||
if 'deleted_at' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
|
||||
if 'deleted_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
|
||||
if 'updated_by' not in channel_cols:
|
||||
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
|
||||
|
||||
# Update 'channel_member' table
|
||||
op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True))
|
||||
op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
|
||||
if 'role' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
|
||||
if 'invited_by' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
|
||||
if 'invited_at' not in cm_cols:
|
||||
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
# Create 'channel_webhook' table
|
||||
op.create_table(
|
||||
"channel_webhook",
|
||||
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("channel.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("profile_image_url", sa.Text(), nullable=True),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
pass
|
||||
if 'channel_webhook' not in existing_tables:
|
||||
op.create_table(
|
||||
'channel_webhook',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'channel_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('channel.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('profile_image_url', sa.Text(), nullable=True),
|
||||
sa.Column('token', sa.Text(), nullable=False),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Downgrade 'channel' table
|
||||
op.drop_column("channel", "is_private")
|
||||
op.drop_column("channel", "archived_at")
|
||||
op.drop_column("channel", "archived_by")
|
||||
op.drop_column("channel", "deleted_at")
|
||||
op.drop_column("channel", "deleted_by")
|
||||
op.drop_column("channel", "updated_by")
|
||||
op.drop_column('channel', 'is_private')
|
||||
op.drop_column('channel', 'archived_at')
|
||||
op.drop_column('channel', 'archived_by')
|
||||
op.drop_column('channel', 'deleted_at')
|
||||
op.drop_column('channel', 'deleted_by')
|
||||
op.drop_column('channel', 'updated_by')
|
||||
|
||||
# Downgrade 'channel_member' table
|
||||
op.drop_column("channel_member", "role")
|
||||
op.drop_column("channel_member", "invited_by")
|
||||
op.drop_column("channel_member", "invited_at")
|
||||
op.drop_column('channel_member', 'role')
|
||||
op.drop_column('channel_member', 'invited_by')
|
||||
op.drop_column('channel_member', 'invited_at')
|
||||
|
||||
# Drop 'channel_webhook' table
|
||||
op.drop_table("channel_webhook")
|
||||
|
||||
pass
|
||||
op.drop_table('channel_webhook')
|
||||
|
||||
@@ -6,80 +6,65 @@ Create Date: 2024-11-14 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "922e7a387820"
|
||||
down_revision = "4ace53fd72c8"
|
||||
revision = '922e7a387820'
|
||||
down_revision = '4ace53fd72c8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"group",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("permissions", sa.JSON(), nullable=True),
|
||||
sa.Column("user_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'group' not in existing_tables:
|
||||
op.create_table(
|
||||
'group',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('permissions', sa.JSON(), nullable=True),
|
||||
sa.Column('user_ids', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'model' table
|
||||
op.add_column(
|
||||
"model",
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add 'is_active' column to 'model' table
|
||||
op.add_column(
|
||||
"model",
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.sql.expression.true(),
|
||||
),
|
||||
)
|
||||
model_cols = {c['name'] for c in inspector.get_columns('model')}
|
||||
if 'access_control' not in model_cols:
|
||||
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
if 'is_active' not in model_cols:
|
||||
op.add_column(
|
||||
'model',
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
||||
)
|
||||
|
||||
# Add 'access_control' column to 'knowledge' table
|
||||
op.add_column(
|
||||
"knowledge",
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
)
|
||||
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
|
||||
if 'access_control' not in knowledge_cols:
|
||||
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
|
||||
# Add 'access_control' column to 'prompt' table
|
||||
op.add_column(
|
||||
"prompt",
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
)
|
||||
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
|
||||
if 'access_control' not in prompt_cols:
|
||||
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
|
||||
# Add 'access_control' column to 'tools' table
|
||||
op.add_column(
|
||||
"tool",
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
)
|
||||
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
|
||||
if 'access_control' not in tool_cols:
|
||||
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("group")
|
||||
|
||||
# Drop 'access_control' column from 'model' table
|
||||
op.drop_column("model", "access_control")
|
||||
|
||||
# Drop 'is_active' column from 'model' table
|
||||
op.drop_column("model", "is_active")
|
||||
|
||||
# Drop 'access_control' column from 'knowledge' table
|
||||
op.drop_column("knowledge", "access_control")
|
||||
|
||||
# Drop 'access_control' column from 'prompt' table
|
||||
op.drop_column("prompt", "access_control")
|
||||
|
||||
# Drop 'access_control' column from 'tools' table
|
||||
op.drop_column("tool", "access_control")
|
||||
op.drop_table('group')
|
||||
op.drop_column('model', 'access_control')
|
||||
op.drop_column('model', 'is_active')
|
||||
op.drop_column('knowledge', 'access_control')
|
||||
op.drop_column('prompt', 'access_control')
|
||||
op.drop_column('tool', 'access_control')
|
||||
|
||||
@@ -6,28 +6,33 @@ Create Date: 2025-05-03 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "9f0c9cd09105"
|
||||
down_revision = "3781e22d8b01"
|
||||
revision = '9f0c9cd09105'
|
||||
down_revision = '3781e22d8b01'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"note",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'note' not in existing_tables:
|
||||
op.create_table(
|
||||
'note',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=True),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('access_control', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("note")
|
||||
op.drop_table('note')
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add memory user_id index
|
||||
|
||||
Revision ID: a0b1c2d3e4f5
|
||||
Revises: 4de81c2a3af1
|
||||
Create Date: 2025-09-15 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'a0b1c2d3e4f5'
|
||||
down_revision = '4de81c2a3af1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_indexes = {idx['name'] for idx in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_user_id' not in existing_indexes:
|
||||
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_memory_user_id', table_name='memory')
|
||||
@@ -8,13 +8,12 @@ Create Date: 2026-02-11 09:30:00.000000
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "f1e2d3c4b5a6"
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = 'f1e2d3c4b5a6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
@@ -22,24 +21,24 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
if "skill" not in existing_tables:
|
||||
if 'skill' not in existing_tables:
|
||||
op.create_table(
|
||||
"skill",
|
||||
sa.Column("id", sa.String(), nullable=False, primary_key=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
'skill',
|
||||
sa.Column('id', sa.String(), nullable=False, primary_key=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False, unique=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_index("idx_skill_user_id", "skill", ["user_id"])
|
||||
op.create_index("idx_skill_updated_at", "skill", ["updated_at"])
|
||||
op.create_index('idx_skill_user_id', 'skill', ['user_id'])
|
||||
op.create_index('idx_skill_updated_at', 'skill', ['updated_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_skill_updated_at", table_name="skill")
|
||||
op.drop_index("idx_skill_user_id", table_name="skill")
|
||||
op.drop_table("skill")
|
||||
op.drop_index('idx_skill_updated_at', table_name='skill')
|
||||
op.drop_index('idx_skill_user_id', table_name='skill')
|
||||
op.drop_table('skill')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add tasks and summary columns to chat table
|
||||
|
||||
Revision ID: a3dd5bedd151
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-03-29 22:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3dd5bedd151'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'tasks' not in columns:
|
||||
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||
if 'summary' not in columns:
|
||||
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'summary')
|
||||
op.drop_column('chat', 'tasks')
|
||||
+13
-11
@@ -8,27 +8,29 @@ Create Date: 2025-09-27 02:24:18.058455
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a5c220713937"
|
||||
down_revision: Union[str, None] = "38d63c18f30f"
|
||||
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:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
msg_cols = {c['name'] for c in inspector.get_columns('message')}
|
||||
|
||||
# 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
|
||||
if 'reply_to_id' not in msg_cols:
|
||||
op.add_column(
|
||||
'message',
|
||||
sa.Column('reply_to_id', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove 'reply_to_id' column from the 'message' table
|
||||
op.drop_column("message", "reply_to_id")
|
||||
|
||||
pass
|
||||
op.drop_column('message', 'reply_to_id')
|
||||
|
||||
@@ -6,46 +6,41 @@ Create Date: 2024-10-20 17:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# Revision identifiers, used by Alembic.
|
||||
revision = "af906e964978"
|
||||
down_revision = "c29facfe716b"
|
||||
revision = 'af906e964978'
|
||||
down_revision = 'c29facfe716b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### Create feedback table ###
|
||||
op.create_table(
|
||||
"feedback",
|
||||
sa.Column(
|
||||
"id", sa.Text(), primary_key=True
|
||||
), # Unique identifier for each feedback (TEXT type)
|
||||
sa.Column(
|
||||
"user_id", sa.Text(), nullable=True
|
||||
), # ID of the user providing the feedback (TEXT type)
|
||||
sa.Column(
|
||||
"version", sa.BigInteger(), default=0
|
||||
), # Version of feedback (BIGINT type)
|
||||
sa.Column("type", sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||
sa.Column("data", sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||
sa.Column(
|
||||
"meta", sa.JSON(), nullable=True
|
||||
), # Metadata for feedback (JSON type)
|
||||
sa.Column(
|
||||
"snapshot", sa.JSON(), nullable=True
|
||||
), # snapshot data for feedback (JSON type)
|
||||
sa.Column(
|
||||
"created_at", sa.BigInteger(), nullable=False
|
||||
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||
sa.Column(
|
||||
"updated_at", sa.BigInteger(), nullable=False
|
||||
), # Feedback update timestamp (BIGINT representing epoch)
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'feedback' not in existing_tables:
|
||||
# ### Create feedback table ###
|
||||
op.create_table(
|
||||
'feedback',
|
||||
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
|
||||
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
|
||||
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
|
||||
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
|
||||
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
|
||||
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
|
||||
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
|
||||
sa.Column(
|
||||
'created_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback creation timestamp (BIGINT representing epoch)
|
||||
sa.Column(
|
||||
'updated_at', sa.BigInteger(), nullable=False
|
||||
), # Feedback update timestamp (BIGINT representing epoch)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### Drop feedback table ###
|
||||
op.drop_table("feedback")
|
||||
op.drop_table('feedback')
|
||||
|
||||
@@ -6,74 +6,97 @@ Create Date: 2025-11-28 04:55:31.737538
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
import open_webui.internal.db
|
||||
import json
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import open_webui.internal.db
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b10670c03dd5"
|
||||
down_revision: Union[str, None] = "2f1211949ecc"
|
||||
revision: str = 'b10670c03dd5'
|
||||
down_revision: Union[str, None] = '2f1211949ecc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# ── Ad-hoc table references for Core DML ─────────────────────────────────
|
||||
# These are lightweight table() / column() references used only inside this
|
||||
# migration for SELECT / UPDATE / INSERT — they do NOT create or alter
|
||||
# anything on disk.
|
||||
|
||||
_user = sa.table(
|
||||
'user',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('oauth_sub', sa.Text),
|
||||
sa.column('oauth', sa.JSON),
|
||||
sa.column('api_key', sa.Text),
|
||||
sa.column('info', sa.Text),
|
||||
sa.column('settings', sa.Text),
|
||||
)
|
||||
|
||||
_api_key = sa.table(
|
||||
'api_key',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
|
||||
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
|
||||
"""
|
||||
SQLite requires manual removal of any indexes referencing a column
|
||||
before ALTER TABLE ... DROP COLUMN can succeed.
|
||||
SQLite requires manual removal of any user-created indexes referencing
|
||||
a column before ALTER TABLE ... DROP COLUMN can succeed.
|
||||
|
||||
NOTE: PRAGMAs have no Core equivalent — raw text is unavoidable here.
|
||||
"""
|
||||
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
|
||||
|
||||
for idx in indexes:
|
||||
index_name = idx[1] # index name
|
||||
# Get indexed columns
|
||||
idx_info = conn.execute(
|
||||
sa.text(f"PRAGMA index_info('{index_name}')")
|
||||
).fetchall()
|
||||
|
||||
indexed_cols = [row[2] for row in idx_info] # col names
|
||||
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
|
||||
# cannot be dropped directly and will disappear when the column is
|
||||
# removed via batch_alter_table.
|
||||
if index_name.startswith('sqlite_autoindex_'):
|
||||
continue
|
||||
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
|
||||
indexed_cols = [row[2] for row in idx_info]
|
||||
if column_name in indexed_cols:
|
||||
conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}"))
|
||||
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
|
||||
|
||||
|
||||
def _convert_column_to_json(table: str, column: str):
|
||||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
|
||||
t_json = sa.column(f'{column}_json', sa.JSON)
|
||||
|
||||
# SQLite cannot ALTER COLUMN → must recreate column
|
||||
if dialect == "sqlite":
|
||||
# 1. Add temporary column
|
||||
op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True))
|
||||
if dialect == 'sqlite':
|
||||
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
|
||||
|
||||
# 2. Load old data
|
||||
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
|
||||
|
||||
for row in rows:
|
||||
uid, raw = row
|
||||
for uid, raw in rows:
|
||||
if raw is None:
|
||||
parsed = None
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None # fallback safe behavior
|
||||
parsed = None
|
||||
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
|
||||
{"val": json.dumps(parsed) if parsed else None, "id": uid},
|
||||
sa.update(sa.table(table, sa.column('id'), t_json))
|
||||
.where(sa.column('id') == uid)
|
||||
.values({f'{column}_json': json.dumps(parsed) if parsed else None})
|
||||
)
|
||||
|
||||
# 3. Drop old TEXT column
|
||||
op.drop_column(table, column)
|
||||
|
||||
# 4. Rename new JSON column → original name
|
||||
op.alter_column(table, f"{column}_json", new_column_name=column)
|
||||
op.alter_column(table, f'{column}_json', new_column_name=column)
|
||||
|
||||
else:
|
||||
# PostgreSQL supports direct CAST
|
||||
@@ -81,7 +104,7 @@ def _convert_column_to_json(table: str, column: str):
|
||||
table,
|
||||
column,
|
||||
type_=sa.JSON(),
|
||||
postgresql_using=f"{column}::json",
|
||||
postgresql_using=f'{column}::json',
|
||||
)
|
||||
|
||||
|
||||
@@ -89,161 +112,152 @@ def _convert_column_to_text(table: str, column: str):
|
||||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
if dialect == "sqlite":
|
||||
op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True))
|
||||
t = sa.table(table, sa.column('id', sa.Text), sa.column(column))
|
||||
t_text = sa.column(f'{column}_text', sa.Text)
|
||||
|
||||
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
|
||||
if dialect == 'sqlite':
|
||||
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
|
||||
|
||||
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
|
||||
|
||||
for uid, raw in rows:
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
|
||||
{"val": json.dumps(raw) if raw else None, "id": uid},
|
||||
sa.update(sa.table(table, sa.column('id'), t_text))
|
||||
.where(sa.column('id') == uid)
|
||||
.values({f'{column}_text': json.dumps(raw) if raw else None})
|
||||
)
|
||||
|
||||
op.drop_column(table, column)
|
||||
op.alter_column(table, f"{column}_text", new_column_name=column)
|
||||
op.alter_column(table, f'{column}_text', new_column_name=column)
|
||||
|
||||
else:
|
||||
op.alter_column(
|
||||
table,
|
||||
column,
|
||||
type_=sa.Text(),
|
||||
postgresql_using=f"to_json({column})::text",
|
||||
postgresql_using=f'to_json({column})::text',
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True)
|
||||
)
|
||||
op.add_column("user", sa.Column("timezone", sa.String(), nullable=True))
|
||||
|
||||
op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True))
|
||||
op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True))
|
||||
op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
|
||||
op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True))
|
||||
|
||||
# Convert info (TEXT/JSONField) → JSON
|
||||
_convert_column_to_json("user", "info")
|
||||
# Convert settings (TEXT/JSONField) → JSON
|
||||
_convert_column_to_json("user", "settings")
|
||||
|
||||
op.create_table(
|
||||
"api_key",
|
||||
sa.Column("id", sa.Text(), primary_key=True, unique=True),
|
||||
sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")),
|
||||
sa.Column("key", sa.Text(), unique=True, nullable=False),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("expires_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
users = conn.execute(
|
||||
sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')
|
||||
).fetchall()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
user_columns = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
for uid, oauth_sub in users:
|
||||
if oauth_sub:
|
||||
# Example formats supported:
|
||||
# provider@sub
|
||||
# plain sub (stored as {"oidc": {"sub": sub}})
|
||||
if "@" in oauth_sub:
|
||||
provider, sub = oauth_sub.split("@", 1)
|
||||
else:
|
||||
provider, sub = "oidc", oauth_sub
|
||||
# ── Add new columns (idempotent) ──────────────────────────────────
|
||||
for col_name, col_type in [
|
||||
('profile_banner_image_url', sa.Text()),
|
||||
('timezone', sa.String()),
|
||||
('presence_state', sa.String()),
|
||||
('status_emoji', sa.String()),
|
||||
('status_message', sa.Text()),
|
||||
('status_expires_at', sa.BigInteger()),
|
||||
('oauth', sa.JSON()),
|
||||
]:
|
||||
if col_name not in user_columns:
|
||||
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
|
||||
|
||||
oauth_json = json.dumps({provider: {"sub": sub}})
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
|
||||
{"oauth": oauth_json, "id": uid},
|
||||
)
|
||||
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
|
||||
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
|
||||
if isinstance(user_col_types.get('info'), sa.Text):
|
||||
_convert_column_to_json('user', 'info')
|
||||
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
|
||||
if isinstance(user_col_types.get('settings'), sa.Text):
|
||||
_convert_column_to_json('user', 'settings')
|
||||
|
||||
users_with_keys = conn.execute(
|
||||
sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')
|
||||
).fetchall()
|
||||
now = int(time.time())
|
||||
# ── Create api_key table (idempotent) ─────────────────────────────
|
||||
if 'api_key' not in existing_tables:
|
||||
op.create_table(
|
||||
'api_key',
|
||||
sa.Column('id', sa.Text(), primary_key=True, unique=True),
|
||||
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
|
||||
sa.Column('key', sa.Text(), unique=True, nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
for uid, api_key in users_with_keys:
|
||||
if api_key:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
|
||||
VALUES (:id, :user_id, :key, :created_at, :updated_at)
|
||||
"""),
|
||||
{
|
||||
"id": f"key_{uid}",
|
||||
"user_id": uid,
|
||||
"key": api_key,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
|
||||
if 'oauth_sub' in user_columns:
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
|
||||
|
||||
if conn.dialect.name == "sqlite":
|
||||
_drop_sqlite_indexes_for_column("user", "api_key", conn)
|
||||
_drop_sqlite_indexes_for_column("user", "oauth_sub", conn)
|
||||
for uid, oauth_sub in rows:
|
||||
if oauth_sub:
|
||||
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
|
||||
conn.execute(
|
||||
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
|
||||
)
|
||||
|
||||
with op.batch_alter_table("user") as batch_op:
|
||||
batch_op.drop_column("api_key")
|
||||
batch_op.drop_column("oauth_sub")
|
||||
# ── Migrate api_key column → api_key table (only if old column still exists)
|
||||
if 'api_key' in user_columns:
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
|
||||
now = int(time.time())
|
||||
|
||||
for uid, key_val in rows:
|
||||
if key_val:
|
||||
conn.execute(
|
||||
sa.insert(_api_key).values(
|
||||
id=f'key_{uid}',
|
||||
user_id=uid,
|
||||
key=key_val,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Drop legacy columns (idempotent) ──────────────────────────────
|
||||
cols_to_drop = {'api_key', 'oauth_sub'} & user_columns
|
||||
if cols_to_drop:
|
||||
if conn.dialect.name == 'sqlite':
|
||||
for col in cols_to_drop:
|
||||
_drop_sqlite_indexes_for_column('user', col, conn)
|
||||
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
for col in cols_to_drop:
|
||||
batch_op.drop_column(col)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# --- 1. Restore old oauth_sub column ---
|
||||
op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True))
|
||||
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
users = conn.execute(
|
||||
sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')
|
||||
).fetchall()
|
||||
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
|
||||
|
||||
for uid, oauth in users:
|
||||
for uid, oauth in rows:
|
||||
try:
|
||||
data = json.loads(oauth)
|
||||
provider = list(data.keys())[0]
|
||||
sub = data[provider].get("sub")
|
||||
oauth_sub = f"{provider}@{sub}"
|
||||
sub = data[provider].get('sub')
|
||||
oauth_sub = f'{provider}@{sub}'
|
||||
except Exception:
|
||||
oauth_sub = None
|
||||
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
|
||||
{"oauth_sub": oauth_sub, "id": uid},
|
||||
)
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
|
||||
|
||||
op.drop_column("user", "oauth")
|
||||
op.drop_column('user', 'oauth')
|
||||
|
||||
# --- 2. Restore api_key field ---
|
||||
op.add_column("user", sa.Column("api_key", sa.String(), nullable=True))
|
||||
# --- Restore api_key field ---
|
||||
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
|
||||
|
||||
# Restore values from api_key
|
||||
keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall()
|
||||
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
|
||||
for uid, key in keys:
|
||||
conn.execute(
|
||||
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
|
||||
{"key": key, "id": uid},
|
||||
)
|
||||
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
|
||||
|
||||
# Drop new table
|
||||
op.drop_table("api_key")
|
||||
op.drop_table('api_key')
|
||||
|
||||
with op.batch_alter_table("user") as batch_op:
|
||||
batch_op.drop_column("profile_banner_image_url")
|
||||
batch_op.drop_column("timezone")
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
batch_op.drop_column('profile_banner_image_url')
|
||||
batch_op.drop_column('timezone')
|
||||
|
||||
batch_op.drop_column("presence_state")
|
||||
batch_op.drop_column("status_emoji")
|
||||
batch_op.drop_column("status_message")
|
||||
batch_op.drop_column("status_expires_at")
|
||||
batch_op.drop_column('presence_state')
|
||||
batch_op.drop_column('status_emoji')
|
||||
batch_op.drop_column('status_message')
|
||||
batch_op.drop_column('status_expires_at')
|
||||
|
||||
# Convert info (JSON) → TEXT
|
||||
_convert_column_to_text("user", "info")
|
||||
_convert_column_to_text('user', 'info')
|
||||
# Convert settings (JSON) → TEXT
|
||||
_convert_column_to_text("user", "settings")
|
||||
_convert_column_to_text('user', 'settings')
|
||||
|
||||
+10
-5
@@ -8,19 +8,24 @@ Create Date: 2026-02-13 14:19:00.000000
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
revision: str = 'b2c3d4e5f6a7'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True))
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
user_cols = {c['name'] for c in inspector.get_columns('user')}
|
||||
|
||||
if 'scim' not in user_cols:
|
||||
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "scim")
|
||||
op.drop_column('user', 'scim')
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add last_read_at to chat
|
||||
|
||||
Revision ID: b7c8d9e0f1a2
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-04-01 04:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7c8d9e0f1a2'
|
||||
down_revision = 'd4e5f6a7b8c9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'last_read_at' not in columns:
|
||||
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
|
||||
# Set existing chats to be marked as read
|
||||
op.execute('UPDATE chat SET last_read_at = updated_at')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('chat', 'last_read_at')
|
||||
@@ -12,21 +12,28 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c0fbf31ca0db"
|
||||
down_revision: Union[str, None] = "ca81bd47c050"
|
||||
revision: str = 'c0fbf31ca0db'
|
||||
down_revision: Union[str, None] = 'ca81bd47c050'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("file", sa.Column("hash", sa.Text(), nullable=True))
|
||||
op.add_column("file", sa.Column("data", sa.JSON(), nullable=True))
|
||||
op.add_column("file", sa.Column("updated_at", sa.BigInteger(), nullable=True))
|
||||
if 'hash' not in file_cols:
|
||||
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
|
||||
if 'data' not in file_cols:
|
||||
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
|
||||
if 'updated_at' not in file_cols:
|
||||
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("file", "updated_at")
|
||||
op.drop_column("file", "data")
|
||||
op.drop_column("file", "hash")
|
||||
op.drop_column('file', 'updated_at')
|
||||
op.drop_column('file', 'data')
|
||||
op.drop_column('file', 'hash')
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Add shared_chat table and migrate existing shares
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: e1f2a3b4c5d6
|
||||
Create Date: 2026-04-16 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'e1f2a3b4c5d6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Lightweight table references for data migration (no ORM models needed)
|
||||
chat_t = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('title', sa.Text),
|
||||
sa.column('chat', sa.JSON),
|
||||
sa.column('share_id', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
sa.column('archived', sa.Boolean),
|
||||
sa.column('meta', sa.JSON),
|
||||
)
|
||||
|
||||
shared_chat_t = sa.table(
|
||||
'shared_chat',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('chat_id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('title', sa.Text),
|
||||
sa.column('chat', sa.JSON),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
chat_message_t = sa.table(
|
||||
'chat_message',
|
||||
sa.column('chat_id', sa.Text),
|
||||
)
|
||||
|
||||
access_grant_t = sa.table(
|
||||
'access_grant',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('resource_type', sa.Text),
|
||||
sa.column('resource_id', sa.Text),
|
||||
sa.column('principal_type', sa.Text),
|
||||
sa.column('principal_id', sa.Text),
|
||||
sa.column('permission', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# 1. Create shared_chat table (idempotent)
|
||||
if 'shared_chat' not in tables:
|
||||
op.create_table(
|
||||
'shared_chat',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Migrate existing shared-* rows
|
||||
shared_rows = conn.execute(
|
||||
sa.select(
|
||||
chat_t.c.id,
|
||||
chat_t.c.user_id,
|
||||
chat_t.c.title,
|
||||
chat_t.c.chat,
|
||||
chat_t.c.created_at,
|
||||
chat_t.c.updated_at,
|
||||
).where(chat_t.c.user_id.like('shared-%'))
|
||||
).fetchall()
|
||||
|
||||
for row in shared_rows:
|
||||
share_token = row.id
|
||||
original_chat_id = row.user_id.replace('shared-', '', 1)
|
||||
|
||||
# Verify original chat still exists
|
||||
original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone()
|
||||
|
||||
if not original:
|
||||
continue
|
||||
|
||||
# Check if shared_chat record already exists (idempotent)
|
||||
existing_shared = conn.execute(
|
||||
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
|
||||
).fetchone()
|
||||
|
||||
if not existing_shared:
|
||||
# Insert snapshot into shared_chat
|
||||
conn.execute(
|
||||
shared_chat_t.insert().values(
|
||||
id=share_token,
|
||||
chat_id=original_chat_id,
|
||||
user_id=original.user_id,
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Check if access_grant record already exists (idempotent)
|
||||
existing_grant = conn.execute(
|
||||
sa.select(access_grant_t.c.id).where(
|
||||
sa.and_(
|
||||
access_grant_t.c.resource_type == 'shared_chat',
|
||||
access_grant_t.c.resource_id == original_chat_id,
|
||||
access_grant_t.c.principal_type == 'user',
|
||||
access_grant_t.c.principal_id == '*',
|
||||
access_grant_t.c.permission == 'read',
|
||||
)
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if not existing_grant:
|
||||
# Create user:*:read grant for backward compat
|
||||
conn.execute(
|
||||
access_grant_t.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type='shared_chat',
|
||||
resource_id=original_chat_id,
|
||||
principal_type='user',
|
||||
principal_id='*',
|
||||
permission='read',
|
||||
created_at=row.created_at or int(time.time()),
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Clean up old phantom rows
|
||||
conn.execute(
|
||||
chat_message_t.delete().where(
|
||||
chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')))
|
||||
)
|
||||
)
|
||||
conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%')))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
shared_rows = conn.execute(
|
||||
sa.select(
|
||||
shared_chat_t.c.id,
|
||||
shared_chat_t.c.chat_id,
|
||||
shared_chat_t.c.user_id,
|
||||
shared_chat_t.c.title,
|
||||
shared_chat_t.c.chat,
|
||||
shared_chat_t.c.created_at,
|
||||
shared_chat_t.c.updated_at,
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in shared_rows:
|
||||
conn.execute(
|
||||
chat_t.insert().values(
|
||||
id=row.id,
|
||||
user_id=f'shared-{row.chat_id}',
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
archived=False,
|
||||
meta={},
|
||||
)
|
||||
)
|
||||
|
||||
conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat'))
|
||||
op.drop_table('shared_chat')
|
||||
@@ -6,41 +6,45 @@ Create Date: 2024-10-20 17:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import json
|
||||
from sqlalchemy.sql import table, column
|
||||
from sqlalchemy import String, Text, JSON, and_
|
||||
|
||||
revision = "c29facfe716b"
|
||||
down_revision = "c69f45358db4"
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import JSON, String, Text, and_
|
||||
from sqlalchemy.sql import column, table
|
||||
|
||||
revision = 'c29facfe716b'
|
||||
down_revision = 'c69f45358db4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
file_cols = {c['name'] for c in inspector.get_columns('file')}
|
||||
|
||||
# 1. Add the `path` column to the "file" table.
|
||||
op.add_column("file", sa.Column("path", sa.Text(), nullable=True))
|
||||
if 'path' not in file_cols:
|
||||
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
|
||||
|
||||
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
|
||||
# Use Alembic's default batch_op for dialect compatibility.
|
||||
with op.batch_alter_table("file", schema=None) as batch_op:
|
||||
with op.batch_alter_table('file', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"meta",
|
||||
'meta',
|
||||
type_=sa.JSON(),
|
||||
existing_type=sa.Text(),
|
||||
existing_nullable=True,
|
||||
nullable=True,
|
||||
postgresql_using="meta::json",
|
||||
postgresql_using='meta::json',
|
||||
)
|
||||
|
||||
# 3. Migrate legacy data from `meta` JSONField
|
||||
# Fetch and process `meta` data from the table, add values to the new `path` column as necessary.
|
||||
# We will use SQLAlchemy core bindings to ensure safety across different databases.
|
||||
|
||||
file_table = table(
|
||||
"file", column("id", String), column("meta", JSON), column("path", Text)
|
||||
)
|
||||
file_table = table('file', column('id', String), column('meta', JSON), column('path', Text))
|
||||
|
||||
# Create connection to the database
|
||||
connection = op.get_bind()
|
||||
@@ -55,24 +59,18 @@ def upgrade():
|
||||
|
||||
# Iterate over each row to extract and update the `path` from `meta` column
|
||||
for row in results:
|
||||
if "path" in row.meta:
|
||||
if 'path' in row.meta:
|
||||
# Extract the `path` field from the `meta` JSON
|
||||
path = row.meta.get("path")
|
||||
path = row.meta.get('path')
|
||||
|
||||
# Update the `file` table with the new `path` value
|
||||
connection.execute(
|
||||
file_table.update()
|
||||
.where(file_table.c.id == row.id)
|
||||
.values({"path": path})
|
||||
)
|
||||
connection.execute(file_table.update().where(file_table.c.id == row.id).values({'path': path}))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 1. Remove the `path` column
|
||||
op.drop_column("file", "path")
|
||||
op.drop_column('file', 'path')
|
||||
|
||||
# 2. Revert the `meta` column back to Text/JSONField
|
||||
with op.batch_alter_table("file", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"meta", type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True
|
||||
)
|
||||
with op.batch_alter_table('file', schema=None) as batch_op:
|
||||
batch_op.alter_column('meta', type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True)
|
||||
|
||||
@@ -8,49 +8,50 @@ Create Date: 2025-12-21 20:27:41.694897
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c440947495f3"
|
||||
down_revision: Union[str, None] = "81cc2ce44d79"
|
||||
revision: str = 'c440947495f3'
|
||||
down_revision: Union[str, None] = '81cc2ce44d79'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"chat_file",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"chat_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("chat.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"file_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("file.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("message_id", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index("ix_chat_file_chat_id", "chat_id"),
|
||||
sa.Index("ix_chat_file_file_id", "file_id"),
|
||||
sa.Index("ix_chat_file_message_id", "message_id"),
|
||||
sa.Index("ix_chat_file_user_id", "user_id"),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint(
|
||||
"chat_id", "file_id", name="uq_chat_file_chat_file"
|
||||
), # prevent duplicate entries
|
||||
)
|
||||
pass
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'chat_file' not in existing_tables:
|
||||
op.create_table(
|
||||
'chat_file',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'chat_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('chat.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'file_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('file.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('message_id', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
# indexes
|
||||
sa.Index('ix_chat_file_chat_id', 'chat_id'),
|
||||
sa.Index('ix_chat_file_file_id', 'file_id'),
|
||||
sa.Index('ix_chat_file_message_id', 'message_id'),
|
||||
sa.Index('ix_chat_file_user_id', 'user_id'),
|
||||
# unique constraints
|
||||
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("chat_file")
|
||||
pass
|
||||
op.drop_table('chat_file')
|
||||
|
||||
@@ -6,45 +6,50 @@ Create Date: 2024-10-16 02:02:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c69f45358db4"
|
||||
down_revision = "3ab32c4b8f59"
|
||||
revision = 'c69f45358db4'
|
||||
down_revision = '3ab32c4b8f59'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"folder",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("items", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("is_expanded", sa.Boolean(), default=False, nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", "user_id"),
|
||||
)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
op.add_column(
|
||||
"chat",
|
||||
sa.Column("folder_id", sa.Text(), nullable=True),
|
||||
)
|
||||
if 'folder' not in existing_tables:
|
||||
op.create_table(
|
||||
'folder',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('parent_id', sa.Text(), nullable=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('items', sa.JSON(), nullable=True),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id', 'user_id'),
|
||||
)
|
||||
|
||||
chat_cols = {c['name'] for c in inspector.get_columns('chat')}
|
||||
if 'folder_id' not in chat_cols:
|
||||
op.add_column(
|
||||
'chat',
|
||||
sa.Column('folder_id', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("chat", "folder_id")
|
||||
op.drop_column('chat', 'folder_id')
|
||||
|
||||
op.drop_table("folder")
|
||||
op.drop_table('folder')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Add config table
|
||||
"""Add config table.
|
||||
|
||||
Revision ID: ca81bd47c050
|
||||
Revises: 7e5b5dc7342b
|
||||
Create Date: 2024-08-25 15:26:35.241684
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
@@ -11,31 +10,40 @@ from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ca81bd47c050"
|
||||
down_revision: Union[str, None] = "7e5b5dc7342b"
|
||||
revision: str = 'ca81bd47c050'
|
||||
down_revision: Union[str, None] = '7e5b5dc7342b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"config",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("data", sa.JSON(), nullable=False),
|
||||
sa.Column("version", sa.Integer, nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
)
|
||||
def upgrade() -> None:
|
||||
"""Create a key-value config table with versioning."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if 'config' not in existing_tables:
|
||||
op.create_table(
|
||||
'config',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('version', sa.Integer, nullable=False),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("config")
|
||||
def downgrade() -> None:
|
||||
"""Drop the config table."""
|
||||
op.drop_table('config')
|
||||
|
||||
@@ -6,18 +6,23 @@ Create Date: 2025-07-13 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d31026856c01"
|
||||
down_revision = "9f0c9cd09105"
|
||||
revision = 'd31026856c01'
|
||||
down_revision = '9f0c9cd09105'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True))
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
folder_cols = {c['name'] for c in inspector.get_columns('folder')}
|
||||
|
||||
if 'data' not in folder_cols:
|
||||
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("folder", "data")
|
||||
op.drop_column('folder', 'data')
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""add automation tables
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: f1e2d3c4b5a6
|
||||
Create Date: 2026-03-30
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = 'd4e5f6a7b8c9'
|
||||
down_revision: Union[str, None] = 'a3dd5bedd151'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _index_exists(inspector, index_name, table_name):
|
||||
"""Check if an index already exists on the given table (works for both SQLite and PostgreSQL)."""
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'automation' not in tables:
|
||||
op.create_table(
|
||||
'automation',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('meta', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'automation' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_automation_next_run', 'automation'):
|
||||
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
|
||||
|
||||
if 'automation_run' not in tables:
|
||||
op.create_table(
|
||||
'automation_run',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('automation_id', sa.Text(), nullable=False),
|
||||
sa.Column('chat_id', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.Text(), nullable=False),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
inspector.clear_cache()
|
||||
if 'automation_run' in inspector.get_table_names():
|
||||
if not _index_exists(inspector, 'ix_automation_run_automation_id', 'automation_run'):
|
||||
op.create_index(
|
||||
'ix_automation_run_automation_id',
|
||||
'automation_run',
|
||||
['automation_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_automation_run_automation_id')
|
||||
op.drop_table('automation_run')
|
||||
op.drop_index('ix_automation_next_run')
|
||||
op.drop_table('automation')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add is_pinned to note table
|
||||
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-04-14 22:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'e1f2a3b4c5d6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('note')]
|
||||
|
||||
if 'is_pinned' not in columns:
|
||||
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('note', 'is_pinned')
|
||||
@@ -11,17 +11,16 @@ Access control semantics:
|
||||
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
import time
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = "f1e2d3c4b5a6"
|
||||
down_revision: Union[str, None] = "8452d01d26d7"
|
||||
revision: str = 'f1e2d3c4b5a6'
|
||||
down_revision: Union[str, None] = '8452d01d26d7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
@@ -30,34 +29,34 @@ def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
# Create access_grant table
|
||||
if "access_grant" not in existing_tables:
|
||||
if 'access_grant' not in existing_tables:
|
||||
op.create_table(
|
||||
"access_grant",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("resource_type", sa.Text(), nullable=False),
|
||||
sa.Column("resource_id", sa.Text(), nullable=False),
|
||||
sa.Column("principal_type", sa.Text(), nullable=False),
|
||||
sa.Column("principal_id", sa.Text(), nullable=False),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
'access_grant',
|
||||
sa.Column('id', sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column('resource_type', sa.Text(), nullable=False),
|
||||
sa.Column('resource_id', sa.Text(), nullable=False),
|
||||
sa.Column('principal_type', sa.Text(), nullable=False),
|
||||
sa.Column('principal_id', sa.Text(), nullable=False),
|
||||
sa.Column('permission', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission",
|
||||
name="uq_access_grant_grant",
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'principal_type',
|
||||
'principal_id',
|
||||
'permission',
|
||||
name='uq_access_grant_grant',
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_access_grant_resource",
|
||||
"access_grant",
|
||||
["resource_type", "resource_id"],
|
||||
'idx_access_grant_resource',
|
||||
'access_grant',
|
||||
['resource_type', 'resource_id'],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_access_grant_principal",
|
||||
"access_grant",
|
||||
["principal_type", "principal_id"],
|
||||
'idx_access_grant_principal',
|
||||
'access_grant',
|
||||
['principal_type', 'principal_id'],
|
||||
)
|
||||
|
||||
# Backfill existing access_control JSON data
|
||||
@@ -65,13 +64,13 @@ def upgrade() -> None:
|
||||
|
||||
# Tables with access_control JSON columns: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
("prompt", "prompt"),
|
||||
("tool", "tool"),
|
||||
("model", "model"),
|
||||
("note", "note"),
|
||||
("channel", "channel"),
|
||||
("file", "file"),
|
||||
('knowledge', 'knowledge'),
|
||||
('prompt', 'prompt'),
|
||||
('tool', 'tool'),
|
||||
('model', 'model'),
|
||||
('note', 'note'),
|
||||
('channel', 'channel'),
|
||||
('file', 'file'),
|
||||
]
|
||||
|
||||
now = int(time.time())
|
||||
@@ -81,15 +80,18 @@ def upgrade() -> None:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
|
||||
# Query all rows
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text(f'SELECT id, access_control FROM "{table_name}"')
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
# Check if access_control and id columns exist (may already be dropped on re-run,
|
||||
# or table may have been rebuilt without id during intermediate migration states)
|
||||
insp = sa.inspect(conn)
|
||||
insp.clear_cache() # Ensure fresh metadata after prior migrations that rebuild tables
|
||||
table_cols = {c['name'] for c in insp.get_columns(table_name)}
|
||||
if 'access_control' not in table_cols or 'id' not in table_cols:
|
||||
continue
|
||||
|
||||
# Query all rows
|
||||
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
resource_id = row[0]
|
||||
access_control_json = row[1]
|
||||
@@ -99,19 +101,16 @@ def upgrade() -> None:
|
||||
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
|
||||
is_null = (
|
||||
access_control_json is None
|
||||
or access_control_json == "null"
|
||||
or (
|
||||
isinstance(access_control_json, str)
|
||||
and access_control_json.strip().lower() == "null"
|
||||
)
|
||||
or access_control_json == 'null'
|
||||
or (isinstance(access_control_json, str) and access_control_json.strip().lower() == 'null')
|
||||
)
|
||||
if is_null:
|
||||
# Files: NULL = private (no entry needed, owner has implicit access)
|
||||
# Other resources: NULL = public (insert user:* for read)
|
||||
if resource_type == "file":
|
||||
if resource_type == 'file':
|
||||
continue # Private - no entry needed
|
||||
|
||||
key = (resource_type, resource_id, "user", "*", "read")
|
||||
key = (resource_type, resource_id, 'user', '*', 'read')
|
||||
if key not in inserted:
|
||||
try:
|
||||
conn.execute(
|
||||
@@ -120,13 +119,13 @@ def upgrade() -> None:
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": "*",
|
||||
"permission": "read",
|
||||
"created_at": now,
|
||||
'id': str(uuid.uuid4()),
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'principal_type': 'user',
|
||||
'principal_id': '*',
|
||||
'permission': 'read',
|
||||
'created_at': now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
@@ -149,28 +148,24 @@ def upgrade() -> None:
|
||||
continue
|
||||
|
||||
# Check if it's effectively empty (no read/write keys with content)
|
||||
read_data = access_control_json.get("read", {})
|
||||
write_data = access_control_json.get("write", {})
|
||||
read_data = access_control_json.get('read', {})
|
||||
write_data = access_control_json.get('write', {})
|
||||
|
||||
has_read_grants = read_data.get("group_ids", []) or read_data.get(
|
||||
"user_ids", []
|
||||
)
|
||||
has_write_grants = write_data.get("group_ids", []) or write_data.get(
|
||||
"user_ids", []
|
||||
)
|
||||
has_read_grants = read_data.get('group_ids', []) or read_data.get('user_ids', [])
|
||||
has_write_grants = write_data.get('group_ids', []) or write_data.get('user_ids', [])
|
||||
|
||||
if not has_read_grants and not has_write_grants:
|
||||
# Empty permissions = private, no grants needed
|
||||
continue
|
||||
|
||||
# Extract permissions and insert into access_grant table
|
||||
for permission in ["read", "write"]:
|
||||
for permission in ['read', 'write']:
|
||||
perm_data = access_control_json.get(permission, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
key = (resource_type, resource_id, "group", group_id, permission)
|
||||
for group_id in perm_data.get('group_ids', []):
|
||||
key = (resource_type, resource_id, 'group', group_id, permission)
|
||||
if key in inserted:
|
||||
continue
|
||||
try:
|
||||
@@ -180,21 +175,21 @@ def upgrade() -> None:
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": permission,
|
||||
"created_at": now,
|
||||
'id': str(uuid.uuid4()),
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'principal_type': 'group',
|
||||
'principal_id': group_id,
|
||||
'permission': permission,
|
||||
'created_at': now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for user_id in perm_data.get("user_ids", []):
|
||||
key = (resource_type, resource_id, "user", user_id, permission)
|
||||
for user_id in perm_data.get('user_ids', []):
|
||||
key = (resource_type, resource_id, 'user', user_id, permission)
|
||||
if key in inserted:
|
||||
continue
|
||||
try:
|
||||
@@ -204,28 +199,28 @@ def upgrade() -> None:
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": user_id,
|
||||
"permission": permission,
|
||||
"created_at": now,
|
||||
'id': str(uuid.uuid4()),
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'principal_type': 'user',
|
||||
'principal_id': user_id,
|
||||
'permission': permission,
|
||||
'created_at': now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drop access_control columns from resource tables
|
||||
# Drop access_control columns from resource tables (only if column still exists)
|
||||
inspector = sa.inspect(conn)
|
||||
for table_name, _ in resource_tables:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
try:
|
||||
cols = {c['name'] for c in inspector.get_columns(table_name)}
|
||||
if 'access_control' in cols:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.drop_column("access_control")
|
||||
except Exception:
|
||||
pass
|
||||
batch.drop_column('access_control')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
@@ -235,20 +230,20 @@ def downgrade() -> None:
|
||||
|
||||
# Resource tables mapping: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
("prompt", "prompt"),
|
||||
("tool", "tool"),
|
||||
("model", "model"),
|
||||
("note", "note"),
|
||||
("channel", "channel"),
|
||||
("file", "file"),
|
||||
('knowledge', 'knowledge'),
|
||||
('prompt', 'prompt'),
|
||||
('tool', 'tool'),
|
||||
('model', 'model'),
|
||||
('note', 'note'),
|
||||
('channel', 'channel'),
|
||||
('file', 'file'),
|
||||
]
|
||||
|
||||
# Step 1: Re-add access_control columns to resource tables
|
||||
for table_name, _ in resource_tables:
|
||||
try:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
|
||||
batch.add_column(sa.Column('access_control', sa.JSON(), nullable=True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -262,7 +257,7 @@ def downgrade() -> None:
|
||||
FROM access_grant
|
||||
WHERE resource_type = :resource_type
|
||||
"""),
|
||||
{"resource_type": resource_type},
|
||||
{'resource_type': resource_type},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
@@ -278,49 +273,35 @@ def downgrade() -> None:
|
||||
|
||||
if resource_id not in resource_grants:
|
||||
resource_grants[resource_id] = {
|
||||
"is_public": False,
|
||||
"read": {"group_ids": [], "user_ids": []},
|
||||
"write": {"group_ids": [], "user_ids": []},
|
||||
'is_public': False,
|
||||
'read': {'group_ids': [], 'user_ids': []},
|
||||
'write': {'group_ids': [], 'user_ids': []},
|
||||
}
|
||||
|
||||
# Handle public access (user:* for read)
|
||||
if (
|
||||
principal_type == "user"
|
||||
and principal_id == "*"
|
||||
and permission == "read"
|
||||
):
|
||||
resource_grants[resource_id]["is_public"] = True
|
||||
if principal_type == 'user' and principal_id == '*' and permission == 'read':
|
||||
resource_grants[resource_id]['is_public'] = True
|
||||
continue
|
||||
|
||||
# Add to appropriate list
|
||||
if permission in ["read", "write"]:
|
||||
if principal_type == "group":
|
||||
if (
|
||||
principal_id
|
||||
not in resource_grants[resource_id][permission]["group_ids"]
|
||||
):
|
||||
resource_grants[resource_id][permission]["group_ids"].append(
|
||||
principal_id
|
||||
)
|
||||
elif principal_type == "user":
|
||||
if (
|
||||
principal_id
|
||||
not in resource_grants[resource_id][permission]["user_ids"]
|
||||
):
|
||||
resource_grants[resource_id][permission]["user_ids"].append(
|
||||
principal_id
|
||||
)
|
||||
if permission in ['read', 'write']:
|
||||
if principal_type == 'group':
|
||||
if principal_id not in resource_grants[resource_id][permission]['group_ids']:
|
||||
resource_grants[resource_id][permission]['group_ids'].append(principal_id)
|
||||
elif principal_type == 'user':
|
||||
if principal_id not in resource_grants[resource_id][permission]['user_ids']:
|
||||
resource_grants[resource_id][permission]['user_ids'].append(principal_id)
|
||||
|
||||
# Step 3: Update each resource with reconstructed JSON
|
||||
for resource_id, grants in resource_grants.items():
|
||||
if grants["is_public"]:
|
||||
if grants['is_public']:
|
||||
# Public = NULL
|
||||
access_control_value = None
|
||||
elif (
|
||||
not grants["read"]["group_ids"]
|
||||
and not grants["read"]["user_ids"]
|
||||
and not grants["write"]["group_ids"]
|
||||
and not grants["write"]["user_ids"]
|
||||
not grants['read']['group_ids']
|
||||
and not grants['read']['user_ids']
|
||||
and not grants['write']['group_ids']
|
||||
and not grants['write']['user_ids']
|
||||
):
|
||||
# No grants = should not happen (would mean no entries), default to {}
|
||||
access_control_value = json.dumps({})
|
||||
@@ -328,17 +309,15 @@ def downgrade() -> None:
|
||||
# Custom permissions
|
||||
access_control_value = json.dumps(
|
||||
{
|
||||
"read": grants["read"],
|
||||
"write": grants["write"],
|
||||
'read': grants['read'],
|
||||
'write': grants['write'],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'
|
||||
),
|
||||
{"access_control": access_control_value, "id": resource_id},
|
||||
sa.text(f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'),
|
||||
{'access_control': access_control_value, 'id': resource_id},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -346,7 +325,7 @@ def downgrade() -> None:
|
||||
# Step 4: Set all resources WITHOUT entries to private
|
||||
# For files: NULL means private (owner-only), so leave as NULL
|
||||
# For other resources: {} means private, so update to {}
|
||||
if resource_type != "file":
|
||||
if resource_type != 'file':
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(f"""
|
||||
@@ -357,13 +336,13 @@ def downgrade() -> None:
|
||||
)
|
||||
AND access_control IS NULL
|
||||
"""),
|
||||
{"private_value": json.dumps({}), "resource_type": resource_type},
|
||||
{'private_value': json.dumps({}), 'resource_type': resource_type},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# For files, NULL stays NULL - no action needed
|
||||
|
||||
# Step 5: Drop the access_grant table
|
||||
op.drop_index("idx_access_grant_principal", table_name="access_grant")
|
||||
op.drop_index("idx_access_grant_resource", table_name="access_grant")
|
||||
op.drop_table("access_grant")
|
||||
op.drop_index('idx_access_grant_principal', table_name='access_grant')
|
||||
op.drop_index('idx_access_grant_resource', table_name='access_grant')
|
||||
op.drop_table('access_grant')
|
||||
|
||||
@@ -3,12 +3,11 @@ import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db_context
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
|
||||
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, and_, delete, or_, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,28 +18,24 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessGrant(Base):
|
||||
__tablename__ = "access_grant"
|
||||
__tablename__ = 'access_grant'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
resource_type = Column(
|
||||
Text, nullable=False
|
||||
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_id = Column(Text, nullable=False)
|
||||
principal_type = Column(Text, nullable=False) # "user" or "group"
|
||||
principal_id = Column(
|
||||
Text, nullable=False
|
||||
) # user_id, group_id, or "*" (wildcard for public)
|
||||
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
|
||||
permission = Column(Text, nullable=False) # "read" or "write"
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission",
|
||||
name="uq_access_grant_grant",
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'principal_type',
|
||||
'principal_id',
|
||||
'permission',
|
||||
name='uq_access_grant_grant',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -66,7 +61,7 @@ class AccessGrantResponse(BaseModel):
|
||||
permission: str
|
||||
|
||||
@classmethod
|
||||
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
|
||||
def from_grant(cls, grant: 'AccessGrantModel') -> 'AccessGrantResponse':
|
||||
return cls(
|
||||
id=grant.id,
|
||||
principal_type=grant.principal_type,
|
||||
@@ -100,14 +95,14 @@ def access_control_to_grants(
|
||||
if access_control is None:
|
||||
# NULL → public read (user:* for read)
|
||||
# Exception: files with NULL are private (owner-only), no grants needed
|
||||
if resource_type != "file":
|
||||
if resource_type != 'file':
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": "*",
|
||||
"permission": "read",
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'principal_type': 'user',
|
||||
'principal_id': '*',
|
||||
'permission': 'read',
|
||||
}
|
||||
)
|
||||
return grants
|
||||
@@ -117,30 +112,30 @@ def access_control_to_grants(
|
||||
return grants
|
||||
|
||||
# Parse structured permissions
|
||||
for permission in ["read", "write"]:
|
||||
for permission in ['read', 'write']:
|
||||
perm_data = access_control.get(permission, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
for group_id in perm_data.get('group_ids', []):
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": permission,
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'principal_type': 'group',
|
||||
'principal_id': group_id,
|
||||
'permission': permission,
|
||||
}
|
||||
)
|
||||
|
||||
for user_id in perm_data.get("user_ids", []):
|
||||
for user_id in perm_data.get('user_ids', []):
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": user_id,
|
||||
"permission": permission,
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'principal_type': 'user',
|
||||
'principal_id': user_id,
|
||||
'permission': permission,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -164,27 +159,23 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
|
||||
principal_type = grant.get("principal_type")
|
||||
principal_id = grant.get("principal_id")
|
||||
permission = grant.get("permission")
|
||||
principal_type = grant.get('principal_type')
|
||||
principal_id = grant.get('principal_id')
|
||||
permission = grant.get('permission')
|
||||
|
||||
if principal_type not in ("user", "group"):
|
||||
if principal_type not in ('user', 'group'):
|
||||
continue
|
||||
if permission not in ("read", "write"):
|
||||
if permission not in ('read', 'write'):
|
||||
continue
|
||||
if not isinstance(principal_id, str) or not principal_id:
|
||||
continue
|
||||
|
||||
key = (principal_type, principal_id, permission)
|
||||
deduped[key] = {
|
||||
"id": (
|
||||
grant.get("id")
|
||||
if isinstance(grant.get("id"), str) and grant.get("id")
|
||||
else str(uuid.uuid4())
|
||||
),
|
||||
"principal_type": principal_type,
|
||||
"principal_id": principal_id,
|
||||
"permission": permission,
|
||||
'id': (grant.get('id') if isinstance(grant.get('id'), str) and grant.get('id') else str(uuid.uuid4())),
|
||||
'principal_type': principal_type,
|
||||
'principal_id': principal_id,
|
||||
'permission': permission,
|
||||
}
|
||||
|
||||
return list(deduped.values())
|
||||
@@ -195,11 +186,17 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
|
||||
Returns True when a direct grant list includes wildcard public-read.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if (
|
||||
grant["principal_type"] == "user"
|
||||
and grant["principal_id"] == "*"
|
||||
and grant["permission"] == "read"
|
||||
):
|
||||
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
|
||||
"""
|
||||
Returns True when a direct grant list includes wildcard public-write.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -209,7 +206,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool:
|
||||
Returns True when a direct grant list includes any non-wildcard user grant.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if grant["principal_type"] == "user" and grant["principal_id"] != "*":
|
||||
if grant['principal_type'] == 'user' and grant['principal_id'] != '*':
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -225,18 +222,9 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list:
|
||||
grant
|
||||
for grant in access_grants
|
||||
if not (
|
||||
(
|
||||
grant.get("principal_type")
|
||||
if isinstance(grant, dict)
|
||||
else getattr(grant, "principal_type", None)
|
||||
)
|
||||
== "user"
|
||||
and (
|
||||
grant.get("principal_id")
|
||||
if isinstance(grant, dict)
|
||||
else getattr(grant, "principal_id", None)
|
||||
)
|
||||
!= "*"
|
||||
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
|
||||
== 'user'
|
||||
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
|
||||
)
|
||||
]
|
||||
|
||||
@@ -260,29 +248,25 @@ def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||
return {} # No grants = private/owner-only
|
||||
|
||||
result = {
|
||||
"read": {"group_ids": [], "user_ids": []},
|
||||
"write": {"group_ids": [], "user_ids": []},
|
||||
'read': {'group_ids': [], 'user_ids': []},
|
||||
'write': {'group_ids': [], 'user_ids': []},
|
||||
}
|
||||
|
||||
is_public = False
|
||||
for grant in grants:
|
||||
if (
|
||||
grant.principal_type == "user"
|
||||
and grant.principal_id == "*"
|
||||
and grant.permission == "read"
|
||||
):
|
||||
if grant.principal_type == 'user' and grant.principal_id == '*' and grant.permission == 'read':
|
||||
is_public = True
|
||||
continue # Don't add wildcard to user_ids list
|
||||
|
||||
if grant.permission not in ("read", "write"):
|
||||
if grant.permission not in ('read', 'write'):
|
||||
continue
|
||||
|
||||
if grant.principal_type == "group":
|
||||
if grant.principal_id not in result[grant.permission]["group_ids"]:
|
||||
result[grant.permission]["group_ids"].append(grant.principal_id)
|
||||
elif grant.principal_type == "user":
|
||||
if grant.principal_id not in result[grant.permission]["user_ids"]:
|
||||
result[grant.permission]["user_ids"].append(grant.principal_id)
|
||||
if grant.principal_type == 'group':
|
||||
if grant.principal_id not in result[grant.permission]['group_ids']:
|
||||
result[grant.permission]['group_ids'].append(grant.principal_id)
|
||||
elif grant.principal_type == 'user':
|
||||
if grant.principal_id not in result[grant.permission]['user_ids']:
|
||||
result[grant.permission]['user_ids'].append(grant.principal_id)
|
||||
|
||||
if is_public:
|
||||
return None # Public read access
|
||||
@@ -296,29 +280,28 @@ def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||
|
||||
|
||||
class AccessGrantsTable:
|
||||
def grant_access(
|
||||
async def grant_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
permission: str,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[AccessGrantModel]:
|
||||
"""Add a single access grant. Idempotent (ignores duplicates)."""
|
||||
with get_db_context(db) as db:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Check for existing grant
|
||||
existing = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
return AccessGrantModel.model_validate(existing)
|
||||
|
||||
@@ -332,76 +315,72 @@ class AccessGrantsTable:
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
db.commit()
|
||||
db.refresh(grant)
|
||||
await db.commit()
|
||||
await db.refresh(grant)
|
||||
return AccessGrantModel.model_validate(grant)
|
||||
|
||||
def revoke_access(
|
||||
async def revoke_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
permission: str,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
"""Remove a single access grant."""
|
||||
with get_db_context(db) as db:
|
||||
deleted = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
delete(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return deleted > 0
|
||||
await db.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def revoke_all_access(
|
||||
async def revoke_all_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> int:
|
||||
"""Remove all access grants for a resource."""
|
||||
with get_db_context(db) as db:
|
||||
deleted = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
delete(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return deleted
|
||||
await db.commit()
|
||||
return result.rowcount
|
||||
|
||||
def set_access_control(
|
||||
async def set_access_control(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_control: Optional[dict],
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""
|
||||
Replace all grants for a resource from an access_control JSON dict.
|
||||
This is the primary bridge for backward compat with the frontend.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Delete all existing grants for this resource
|
||||
db.query(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
).delete()
|
||||
await db.execute(
|
||||
delete(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert JSON to grant dicts
|
||||
grant_dicts = access_control_to_grants(
|
||||
resource_type, resource_id, access_control
|
||||
)
|
||||
grant_dicts = access_control_to_grants(resource_type, resource_id, access_control)
|
||||
|
||||
# Insert new grants
|
||||
results = []
|
||||
@@ -414,25 +393,27 @@ class AccessGrantsTable:
|
||||
db.add(grant)
|
||||
results.append(grant)
|
||||
|
||||
db.commit()
|
||||
await db.commit()
|
||||
|
||||
return [AccessGrantModel.model_validate(g) for g in results]
|
||||
|
||||
def set_access_grants(
|
||||
async def set_access_grants(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_grants: Optional[list],
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""
|
||||
Replace all grants for a resource from a direct access_grants list.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
db.query(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
).delete()
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(
|
||||
delete(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
)
|
||||
|
||||
normalized_grants = normalize_access_grants(access_grants)
|
||||
|
||||
@@ -442,90 +423,85 @@ class AccessGrantsTable:
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=grant_dict["principal_type"],
|
||||
principal_id=grant_dict["principal_id"],
|
||||
permission=grant_dict["permission"],
|
||||
principal_type=grant_dict['principal_type'],
|
||||
principal_id=grant_dict['principal_id'],
|
||||
permission=grant_dict['permission'],
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
results.append(grant)
|
||||
|
||||
db.commit()
|
||||
await db.commit()
|
||||
return [AccessGrantModel.model_validate(g) for g in results]
|
||||
|
||||
def get_access_control(
|
||||
async def get_access_control(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Reconstruct the old-style access_control JSON dict from grants.
|
||||
For backward compat with the frontend.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grants = result.scalars().all()
|
||||
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
|
||||
return grants_to_access_control(grant_models)
|
||||
|
||||
def get_grants_by_resource(
|
||||
async def get_grants_by_resource(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""Get all grants for a specific resource."""
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grants = result.scalars().all()
|
||||
return [AccessGrantModel.model_validate(g) for g in grants]
|
||||
|
||||
def get_grants_by_resources(
|
||||
async def get_grants_by_resources(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_ids: list[str],
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, list[AccessGrantModel]]:
|
||||
"""Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}."""
|
||||
if not resource_ids:
|
||||
return {}
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter(
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id.in_(resource_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
result: dict[str, list[AccessGrantModel]] = {
|
||||
rid: [] for rid in resource_ids
|
||||
}
|
||||
grants = result.scalars().all()
|
||||
result_dict: dict[str, list[AccessGrantModel]] = {rid: [] for rid in resource_ids}
|
||||
for g in grants:
|
||||
result[g.resource_id].append(AccessGrantModel.model_validate(g))
|
||||
return result
|
||||
result_dict[g.resource_id].append(AccessGrantModel.model_validate(g))
|
||||
return result_dict
|
||||
|
||||
def has_access(
|
||||
async def has_access(
|
||||
self,
|
||||
user_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = "read",
|
||||
permission: str = 'read',
|
||||
user_group_ids: Optional[set[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified permission on a resource.
|
||||
@@ -535,17 +511,17 @@ class AccessGrantsTable:
|
||||
- There's a grant for the specific user with the requested permission
|
||||
- There's a grant for any of the user's groups with the requested permission
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Build conditions for matching grants
|
||||
conditions = [
|
||||
# Public access
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
),
|
||||
# Direct user access
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == user_id,
|
||||
),
|
||||
]
|
||||
@@ -554,37 +530,38 @@ class AccessGrantsTable:
|
||||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(user_group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
exists = (
|
||||
db.query(AccessGrant)
|
||||
result = await db.execute(
|
||||
select(AccessGrant)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == resource_id,
|
||||
AccessGrant.permission == permission,
|
||||
or_(*conditions),
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
return exists is not None
|
||||
grant = result.scalars().first()
|
||||
return grant is not None
|
||||
|
||||
def get_accessible_resource_ids(
|
||||
async def get_accessible_resource_ids(
|
||||
self,
|
||||
user_id: str,
|
||||
resource_type: str,
|
||||
resource_ids: list[str],
|
||||
permission: str = "read",
|
||||
permission: str = 'read',
|
||||
user_group_ids: Optional[set[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
Batch check: return the subset of resource_ids that the user can access.
|
||||
@@ -594,14 +571,14 @@ class AccessGrantsTable:
|
||||
if not resource_ids:
|
||||
return set()
|
||||
|
||||
with get_db_context(db) as db:
|
||||
async with get_async_db_context(db) as db:
|
||||
conditions = [
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
),
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == user_id,
|
||||
),
|
||||
]
|
||||
@@ -609,19 +586,19 @@ class AccessGrantsTable:
|
||||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(user_group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(AccessGrant.resource_id)
|
||||
result = await db.execute(
|
||||
select(AccessGrant.resource_id)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id.in_(resource_ids),
|
||||
@@ -629,57 +606,54 @@ class AccessGrantsTable:
|
||||
or_(*conditions),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
rows = result.all()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
def get_users_with_access(
|
||||
async def get_users_with_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = "read",
|
||||
db: Optional[Session] = None,
|
||||
permission: str = 'read',
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get all users who have the specified permission on a resource.
|
||||
Returns a list of UserModel instances.
|
||||
"""
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import UserModel, Users
|
||||
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
permission=permission,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grants = result.scalars().all()
|
||||
|
||||
# Check for public access
|
||||
for grant in grants:
|
||||
if grant.principal_type == "user" and grant.principal_id == "*":
|
||||
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
|
||||
return result.get("users", [])
|
||||
if grant.principal_type == 'user' and grant.principal_id == '*':
|
||||
result = await Users.get_users(filter={'roles': ['!pending']}, db=db)
|
||||
return result.get('users', [])
|
||||
|
||||
user_ids_with_access = set()
|
||||
|
||||
for grant in grants:
|
||||
if grant.principal_type == "user":
|
||||
if grant.principal_type == 'user':
|
||||
user_ids_with_access.add(grant.principal_id)
|
||||
elif grant.principal_type == "group":
|
||||
group_user_ids = Groups.get_group_user_ids_by_id(
|
||||
grant.principal_id, db=db
|
||||
)
|
||||
elif grant.principal_type == 'group':
|
||||
group_user_ids = await Groups.get_group_user_ids_by_id(grant.principal_id, db=db)
|
||||
if group_user_ids:
|
||||
user_ids_with_access.update(group_user_ids)
|
||||
|
||||
if not user_ids_with_access:
|
||||
return []
|
||||
|
||||
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
|
||||
return await Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
|
||||
|
||||
def has_permission_filter(
|
||||
self,
|
||||
@@ -688,20 +662,22 @@ class AccessGrantsTable:
|
||||
DocumentModel,
|
||||
filter: dict,
|
||||
resource_type: str,
|
||||
permission: str = "read",
|
||||
permission: str = 'read',
|
||||
):
|
||||
"""
|
||||
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
|
||||
|
||||
This replaces the old JSON-column-based filtering with a proper relational JOIN.
|
||||
"""
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
if permission == "read_only":
|
||||
return self._has_read_only_permission_filter(
|
||||
db, query, DocumentModel, filter, resource_type
|
||||
)
|
||||
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
|
||||
so it remains synchronous. The caller is responsible for executing the query
|
||||
asynchronously with `await db.execute(...)`.
|
||||
"""
|
||||
group_ids = filter.get('group_ids', [])
|
||||
user_id = filter.get('user_id')
|
||||
|
||||
if permission == 'read_only':
|
||||
return self._has_read_only_permission_filter(db, query, DocumentModel, filter, resource_type)
|
||||
|
||||
# Build principal conditions
|
||||
principal_conditions = []
|
||||
@@ -710,8 +686,8 @@ class AccessGrantsTable:
|
||||
# Public access: user:* read
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -722,7 +698,7 @@ class AccessGrantsTable:
|
||||
# Direct user grant
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
)
|
||||
@@ -731,7 +707,7 @@ class AccessGrantsTable:
|
||||
# Group grants
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
@@ -741,7 +717,7 @@ class AccessGrantsTable:
|
||||
|
||||
# LEFT JOIN access_grant and filter
|
||||
# We use a subquery approach to avoid duplicates from multiple matching grants
|
||||
from sqlalchemy import exists as sa_exists, select
|
||||
from sqlalchemy import exists as sa_exists
|
||||
|
||||
grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
@@ -751,13 +727,13 @@ class AccessGrantsTable:
|
||||
AccessGrant.permission == permission,
|
||||
or_(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
@@ -767,7 +743,7 @@ class AccessGrantsTable:
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
@@ -799,11 +775,15 @@ class AccessGrantsTable:
|
||||
"""
|
||||
Filter for items where user has read BUT NOT write access.
|
||||
Public items are NOT considered read_only.
|
||||
"""
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
from sqlalchemy import exists as sa_exists, select
|
||||
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
|
||||
so it remains synchronous. The caller is responsible for executing the query
|
||||
asynchronously with `await db.execute(...)`.
|
||||
"""
|
||||
group_ids = filter.get('group_ids', [])
|
||||
user_id = filter.get('user_id')
|
||||
|
||||
from sqlalchemy import exists as sa_exists
|
||||
|
||||
# Has read grant (not public)
|
||||
read_grant_exists = (
|
||||
@@ -811,12 +791,12 @@ class AccessGrantsTable:
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "read",
|
||||
AccessGrant.permission == 'read',
|
||||
or_(
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
@@ -826,7 +806,7 @@ class AccessGrantsTable:
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
@@ -845,12 +825,12 @@ class AccessGrantsTable:
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "write",
|
||||
AccessGrant.permission == 'write',
|
||||
or_(
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
@@ -860,7 +840,7 @@ class AccessGrantsTable:
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
@@ -879,9 +859,9 @@ class AccessGrantsTable:
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "read",
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
AccessGrant.permission == 'read',
|
||||
AccessGrant.principal_type == 'user',
|
||||
AccessGrant.principal_id == '*',
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
|
||||
+143
-136
@@ -1,49 +1,50 @@
|
||||
"""Auth credential models and data-access layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import Boolean, Column, String, Text
|
||||
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
# DB MODEL
|
||||
####################
|
||||
|
||||
class Auth(Base): # credential ↔ user linkage
|
||||
"""Maps a user ID to an email/password pair with an active flag."""
|
||||
|
||||
class Auth(Base):
|
||||
__tablename__ = "auth"
|
||||
__tablename__ = 'auth'
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
email = Column(String)
|
||||
password = Column(Text)
|
||||
active = Column(Boolean)
|
||||
id = Column(String, primary_key=True, unique=True) # mirrors User.id
|
||||
email = Column(String) # login address, kept in sync with User.email
|
||||
password = Column(Text) # argon2 / bcrypt hash
|
||||
active = Column(Boolean) # account soft-disable toggle
|
||||
|
||||
|
||||
class AuthModel(BaseModel):
|
||||
"""Pydantic mirror of the ``auth`` table row."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
password: str
|
||||
active: bool = True
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""JWT bearer-token response wrapper."""
|
||||
|
||||
token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class ApiKey(BaseModel):
|
||||
api_key: Optional[str] = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
class SigninResponse(Token, UserProfileImageResponse):
|
||||
@@ -73,152 +74,158 @@ class SignupForm(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
password: str
|
||||
profile_image_url: Optional[str] = "/user.png"
|
||||
profile_image_url: str | None = '/user.png'
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@field_validator('profile_image_url')
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
def check_profile_image_url(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
return validate_profile_image_url(v)
|
||||
return v
|
||||
|
||||
|
||||
class AddUserForm(SignupForm):
|
||||
role: Optional[str] = "pending"
|
||||
role: str | None = 'pending'
|
||||
|
||||
|
||||
# --- data-access layer ---
|
||||
|
||||
|
||||
class AuthsTable:
|
||||
def insert_new_auth(
|
||||
"""Provides CRUD operations for the Auth ↔ User lifecycle."""
|
||||
|
||||
async def insert_new_auth(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
name: str,
|
||||
profile_image_url: str = "/user.png",
|
||||
role: str = "pending",
|
||||
oauth: Optional[dict] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[UserModel]:
|
||||
with get_db_context(db) as db:
|
||||
log.info("insert_new_auth")
|
||||
profile_image_url: str = '/user.png',
|
||||
role: str = 'pending',
|
||||
oauth: dict | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Create an Auth + User pair inside a single transaction."""
|
||||
async with get_async_db_context(db) as session:
|
||||
log.info('insert_new_auth')
|
||||
|
||||
id = str(uuid.uuid4())
|
||||
new_id = str(uuid.uuid4())
|
||||
|
||||
auth = AuthModel(
|
||||
**{"id": id, "email": email, "password": password, "active": True}
|
||||
credential = Auth(
|
||||
id=new_id,
|
||||
email=email,
|
||||
password=password,
|
||||
active=True,
|
||||
)
|
||||
result = Auth(**auth.model_dump())
|
||||
db.add(result)
|
||||
session.add(credential)
|
||||
|
||||
user = Users.insert_new_user(
|
||||
id, name, email, profile_image_url, role, oauth=oauth, db=db
|
||||
created_user = await Users.insert_new_user(
|
||||
new_id,
|
||||
name,
|
||||
email,
|
||||
profile_image_url,
|
||||
role,
|
||||
oauth=oauth,
|
||||
db=session,
|
||||
)
|
||||
# persist both records and reload generated defaults
|
||||
await session.commit()
|
||||
await session.refresh(credential)
|
||||
return created_user if credential and created_user else None
|
||||
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
async def authenticate_user(
|
||||
self,
|
||||
email: str,
|
||||
verify_password: callable,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Verify email + password credentials and return the matching user."""
|
||||
log.info('authenticate_user: %s', email)
|
||||
resolved = await Users.get_user_by_email(email, db=db)
|
||||
if not resolved:
|
||||
return
|
||||
# load the credential row and verify the password hash
|
||||
async with get_async_db_context(db) as session:
|
||||
credential = await session.get(Auth, resolved.id)
|
||||
if not credential or not credential.active:
|
||||
return
|
||||
if not verify_password(credential.password):
|
||||
return
|
||||
return resolved
|
||||
|
||||
if result and user:
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
|
||||
def authenticate_user(
|
||||
self, email: str, verify_password: callable, db: Optional[Session] = None
|
||||
) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user: {email}")
|
||||
|
||||
user = Users.get_user_by_email(email, db=db)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
|
||||
if auth:
|
||||
if verify_password(auth.password):
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def authenticate_user_by_api_key(
|
||||
self, api_key: str, db: Optional[Session] = None
|
||||
) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user_by_api_key")
|
||||
# if no api_key, return None
|
||||
async def authenticate_user_by_api_key(
|
||||
self,
|
||||
api_key: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Look up the user that owns the given API key."""
|
||||
log.info('authenticate_user_by_api_key')
|
||||
if not api_key:
|
||||
return None
|
||||
return
|
||||
# delegate to the Users model for the actual lookup
|
||||
return await Users.get_user_by_api_key(api_key, db=db)
|
||||
|
||||
try:
|
||||
user = Users.get_user_by_api_key(api_key, db=db)
|
||||
return user if user else None
|
||||
except Exception:
|
||||
return False
|
||||
async def authenticate_user_by_email(
|
||||
self,
|
||||
email: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> UserModel | None:
|
||||
"""Single-query auth via JOIN on Auth ↔ User, filtered by active flag."""
|
||||
log.info('authenticate_user_by_email: %s', email)
|
||||
# single JOIN avoids N+1 — returns (Auth, User) tuple or None
|
||||
async with get_async_db_context(db) as session:
|
||||
joined_query = (
|
||||
select(Auth, User).join(User, Auth.id == User.id).where(Auth.email == email, Auth.active.is_(True))
|
||||
)
|
||||
match = (await session.execute(joined_query)).first()
|
||||
if not match:
|
||||
return
|
||||
_, found_user = match
|
||||
return UserModel.model_validate(found_user)
|
||||
|
||||
def authenticate_user_by_email(
|
||||
self, email: str, db: Optional[Session] = None
|
||||
) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user_by_email: {email}")
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
# Single JOIN query instead of two separate queries
|
||||
result = (
|
||||
db.query(Auth, User)
|
||||
.join(User, Auth.id == User.id)
|
||||
.filter(Auth.email == email, Auth.active == True)
|
||||
.first()
|
||||
)
|
||||
if result:
|
||||
_, user = result
|
||||
return UserModel.model_validate(user)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_user_password_by_id(
|
||||
self, id: str, new_password: str, db: Optional[Session] = None
|
||||
async def update_email_by_id(
|
||||
self,
|
||||
user_id: str,
|
||||
email: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = (
|
||||
db.query(Auth).filter_by(id=id).update({"password": new_password})
|
||||
)
|
||||
db.commit()
|
||||
return True if result == 1 else False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def update_email_by_id(
|
||||
self, id: str, email: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = db.query(Auth).filter_by(id=id).update({"email": email})
|
||||
db.commit()
|
||||
if result == 1:
|
||||
Users.update_user_by_id(id, {"email": email}, db=db)
|
||||
return True
|
||||
"""Set a new email on the auth record and propagate to the user row."""
|
||||
async with get_async_db_context(db) as session:
|
||||
auth_row = await session.get(Auth, user_id)
|
||||
if auth_row is None:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
auth_row.email = email
|
||||
await session.commit()
|
||||
await Users.update_user_by_id(user_id, {'email': email}, db=session)
|
||||
return True
|
||||
# --- password modification ---
|
||||
|
||||
def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
# Delete User
|
||||
result = Users.delete_user_by_id(id, db=db)
|
||||
async def update_user_password_by_id(
|
||||
self,
|
||||
user_id: str,
|
||||
new_password: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Set a new password hash for an existing user."""
|
||||
async with get_async_db_context(db) as session:
|
||||
auth_row = await session.get(Auth, user_id)
|
||||
if auth_row is None:
|
||||
return False
|
||||
auth_row.password = new_password
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
if result:
|
||||
db.query(Auth).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
async def delete_auth_by_id(
|
||||
self,
|
||||
id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Remove a user and their auth credential in one transaction."""
|
||||
async with get_async_db_context(db) as session:
|
||||
if not await Users.delete_user_by_id(id, db=session):
|
||||
return False
|
||||
await session.execute(delete(Auth).where(Auth.id == id))
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
Auths = AuthsTable()
|
||||
Auths = AuthsTable() # singleton — module-level instance
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# Automation DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Automation(Base):
|
||||
__tablename__ = 'automation'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
|
||||
meta = Column(JSON, nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
last_run_at = Column(BigInteger, nullable=True)
|
||||
next_run_at = Column(BigInteger, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
|
||||
|
||||
|
||||
class AutomationRun(Base):
|
||||
__tablename__ = 'automation_run'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
automation_id = Column(Text, nullable=False)
|
||||
chat_id = Column(Text, nullable=True)
|
||||
status = Column(Text, nullable=False) # success | error
|
||||
error = Column(Text, nullable=True)
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_automation_run_automation_id', 'automation_id'),
|
||||
Index('ix_automation_run_aid_created', 'automation_id', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# Pydantic Models
|
||||
####################
|
||||
|
||||
|
||||
class AutomationTerminalConfig(BaseModel):
|
||||
server_id: str
|
||||
cwd: Optional[str] = None
|
||||
|
||||
|
||||
class AutomationData(BaseModel):
|
||||
prompt: str
|
||||
model_id: str
|
||||
rrule: str
|
||||
terminal: Optional[AutomationTerminalConfig] = None
|
||||
|
||||
|
||||
class AutomationModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
data: dict
|
||||
meta: Optional[dict] = None
|
||||
is_active: bool
|
||||
last_run_at: Optional[int] = None
|
||||
next_run_at: Optional[int] = None
|
||||
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class AutomationRunModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
automation_id: str
|
||||
chat_id: Optional[str] = None
|
||||
status: str
|
||||
error: Optional[str] = None
|
||||
created_at: int
|
||||
|
||||
|
||||
class AutomationForm(BaseModel):
|
||||
name: str
|
||||
data: AutomationData
|
||||
meta: Optional[dict] = None
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
|
||||
class AutomationResponse(AutomationModel):
|
||||
last_run: Optional[AutomationRunModel] = None
|
||||
next_runs: Optional[list[int]] = None
|
||||
|
||||
|
||||
class AutomationListResponse(BaseModel):
|
||||
items: list[AutomationModel]
|
||||
total: int
|
||||
|
||||
|
||||
####################
|
||||
# AutomationTable
|
||||
####################
|
||||
|
||||
|
||||
class AutomationTable:
|
||||
async def insert(
|
||||
self,
|
||||
user_id: str,
|
||||
form: AutomationForm,
|
||||
next_run_at: int,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> AutomationModel:
|
||||
async with get_async_db_context(db) as db:
|
||||
now = int(time.time_ns())
|
||||
row = Automation(
|
||||
id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
name=form.name,
|
||||
data=form.data.model_dump(),
|
||||
meta=form.meta,
|
||||
is_active=form.is_active,
|
||||
next_run_at=next_run_at,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(func.count()).select_from(Automation).filter_by(user_id=user_id))
|
||||
return result.scalar()
|
||||
|
||||
async def get_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
row = await db.get(Automation, id)
|
||||
return AutomationModel.model_validate(row) if row else None
|
||||
|
||||
async def get_active_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[AutomationModel]:
|
||||
"""Get active automations for a user (for calendar RRULE expansion)."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(Automation).filter_by(user_id=user_id, is_active=True).order_by(Automation.created_at.desc())
|
||||
)
|
||||
return [AutomationModel.model_validate(r) for r in result.scalars().all()]
|
||||
|
||||
async def search_automations(
|
||||
self,
|
||||
user_id: str,
|
||||
query: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> 'AutomationListResponse':
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Automation).filter_by(user_id=user_id)
|
||||
|
||||
if query:
|
||||
search = f'%{query}%'
|
||||
# Search in name and prompt inside JSON data
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
Automation.name.ilike(search),
|
||||
cast(Automation.data, String).ilike(search),
|
||||
)
|
||||
)
|
||||
|
||||
if status == 'active':
|
||||
stmt = stmt.filter(Automation.is_active == True)
|
||||
elif status == 'paused':
|
||||
stmt = stmt.filter(Automation.is_active == False)
|
||||
|
||||
stmt = stmt.order_by(Automation.created_at.desc())
|
||||
|
||||
# Get total count
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return AutomationListResponse(
|
||||
items=[AutomationModel.model_validate(r) for r in rows],
|
||||
total=total,
|
||||
)
|
||||
|
||||
async def update_by_id(
|
||||
self,
|
||||
id: str,
|
||||
form: AutomationForm,
|
||||
next_run_at: int,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[AutomationModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
row = await db.get(Automation, id)
|
||||
if not row:
|
||||
return None
|
||||
row.name = form.name
|
||||
row.data = form.data.model_dump()
|
||||
row.meta = form.meta
|
||||
if form.is_active is not None:
|
||||
row.is_active = form.is_active
|
||||
row.next_run_at = next_run_at
|
||||
row.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def toggle(
|
||||
self,
|
||||
id: str,
|
||||
next_run_at: Optional[int],
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[AutomationModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
row = await db.get(Automation, id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_active = not row.is_active
|
||||
row.next_run_at = next_run_at if row.is_active else None
|
||||
row.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
row = await db.get(Automation, id)
|
||||
if not row:
|
||||
return False
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
async def claim_due(self, now_ns: int, limit: int = 10, db: Optional[AsyncSession] = None) -> list[AutomationModel]:
|
||||
"""
|
||||
Atomically claim due automations for execution.
|
||||
|
||||
Advances next_run_at immediately so the row can never be
|
||||
double-claimed. On PostgreSQL, uses FOR UPDATE SKIP LOCKED
|
||||
for zero-contention distributed work claiming.
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = (
|
||||
select(Automation)
|
||||
.where(
|
||||
Automation.is_active == True,
|
||||
Automation.next_run_at <= now_ns,
|
||||
)
|
||||
.order_by(Automation.next_run_at)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if db.bind.dialect.name == 'postgresql':
|
||||
stmt = stmt.with_for_update(skip_locked=True)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
|
||||
from open_webui.utils.automations import next_run_ns
|
||||
|
||||
# Batch-fetch user timezones so rescheduling respects each
|
||||
# user's local timezone instead of falling back to server time.
|
||||
user_ids = list({row.user_id for row in rows})
|
||||
timezone_by_user_id: dict[str, Optional[str]] = {}
|
||||
if user_ids:
|
||||
from open_webui.models.users import User
|
||||
|
||||
tz_result = await db.execute(select(User.id, User.timezone).where(User.id.in_(user_ids)))
|
||||
timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()}
|
||||
|
||||
for row in rows:
|
||||
row.last_run_at = now_ns
|
||||
row.next_run_at = next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
return [AutomationModel.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
####################
|
||||
# AutomationRunTable
|
||||
####################
|
||||
|
||||
|
||||
class AutomationRunTable:
|
||||
async def insert(
|
||||
self,
|
||||
automation_id: str,
|
||||
status: str,
|
||||
chat_id: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> AutomationRunModel:
|
||||
async with get_async_db_context(db) as db:
|
||||
row = AutomationRun(
|
||||
id=str(uuid4()),
|
||||
automation_id=automation_id,
|
||||
chat_id=chat_id,
|
||||
status=status,
|
||||
error=error,
|
||||
created_at=int(time.time_ns()),
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationRunModel.model_validate(row)
|
||||
|
||||
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AutomationRun)
|
||||
.filter_by(automation_id=automation_id)
|
||||
.order_by(AutomationRun.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
return AutomationRunModel.model_validate(row) if row else None
|
||||
|
||||
async def get_latest_batch(
|
||||
self, automation_ids: list[str], db: Optional[AsyncSession] = None
|
||||
) -> dict[str, AutomationRunModel]:
|
||||
"""Fetch the latest run for each automation in a single query."""
|
||||
if not automation_ids:
|
||||
return {}
|
||||
async with get_async_db_context(db) as db:
|
||||
# Subquery: max created_at per automation_id
|
||||
subq = (
|
||||
select(
|
||||
AutomationRun.automation_id,
|
||||
func.max(AutomationRun.created_at).label('max_created'),
|
||||
)
|
||||
.filter(AutomationRun.automation_id.in_(automation_ids))
|
||||
.group_by(AutomationRun.automation_id)
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(AutomationRun).join(
|
||||
subq,
|
||||
(AutomationRun.automation_id == subq.c.automation_id)
|
||||
& (AutomationRun.created_at == subq.c.max_created),
|
||||
)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return {row.automation_id: AutomationRunModel.model_validate(row) for row in rows}
|
||||
|
||||
async def get_by_automation(
|
||||
self,
|
||||
automation_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[AutomationRunModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AutomationRun)
|
||||
.filter_by(automation_id=automation_id)
|
||||
.order_by(AutomationRun.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return [AutomationRunModel.model_validate(r) for r in rows]
|
||||
|
||||
async def delete_by_automation(self, automation_id: str, db: Optional[AsyncSession] = None) -> int:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(delete(AutomationRun).filter_by(automation_id=automation_id))
|
||||
await db.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def get_runs_by_user_range(
|
||||
self,
|
||||
user_id: str,
|
||||
start_ns: int,
|
||||
end_ns: int,
|
||||
limit: int = 500,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[tuple['AutomationRunModel', 'AutomationModel']]:
|
||||
"""Get runs within a date range for a user, joined with parent automation."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AutomationRun, Automation)
|
||||
.join(Automation, Automation.id == AutomationRun.automation_id)
|
||||
.filter(
|
||||
Automation.user_id == user_id,
|
||||
AutomationRun.created_at >= start_ns,
|
||||
AutomationRun.created_at < end_ns,
|
||||
)
|
||||
.order_by(AutomationRun.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [
|
||||
(AutomationRunModel.model_validate(run), AutomationModel.model_validate(auto))
|
||||
for run, auto in result.all()
|
||||
]
|
||||
|
||||
|
||||
Automations = AutomationTable()
|
||||
AutomationRuns = AutomationRunTable()
|
||||
@@ -0,0 +1,830 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, UserResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
Index,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
delete,
|
||||
exists,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# Calendar DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Calendar(Base):
|
||||
__tablename__ = 'calendar'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
color = Column(Text, nullable=True)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (Index('ix_calendar_user', 'user_id'),)
|
||||
|
||||
|
||||
class CalendarEvent(Base):
|
||||
__tablename__ = 'calendar_event'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
calendar_id = Column(Text, nullable=False)
|
||||
user_id = Column(Text, nullable=False)
|
||||
title = Column(Text, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
start_at = Column(BigInteger, nullable=False)
|
||||
end_at = Column(BigInteger, nullable=True)
|
||||
all_day = Column(Boolean, nullable=False, default=False)
|
||||
rrule = Column(Text, nullable=True)
|
||||
color = Column(Text, nullable=True)
|
||||
location = Column(Text, nullable=True)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
is_cancelled = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'),
|
||||
Index('ix_calendar_event_user_date', 'user_id', 'start_at'),
|
||||
)
|
||||
|
||||
|
||||
class CalendarEventAttendee(Base):
|
||||
__tablename__ = 'calendar_event_attendee'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
event_id = Column(Text, nullable=False)
|
||||
user_id = Column(Text, nullable=False)
|
||||
status = Column(Text, nullable=False, default='pending')
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
|
||||
Index('ix_calendar_event_attendee_user', 'user_id', 'status'),
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# Pydantic Models
|
||||
####################
|
||||
|
||||
|
||||
class CalendarModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
is_default: bool = False
|
||||
is_system: bool = False
|
||||
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class CalendarEventModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, extra='allow')
|
||||
|
||||
id: str
|
||||
calendar_id: str
|
||||
user_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
start_at: int
|
||||
end_at: Optional[int] = None
|
||||
all_day: bool = False
|
||||
rrule: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
is_cancelled: bool = False
|
||||
|
||||
attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list)
|
||||
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class CalendarEventAttendeeModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
event_id: str
|
||||
user_id: str
|
||||
status: str = 'pending'
|
||||
meta: Optional[dict] = None
|
||||
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class CalendarForm(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class CalendarUpdateForm(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class CalendarEventForm(BaseModel):
|
||||
calendar_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
start_at: int
|
||||
end_at: Optional[int] = None
|
||||
all_day: bool = False
|
||||
rrule: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
attendees: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class CalendarEventUpdateForm(BaseModel):
|
||||
calendar_id: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
start_at: Optional[int] = None
|
||||
end_at: Optional[int] = None
|
||||
all_day: Optional[bool] = None
|
||||
rrule: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
is_cancelled: Optional[bool] = None
|
||||
attendees: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class RSVPForm(BaseModel):
|
||||
status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
|
||||
|
||||
|
||||
####################
|
||||
# Response Models
|
||||
####################
|
||||
|
||||
|
||||
class CalendarEventUserResponse(CalendarEventModel):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class CalendarEventListResponse(BaseModel):
|
||||
items: list[CalendarEventUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
####################
|
||||
# Table Operations
|
||||
####################
|
||||
|
||||
|
||||
class CalendarTable:
|
||||
async def _get_access_grants(self, calendar_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
|
||||
return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db)
|
||||
|
||||
async def _to_calendar_model(
|
||||
self,
|
||||
cal: Calendar,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> CalendarModel:
|
||||
cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'})
|
||||
cal_data['access_grants'] = (
|
||||
access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db)
|
||||
)
|
||||
return CalendarModel.model_validate(cal_data)
|
||||
|
||||
async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
|
||||
"""Return user's calendars, creating 'Personal' default if none exist."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc())
|
||||
)
|
||||
calendars = result.scalars().all()
|
||||
|
||||
if calendars:
|
||||
return [CalendarModel.model_validate(c) for c in calendars]
|
||||
|
||||
now = int(time.time_ns())
|
||||
cal = Calendar(
|
||||
id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
name='Personal',
|
||||
color='#3b82f6',
|
||||
is_default=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(cal)
|
||||
await db.commit()
|
||||
return [CalendarModel.model_validate(cal)]
|
||||
|
||||
async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
|
||||
"""Owned + shared calendars."""
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
|
||||
stmt = select(Calendar)
|
||||
stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=stmt,
|
||||
DocumentModel=Calendar,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='calendar',
|
||||
permission='read',
|
||||
)
|
||||
stmt = stmt.order_by(Calendar.created_at.asc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
calendars = result.scalars().all()
|
||||
|
||||
if not calendars:
|
||||
return await self.get_or_create_defaults(user_id, db=db)
|
||||
|
||||
cal_ids = [c.id for c in calendars]
|
||||
grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db)
|
||||
|
||||
return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars]
|
||||
|
||||
async def get_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Calendar).filter(Calendar.id == id))
|
||||
cal = result.scalars().first()
|
||||
return await self._to_calendar_model(cal, db=db) if cal else None
|
||||
|
||||
async def insert_new_calendar(
|
||||
self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[CalendarModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
now = int(time.time_ns())
|
||||
cal = Calendar(
|
||||
id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
name=form_data.name,
|
||||
color=form_data.color,
|
||||
is_default=False,
|
||||
data=form_data.data,
|
||||
meta=form_data.meta,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(cal)
|
||||
await db.commit()
|
||||
if form_data.access_grants is not None:
|
||||
await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db)
|
||||
return await self._to_calendar_model(cal, db=db)
|
||||
|
||||
async def update_calendar_by_id(
|
||||
self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[CalendarModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Calendar).filter(Calendar.id == id))
|
||||
cal = result.scalars().first()
|
||||
if not cal:
|
||||
return None
|
||||
|
||||
update_data = form_data.model_dump(exclude_unset=True)
|
||||
if 'name' in update_data:
|
||||
cal.name = update_data['name']
|
||||
if 'color' in update_data:
|
||||
cal.color = update_data['color']
|
||||
if 'data' in update_data:
|
||||
cal.data = {**(cal.data or {}), **update_data['data']}
|
||||
if 'meta' in update_data:
|
||||
cal.meta = {**(cal.meta or {}), **update_data['meta']}
|
||||
if 'access_grants' in update_data:
|
||||
await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db)
|
||||
|
||||
cal.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
return await self._to_calendar_model(cal, db=db)
|
||||
|
||||
async def set_default_calendar(
|
||||
self, user_id: str, calendar_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[CalendarModel]:
|
||||
"""Set a calendar as the user's default, clearing all others."""
|
||||
async with get_async_db_context(db) as db:
|
||||
# Clear all defaults for this user
|
||||
await db.execute(
|
||||
update(Calendar)
|
||||
.where(Calendar.user_id == user_id, Calendar.is_default == True)
|
||||
.values(is_default=False)
|
||||
)
|
||||
# Set the new default
|
||||
result = await db.execute(select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id))
|
||||
cal = result.scalars().first()
|
||||
if not cal:
|
||||
return None
|
||||
cal.is_default = True
|
||||
cal.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
return await self._to_calendar_model(cal, db=db)
|
||||
|
||||
async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete a non-default calendar. Cascades to events, attendees, and grants."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Calendar).filter(Calendar.id == id))
|
||||
cal = result.scalars().first()
|
||||
if not cal or cal.is_default:
|
||||
return False
|
||||
|
||||
# Delete attendees for all events in this calendar
|
||||
event_ids_result = await db.execute(select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id))
|
||||
event_ids = [r[0] for r in event_ids_result.all()]
|
||||
if event_ids:
|
||||
await db.execute(
|
||||
delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
|
||||
)
|
||||
|
||||
# Delete events
|
||||
await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id))
|
||||
|
||||
# Delete calendar
|
||||
await db.execute(delete(Calendar).filter(Calendar.id == id))
|
||||
await db.commit()
|
||||
|
||||
# Revoke access grants in a separate transaction to avoid
|
||||
# write-lock contention on SQLite when session sharing is off.
|
||||
await AccessGrants.revoke_all_access('calendar', id)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to delete calendar {id}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
class CalendarEventTable:
|
||||
async def _get_attendees(
|
||||
self, event_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[CalendarEventAttendeeModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
|
||||
rows = result.scalars().all()
|
||||
return [CalendarEventAttendeeModel.model_validate(r) for r in rows]
|
||||
|
||||
async def _to_event_model(
|
||||
self,
|
||||
event: CalendarEvent,
|
||||
attendees: Optional[list[CalendarEventAttendeeModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> CalendarEventModel:
|
||||
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
|
||||
event_data['attendees'] = (
|
||||
attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db)
|
||||
)
|
||||
return CalendarEventModel.model_validate(event_data)
|
||||
|
||||
async def insert_new_event(
|
||||
self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[CalendarEventModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
now = int(time.time_ns())
|
||||
event = CalendarEvent(
|
||||
id=str(uuid4()),
|
||||
calendar_id=form_data.calendar_id,
|
||||
user_id=user_id,
|
||||
title=form_data.title,
|
||||
description=form_data.description,
|
||||
start_at=form_data.start_at,
|
||||
end_at=form_data.end_at,
|
||||
all_day=form_data.all_day,
|
||||
rrule=form_data.rrule,
|
||||
color=form_data.color,
|
||||
location=form_data.location,
|
||||
data=form_data.data,
|
||||
meta=form_data.meta,
|
||||
is_cancelled=False,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
|
||||
# Add attendees
|
||||
if form_data.attendees:
|
||||
await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db)
|
||||
|
||||
return await self._to_event_model(event, db=db)
|
||||
|
||||
async def get_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarEventModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
|
||||
event = result.scalars().first()
|
||||
return await self._to_event_model(event, db=db) if event else None
|
||||
|
||||
async def get_events_by_range(
|
||||
self,
|
||||
user_id: str,
|
||||
start: int,
|
||||
end: int,
|
||||
calendar_ids: Optional[list[str]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[CalendarEventUserResponse]:
|
||||
"""Fetch events visible to user within a date range.
|
||||
|
||||
Visible events = events in owned/shared calendars + events user attends.
|
||||
Recurring events are fetched if they have any rrule (expansion in Python).
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
|
||||
# Get calendar IDs accessible to user
|
||||
cal_stmt = select(Calendar.id)
|
||||
cal_stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=cal_stmt,
|
||||
DocumentModel=Calendar,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='calendar',
|
||||
permission='read',
|
||||
)
|
||||
cal_result = await db.execute(cal_stmt)
|
||||
accessible_cal_ids = [r[0] for r in cal_result.all()]
|
||||
|
||||
if calendar_ids:
|
||||
# Filter to requested calendars only
|
||||
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
|
||||
|
||||
# Also get event IDs where user is an attendee
|
||||
attendee_event_ids_result = await db.execute(
|
||||
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
|
||||
)
|
||||
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
|
||||
|
||||
# Build conditions for accessible events
|
||||
conditions = []
|
||||
if accessible_cal_ids:
|
||||
conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids))
|
||||
if attendee_event_ids:
|
||||
conditions.append(CalendarEvent.id.in_(attendee_event_ids))
|
||||
|
||||
if not conditions:
|
||||
return []
|
||||
|
||||
# Build event query
|
||||
stmt = (
|
||||
select(CalendarEvent, User)
|
||||
.outerjoin(User, User.id == CalendarEvent.user_id)
|
||||
.filter(
|
||||
CalendarEvent.is_cancelled == False,
|
||||
or_(*conditions),
|
||||
or_(
|
||||
# Non-recurring: overlaps the range
|
||||
(
|
||||
CalendarEvent.rrule.is_(None)
|
||||
& (CalendarEvent.start_at < end)
|
||||
& or_(
|
||||
CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
|
||||
CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start),
|
||||
)
|
||||
),
|
||||
# Recurring: fetch all (expansion in Python)
|
||||
CalendarEvent.rrule.isnot(None),
|
||||
),
|
||||
)
|
||||
.order_by(CalendarEvent.start_at.asc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = result.all()
|
||||
|
||||
if not items:
|
||||
return []
|
||||
|
||||
# Batch-load attendees for all events in one query (avoid N+1)
|
||||
event_ids = [event.id for event, _user in items]
|
||||
att_result = await db.execute(
|
||||
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
|
||||
)
|
||||
att_rows = att_result.scalars().all()
|
||||
att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
|
||||
for a in att_rows:
|
||||
att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
|
||||
|
||||
events = []
|
||||
for event, user in items:
|
||||
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
|
||||
event_data['attendees'] = att_map.get(event.id, [])
|
||||
events.append(
|
||||
CalendarEventUserResponse(
|
||||
**event_data,
|
||||
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
|
||||
)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
async def search_events(
|
||||
self,
|
||||
user_id: str,
|
||||
query: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> CalendarEventListResponse:
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
|
||||
# Get accessible calendar IDs
|
||||
cal_stmt = select(Calendar.id)
|
||||
cal_stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=cal_stmt,
|
||||
DocumentModel=Calendar,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='calendar',
|
||||
permission='read',
|
||||
)
|
||||
cal_result = await db.execute(cal_stmt)
|
||||
accessible_cal_ids = [r[0] for r in cal_result.all()]
|
||||
if not accessible_cal_ids:
|
||||
return CalendarEventListResponse(items=[], total=0)
|
||||
|
||||
stmt = (
|
||||
select(CalendarEvent, User)
|
||||
.outerjoin(User, User.id == CalendarEvent.user_id)
|
||||
.filter(
|
||||
CalendarEvent.is_cancelled == False,
|
||||
CalendarEvent.calendar_id.in_(accessible_cal_ids),
|
||||
)
|
||||
)
|
||||
|
||||
if query:
|
||||
search = f'%{query}%'
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
CalendarEvent.title.ilike(search),
|
||||
CalendarEvent.description.ilike(search),
|
||||
CalendarEvent.location.ilike(search),
|
||||
)
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(CalendarEvent.start_at.desc())
|
||||
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = result.all()
|
||||
|
||||
if not items:
|
||||
return CalendarEventListResponse(items=[], total=total)
|
||||
|
||||
# Batch-load attendees
|
||||
event_ids = [event.id for event, _user in items]
|
||||
att_result = await db.execute(
|
||||
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
|
||||
)
|
||||
att_rows = att_result.scalars().all()
|
||||
att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
|
||||
for a in att_rows:
|
||||
att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
|
||||
|
||||
events = []
|
||||
for event, user in items:
|
||||
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
|
||||
event_data['attendees'] = att_map.get(event.id, [])
|
||||
events.append(
|
||||
CalendarEventUserResponse(
|
||||
**event_data,
|
||||
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
|
||||
)
|
||||
)
|
||||
|
||||
return CalendarEventListResponse(items=events, total=total)
|
||||
|
||||
async def update_event_by_id(
|
||||
self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[CalendarEventModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
|
||||
event = result.scalars().first()
|
||||
if not event:
|
||||
return None
|
||||
|
||||
update_data = form_data.model_dump(exclude_unset=True)
|
||||
for field in [
|
||||
'calendar_id',
|
||||
'title',
|
||||
'description',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'all_day',
|
||||
'rrule',
|
||||
'color',
|
||||
'location',
|
||||
'is_cancelled',
|
||||
]:
|
||||
if field in update_data:
|
||||
setattr(event, field, update_data[field])
|
||||
|
||||
if 'data' in update_data and update_data['data'] is not None:
|
||||
event.data = {**(event.data or {}), **update_data['data']}
|
||||
if 'meta' in update_data and update_data['meta'] is not None:
|
||||
event.meta = {**(event.meta or {}), **update_data['meta']}
|
||||
|
||||
if 'attendees' in update_data and update_data['attendees'] is not None:
|
||||
await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db)
|
||||
|
||||
event.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
return await self._to_event_model(event, db=db)
|
||||
|
||||
async def get_upcoming_events(
|
||||
self,
|
||||
now_ns: int,
|
||||
default_lookahead_ns: int,
|
||||
grace_ns: int = 0,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[tuple[CalendarEventModel, Optional[str]]]:
|
||||
"""Events starting between now and now + lookahead, for alert processing.
|
||||
|
||||
Per-event lookahead is read from meta.alert_minutes (falls back to
|
||||
default_lookahead_ns). Returns (event, user_timezone) pairs.
|
||||
|
||||
*grace_ns* widens the SQL lower bound so that events whose start_at
|
||||
is up to *grace_ns* nanoseconds in the past are still fetched. This
|
||||
ensures "At time of event" alerts (alert_minutes=0) are not missed
|
||||
when the scheduler polls a few seconds after the event's exact start
|
||||
time.
|
||||
"""
|
||||
from open_webui.models.users import User as UserRow
|
||||
|
||||
# Use the maximum possible lookahead (60 min) to cast a wide net;
|
||||
# per-event filtering happens in Python after fetching.
|
||||
max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000)
|
||||
upper = now_ns + max_lookahead_ns
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(CalendarEvent, UserRow.timezone)
|
||||
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
|
||||
.filter(
|
||||
CalendarEvent.is_cancelled == False,
|
||||
CalendarEvent.start_at >= now_ns - grace_ns,
|
||||
CalendarEvent.start_at <= upper,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
events = []
|
||||
for event, tz in rows:
|
||||
model = CalendarEventModel.model_validate(event)
|
||||
# Determine per-event alert window
|
||||
alert_minutes = None
|
||||
if model.meta and 'alert_minutes' in model.meta:
|
||||
alert_minutes = model.meta['alert_minutes']
|
||||
|
||||
if alert_minutes is not None:
|
||||
if alert_minutes < 0:
|
||||
# alert_minutes < 0 means "no alert"
|
||||
continue
|
||||
event_lookahead_ns = alert_minutes * 60 * 1_000_000_000
|
||||
else:
|
||||
event_lookahead_ns = default_lookahead_ns
|
||||
|
||||
if model.start_at <= now_ns + event_lookahead_ns:
|
||||
events.append((model, tz))
|
||||
|
||||
return events
|
||||
|
||||
async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id))
|
||||
await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id))
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CalendarEventAttendeeTable:
|
||||
async def set_attendees(
|
||||
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
|
||||
) -> list[CalendarEventAttendeeModel]:
|
||||
"""Replace all attendees for an event.
|
||||
|
||||
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
# Remove existing
|
||||
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
|
||||
|
||||
now = int(time.time_ns())
|
||||
models = []
|
||||
for att in attendees:
|
||||
row = CalendarEventAttendee(
|
||||
id=str(uuid4()),
|
||||
event_id=event_id,
|
||||
user_id=att['user_id'],
|
||||
status=att.get('status', 'pending'),
|
||||
meta=att.get('meta'),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
models.append(CalendarEventAttendeeModel.model_validate(row))
|
||||
|
||||
await db.commit()
|
||||
return models
|
||||
|
||||
async def update_rsvp(
|
||||
self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[CalendarEventAttendeeModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(CalendarEventAttendee).filter(
|
||||
CalendarEventAttendee.event_id == event_id,
|
||||
CalendarEventAttendee.user_id == user_id,
|
||||
)
|
||||
)
|
||||
att = result.scalars().first()
|
||||
if not att:
|
||||
return None
|
||||
|
||||
att.status = status
|
||||
att.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
return CalendarEventAttendeeModel.model_validate(att)
|
||||
|
||||
async def get_attendees_by_event(
|
||||
self, event_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[CalendarEventAttendeeModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
|
||||
return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()]
|
||||
|
||||
async def get_events_by_attendee(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
||||
"""Return event IDs where user is an attendee."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
|
||||
)
|
||||
return [r[0] for r in result.all()]
|
||||
|
||||
|
||||
Calendars = CalendarTable()
|
||||
CalendarEvents = CalendarEventTable()
|
||||
CalendarEventAttendees = CalendarEventAttendeeTable()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,24 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db_context
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.utils.response import normalize_usage
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Text,
|
||||
JSON,
|
||||
Index,
|
||||
Integer,
|
||||
Text,
|
||||
cast,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
####################
|
||||
# Helpers
|
||||
@@ -41,19 +45,42 @@ def _normalize_timestamp(timestamp: int) -> float:
|
||||
return timestamp
|
||||
|
||||
|
||||
def get_usage(data: dict) -> Optional[dict]:
|
||||
"""Extract and normalize usage from message data."""
|
||||
usage = data.get('usage') or (data.get('info') or {}).get('usage')
|
||||
return normalize_usage(usage) if usage else None
|
||||
|
||||
|
||||
def _token_columns(dialect: str):
|
||||
"""Return (input_tokens, output_tokens) SQL column expressions.
|
||||
|
||||
Falls back to OpenAI-style keys (prompt_tokens / completion_tokens)
|
||||
when the normalized keys are absent.
|
||||
"""
|
||||
if dialect == 'sqlite':
|
||||
extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer)
|
||||
elif dialect == 'postgresql':
|
||||
extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer)
|
||||
else:
|
||||
raise NotImplementedError(f'Unsupported dialect: {dialect}')
|
||||
|
||||
return (
|
||||
func.coalesce(extract('input_tokens'), extract('prompt_tokens')),
|
||||
func.coalesce(extract('output_tokens'), extract('completion_tokens')),
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# ChatMessage DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_message"
|
||||
__tablename__ = 'chat_message'
|
||||
|
||||
# Identity
|
||||
id = Column(Text, primary_key=True)
|
||||
chat_id = Column(
|
||||
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
user_id = Column(Text, index=True)
|
||||
|
||||
# Structure
|
||||
@@ -85,9 +112,9 @@ class ChatMessage(Base):
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
__table_args__ = (
|
||||
Index("chat_message_chat_parent_idx", "chat_id", "parent_id"),
|
||||
Index("chat_message_model_created_idx", "model_id", "created_at"),
|
||||
Index("chat_message_user_created_idx", "user_id", "created_at"),
|
||||
Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'),
|
||||
Index('chat_message_model_created_idx', 'model_id', 'created_at'),
|
||||
Index('chat_message_user_created_idx', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,516 +151,533 @@ class ChatMessageModel(BaseModel):
|
||||
|
||||
|
||||
class ChatMessageTable:
|
||||
def upsert_message(
|
||||
async def upsert_message(
|
||||
self,
|
||||
message_id: str,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
data: dict,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[ChatMessageModel]:
|
||||
"""Insert or update a chat message."""
|
||||
with get_db_context(db) as db:
|
||||
async with get_async_db_context(db) as db:
|
||||
now = int(time.time())
|
||||
timestamp = data.get("timestamp", now)
|
||||
timestamp = data.get('timestamp', now)
|
||||
|
||||
# Use composite ID: {chat_id}-{message_id}
|
||||
composite_id = f"{chat_id}-{message_id}"
|
||||
composite_id = f'{chat_id}-{message_id}'
|
||||
|
||||
existing = db.get(ChatMessage, composite_id)
|
||||
existing = await db.get(ChatMessage, composite_id)
|
||||
if existing:
|
||||
# Update existing
|
||||
if "role" in data:
|
||||
existing.role = data["role"]
|
||||
if "parent_id" in data:
|
||||
existing.parent_id = data.get("parent_id") or data.get("parentId")
|
||||
if "content" in data:
|
||||
existing.content = data.get("content")
|
||||
if "output" in data:
|
||||
existing.output = data.get("output")
|
||||
if "model_id" in data or "model" in data:
|
||||
existing.model_id = data.get("model_id") or data.get("model")
|
||||
if "files" in data:
|
||||
existing.files = data.get("files")
|
||||
if "sources" in data:
|
||||
existing.sources = data.get("sources")
|
||||
if "embeds" in data:
|
||||
existing.embeds = data.get("embeds")
|
||||
if "done" in data:
|
||||
existing.done = data.get("done", True)
|
||||
if "status_history" in data or "statusHistory" in data:
|
||||
existing.status_history = data.get("status_history") or data.get(
|
||||
"statusHistory"
|
||||
)
|
||||
if "error" in data:
|
||||
existing.error = data.get("error")
|
||||
# Extract usage - check direct field first, then info.usage
|
||||
usage = data.get("usage")
|
||||
if not usage:
|
||||
info = data.get("info", {})
|
||||
usage = info.get("usage") if info else None
|
||||
if 'role' in data:
|
||||
existing.role = data['role']
|
||||
if 'parent_id' in data or 'parentId' in data:
|
||||
existing.parent_id = data.get('parent_id') or data.get('parentId')
|
||||
if 'content' in data:
|
||||
existing.content = data.get('content')
|
||||
if 'output' in data:
|
||||
existing.output = data.get('output')
|
||||
if 'model_id' in data or 'model' in data:
|
||||
existing.model_id = data.get('model_id') or data.get('model')
|
||||
if 'files' in data:
|
||||
existing.files = data.get('files')
|
||||
if 'sources' in data:
|
||||
existing.sources = data.get('sources')
|
||||
if 'embeds' in data:
|
||||
existing.embeds = data.get('embeds')
|
||||
if 'done' in data:
|
||||
existing.done = data.get('done', True)
|
||||
if 'status_history' in data or 'statusHistory' in data:
|
||||
existing.status_history = data.get('status_history') or data.get('statusHistory')
|
||||
if 'error' in data:
|
||||
existing.error = data.get('error')
|
||||
# Extract and normalize usage
|
||||
usage = get_usage(data)
|
||||
if usage:
|
||||
existing.usage = usage
|
||||
# Deep-merge: preserve existing keys not present in new data
|
||||
# This prevents background tasks (follow-ups, title, tags)
|
||||
# from accidentally clearing the primary response's token counts
|
||||
existing.usage = {**(existing.usage or {}), **usage}
|
||||
existing.updated_at = now
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return ChatMessageModel.model_validate(existing)
|
||||
else:
|
||||
# Insert new
|
||||
# Extract usage - check direct field first, then info.usage
|
||||
usage = data.get("usage")
|
||||
if not usage:
|
||||
info = data.get("info", {})
|
||||
usage = info.get("usage") if info else None
|
||||
# Extract and normalize usage
|
||||
usage = get_usage(data)
|
||||
message = ChatMessage(
|
||||
id=composite_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=data.get("role", "user"),
|
||||
parent_id=data.get("parent_id") or data.get("parentId"),
|
||||
content=data.get("content"),
|
||||
output=data.get("output"),
|
||||
model_id=data.get("model_id") or data.get("model"),
|
||||
files=data.get("files"),
|
||||
sources=data.get("sources"),
|
||||
embeds=data.get("embeds"),
|
||||
done=data.get("done", True),
|
||||
status_history=data.get("status_history")
|
||||
or data.get("statusHistory"),
|
||||
error=data.get("error"),
|
||||
role=data.get('role', 'user'),
|
||||
parent_id=data.get('parent_id') or data.get('parentId'),
|
||||
content=data.get('content'),
|
||||
output=data.get('output'),
|
||||
model_id=data.get('model_id') or data.get('model'),
|
||||
files=data.get('files'),
|
||||
sources=data.get('sources'),
|
||||
embeds=data.get('embeds'),
|
||||
done=data.get('done', True),
|
||||
status_history=data.get('status_history') or data.get('statusHistory'),
|
||||
error=data.get('error'),
|
||||
usage=usage,
|
||||
created_at=timestamp,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return ChatMessageModel.model_validate(message)
|
||||
|
||||
def get_message_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
message = db.get(ChatMessage, id)
|
||||
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
message = await db.get(ChatMessage, id)
|
||||
return ChatMessageModel.model_validate(message) if message else None
|
||||
|
||||
def get_messages_by_chat_id(
|
||||
self, chat_id: str, db: Optional[Session] = None
|
||||
) -> list[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
messages = (
|
||||
db.query(ChatMessage)
|
||||
.filter_by(chat_id=chat_id)
|
||||
.order_by(ChatMessage.created_at.asc())
|
||||
.all()
|
||||
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(ChatMessage).filter_by(chat_id=chat_id).order_by(ChatMessage.created_at.asc())
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
def get_messages_by_user_id(
|
||||
# DB column names that differ from the JSON message keys.
|
||||
DB_TO_JSON_KEY_MAP = {
|
||||
'parent_id': 'parentId',
|
||||
'model_id': 'model',
|
||||
'status_history': 'statusHistory',
|
||||
'created_at': 'timestamp',
|
||||
}
|
||||
# DB-internal columns excluded from the reconstructed message dict.
|
||||
EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'})
|
||||
|
||||
async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||
"""Build a {message_id: message_dict} map from chat_message rows.
|
||||
|
||||
Returns the same shape as chat.history.messages so callers
|
||||
(get_message_list, middleware) work unchanged. Returns None if
|
||||
no rows exist for the chat (caller should fall back to the
|
||||
embedded JSON blob for legacy chats).
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(ChatMessage).filter_by(chat_id=chat_id))
|
||||
rows = result.scalars().all()
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
# Strip the composite-id prefix ("{chat_id}-") to recover the
|
||||
# original message_id used as map key.
|
||||
prefix = f'{chat_id}-'
|
||||
prefix_len = len(prefix)
|
||||
col_keys = [c.key for c in ChatMessage.__table__.columns]
|
||||
|
||||
messages_map: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id
|
||||
|
||||
msg: dict = {'id': msg_id}
|
||||
for key in col_keys:
|
||||
if key in self.EXCLUDED_COLUMNS:
|
||||
continue
|
||||
val = getattr(row, key)
|
||||
if val is None:
|
||||
continue
|
||||
json_key = self.DB_TO_JSON_KEY_MAP.get(key, key)
|
||||
msg[json_key] = val
|
||||
|
||||
# Ensure content always has a value
|
||||
msg.setdefault('content', '')
|
||||
|
||||
# Mirror usage into info.usage for callers that read it there
|
||||
if 'usage' in msg:
|
||||
msg['info'] = {'usage': msg['usage']}
|
||||
|
||||
messages_map[msg_id] = msg
|
||||
|
||||
# Reconstruct childrenIds from parentId links so that the map
|
||||
# is fully navigable (callers like the frontend rely on this).
|
||||
for msg_id, msg in messages_map.items():
|
||||
parent_id = msg.get('parentId')
|
||||
if parent_id and parent_id in messages_map:
|
||||
parent = messages_map[parent_id]
|
||||
children = parent.get('childrenIds')
|
||||
if children is None:
|
||||
parent['childrenIds'] = [msg_id]
|
||||
elif msg_id not in children:
|
||||
children.append(msg_id)
|
||||
|
||||
# Ensure every message has a childrenIds list (leaf nodes get [])
|
||||
for msg in messages_map.values():
|
||||
if 'childrenIds' not in msg:
|
||||
msg['childrenIds'] = []
|
||||
|
||||
return messages_map
|
||||
|
||||
async def get_messages_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
messages = (
|
||||
db.query(ChatMessage)
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(ChatMessage)
|
||||
.filter_by(user_id=user_id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
def get_messages_by_model_id(
|
||||
async def get_messages_by_model_id(
|
||||
self,
|
||||
model_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(ChatMessage).filter_by(model_id=model_id)
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(ChatMessage).filter_by(model_id=model_id)
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
messages = (
|
||||
query.order_by(ChatMessage.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.order_by(ChatMessage.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
messages = result.scalars().all()
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
def get_chat_ids_by_model_id(
|
||||
async def get_chat_ids_by_model_id(
|
||||
self,
|
||||
model_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[str]:
|
||||
"""Get distinct chat_ids that used a specific model."""
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(
|
||||
ChatMessage.chat_id,
|
||||
func.max(ChatMessage.created_at).label("last_message_at"),
|
||||
func.max(ChatMessage.created_at).label('last_message_at'),
|
||||
).filter(ChatMessage.model_id == model_id)
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
# Group by chat_id and order by most recent message in each chat
|
||||
chat_ids = (
|
||||
query.group_by(ChatMessage.chat_id)
|
||||
.order_by(func.max(ChatMessage.created_at).desc())
|
||||
# Secondary sort on chat_id ensures deterministic pagination
|
||||
stmt = (
|
||||
stmt.group_by(ChatMessage.chat_id)
|
||||
.order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
chat_ids = result.all()
|
||||
return [chat_id for chat_id, _ in chat_ids]
|
||||
|
||||
def delete_messages_by_chat_id(
|
||||
self, chat_id: str, db: Optional[Session] = None
|
||||
async def delete_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(delete(ChatMessage).filter_by(chat_id=chat_id))
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
async def delete_message_ids_by_chat_id(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_ids: set[str],
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
db.query(ChatMessage).filter_by(chat_id=chat_id).delete()
|
||||
db.commit()
|
||||
"""Delete specific ``chat_message`` rows by their original message IDs."""
|
||||
if not message_ids:
|
||||
return True
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(
|
||||
delete(ChatMessage)
|
||||
.where(ChatMessage.chat_id == chat_id)
|
||||
.where(ChatMessage.id.in_({f'{chat_id}-{mid}' for mid in message_ids}))
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
# Analytics methods
|
||||
def get_message_count_by_model(
|
||||
async def get_message_count_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, int]:
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
|
||||
).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
stmt = select(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.model_id).all()
|
||||
return {row.model_id: row.count for row in results}
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
result = await db.execute(stmt)
|
||||
return {row.model_id: row.count for row in result.all()}
|
||||
|
||||
def get_token_usage_by_model(
|
||||
async def get_token_usage_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Aggregate token usage by model using database-level aggregation."""
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func, cast, Integer
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
dialect = db.bind.dialect.name
|
||||
# We need the dialect to determine JSON extraction syntax
|
||||
# For async sessions, access via get_bind()
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
if dialect == "sqlite":
|
||||
input_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||
)
|
||||
elif dialect == "postgresql":
|
||||
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
query = db.query(
|
||||
stmt = select(
|
||||
ChatMessage.model_id,
|
||||
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||
func.count(ChatMessage.id).label("message_count"),
|
||||
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
|
||||
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
|
||||
func.count(ChatMessage.id).label('message_count'),
|
||||
).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
ChatMessage.usage.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.model_id).all()
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
return {
|
||||
row.model_id: {
|
||||
"input_tokens": row.input_tokens,
|
||||
"output_tokens": row.output_tokens,
|
||||
"total_tokens": row.input_tokens + row.output_tokens,
|
||||
"message_count": row.message_count,
|
||||
'input_tokens': row.input_tokens,
|
||||
'output_tokens': row.output_tokens,
|
||||
'total_tokens': row.input_tokens + row.output_tokens,
|
||||
'message_count': row.message_count,
|
||||
}
|
||||
for row in results
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
def get_token_usage_by_user(
|
||||
async def get_token_usage_by_user(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
db: Optional[Session] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Aggregate token usage by user using database-level aggregation."""
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func, cast, Integer
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
dialect = db.bind.dialect.name
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
if dialect == "sqlite":
|
||||
input_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||
)
|
||||
elif dialect == "postgresql":
|
||||
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
query = db.query(
|
||||
stmt = select(
|
||||
ChatMessage.user_id,
|
||||
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||
func.count(ChatMessage.id).label("message_count"),
|
||||
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
|
||||
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
|
||||
func.count(ChatMessage.id).label('message_count'),
|
||||
).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.user_id.isnot(None),
|
||||
ChatMessage.usage.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.user_id).all()
|
||||
stmt = stmt.group_by(ChatMessage.user_id)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
return {
|
||||
row.user_id: {
|
||||
"input_tokens": row.input_tokens,
|
||||
"output_tokens": row.output_tokens,
|
||||
"total_tokens": row.input_tokens + row.output_tokens,
|
||||
"message_count": row.message_count,
|
||||
'input_tokens': row.input_tokens,
|
||||
'output_tokens': row.output_tokens,
|
||||
'total_tokens': row.input_tokens + row.output_tokens,
|
||||
'message_count': row.message_count,
|
||||
}
|
||||
for row in results
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
def get_message_count_by_user(
|
||||
async def get_message_count_by_user(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, int]:
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
|
||||
).filter(~ChatMessage.user_id.like("shared-%"))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.user_id).all()
|
||||
return {row.user_id: row.count for row in results}
|
||||
|
||||
def get_message_count_by_chat(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, int]:
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
|
||||
).filter(~ChatMessage.user_id.like("shared-%"))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.chat_id).all()
|
||||
return {row.chat_id: row.count for row in results}
|
||||
|
||||
def get_daily_message_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Get message counts grouped by day and model."""
|
||||
with get_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.model_id.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.all()
|
||||
stmt = stmt.group_by(ChatMessage.user_id)
|
||||
result = await db.execute(stmt)
|
||||
return {row.user_id: row.count for row in result.all()}
|
||||
|
||||
async def get_message_count_by_chat(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, int]:
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
)
|
||||
|
||||
if start_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.chat_id)
|
||||
result = await db.execute(stmt)
|
||||
return {row.chat_id: row.count for row in result.all()}
|
||||
|
||||
async def get_daily_message_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Get message counts grouped by day and model."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
results = result.all()
|
||||
|
||||
# Group by date -> model -> count
|
||||
daily_counts: dict[str, dict[str, int]] = {}
|
||||
for timestamp, model_id in results:
|
||||
date_str = datetime.fromtimestamp(
|
||||
_normalize_timestamp(timestamp)
|
||||
).strftime("%Y-%m-%d")
|
||||
date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d')
|
||||
if date_str not in daily_counts:
|
||||
daily_counts[date_str] = {}
|
||||
daily_counts[date_str][model_id] = (
|
||||
daily_counts[date_str].get(model_id, 0) + 1
|
||||
)
|
||||
daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1
|
||||
|
||||
# Fill in missing days
|
||||
if start_date and end_date:
|
||||
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||
while current <= end_dt:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
date_str = current.strftime('%Y-%m-%d')
|
||||
if date_str not in daily_counts:
|
||||
daily_counts[date_str] = {}
|
||||
current += timedelta(days=1)
|
||||
|
||||
return daily_counts
|
||||
|
||||
def get_hourly_message_counts_by_model(
|
||||
async def get_hourly_message_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
db: Optional[Session] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Get message counts grouped by hour and model."""
|
||||
with get_db_context(db) as db:
|
||||
async with get_async_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
results = query.all()
|
||||
result = await db.execute(stmt)
|
||||
results = result.all()
|
||||
|
||||
# Group by hour -> model -> count
|
||||
hourly_counts: dict[str, dict[str, int]] = {}
|
||||
for timestamp, model_id in results:
|
||||
hour_str = datetime.fromtimestamp(
|
||||
_normalize_timestamp(timestamp)
|
||||
).strftime("%Y-%m-%d %H:00")
|
||||
hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00')
|
||||
if hour_str not in hourly_counts:
|
||||
hourly_counts[hour_str] = {}
|
||||
hourly_counts[hour_str][model_id] = (
|
||||
hourly_counts[hour_str].get(model_id, 0) + 1
|
||||
)
|
||||
hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1
|
||||
|
||||
# Fill in missing hours
|
||||
if start_date and end_date:
|
||||
current = datetime.fromtimestamp(
|
||||
_normalize_timestamp(start_date)
|
||||
).replace(minute=0, second=0, microsecond=0)
|
||||
current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||
while current <= end_dt:
|
||||
hour_str = current.strftime("%Y-%m-%d %H:00")
|
||||
hour_str = current.strftime('%Y-%m-%d %H:00')
|
||||
if hour_str not in hourly_counts:
|
||||
hourly_counts[hour_str] = {}
|
||||
current += timedelta(hours=1)
|
||||
|
||||
+919
-876
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user