From 55b7ea04a7998df61da97aa9f12e87941404f092 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:12:19 +0900 Subject: [PATCH 01/53] chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /api (#34776) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 60 ++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 51424fc502..5015f76224 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1128,41 +1128,41 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] From 3a4756449a7850a145ec45b94a9a0c79061cb0ed Mon Sep 17 00:00:00 2001 From: aliworksx08 <57456290+aliworksx08@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:12:57 -0500 Subject: [PATCH 02/53] refactor: migrate session.query to select API in schedule cleanup task (#34775) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../clean_workflow_runlogs_precise.py | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/api/schedule/clean_workflow_runlogs_precise.py b/api/schedule/clean_workflow_runlogs_precise.py index ebb8d52924..c5762fcdad 100644 --- a/api/schedule/clean_workflow_runlogs_precise.py +++ b/api/schedule/clean_workflow_runlogs_precise.py @@ -4,6 +4,7 @@ import time from collections.abc import Sequence import click +from sqlalchemy import delete, select from sqlalchemy.orm import Session, sessionmaker import app @@ -113,11 +114,9 @@ def _delete_batch( try: with session.begin_nested(): workflow_run_ids = [run.id for run in workflow_runs] - message_data = ( - session.query(Message.id, Message.conversation_id) - .where(Message.workflow_run_id.in_(workflow_run_ids)) - .all() - ) + message_data = session.execute( + select(Message.id, Message.conversation_id).where(Message.workflow_run_id.in_(workflow_run_ids)) + ).all() message_id_list = [msg.id for msg in message_data] conversation_id_list = list({msg.conversation_id for msg in message_data if msg.conversation_id}) if message_id_list: @@ -132,23 +131,19 @@ def _delete_batch( SavedMessage, ] for model in message_related_models: - session.query(model).where(model.message_id.in_(message_id_list)).delete(synchronize_session=False) # type: ignore + session.execute(delete(model).where(model.message_id.in_(message_id_list))) # type: ignore # error: "DeclarativeAttributeIntercept" has no attribute "message_id". But this type is only in lib # and these 6 types all have the message_id field. - session.query(Message).where(Message.workflow_run_id.in_(workflow_run_ids)).delete( - synchronize_session=False - ) + session.execute(delete(Message).where(Message.workflow_run_id.in_(workflow_run_ids))) if conversation_id_list: - session.query(ConversationVariable).where( - ConversationVariable.conversation_id.in_(conversation_id_list) - ).delete(synchronize_session=False) - - session.query(Conversation).where(Conversation.id.in_(conversation_id_list)).delete( - synchronize_session=False + session.execute( + delete(ConversationVariable).where(ConversationVariable.conversation_id.in_(conversation_id_list)) ) + session.execute(delete(Conversation).where(Conversation.id.in_(conversation_id_list))) + def _delete_node_executions(active_session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: run_ids = [run.id for run in runs] repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( From 4c70bfa8b8c5eb3d6714d2a3b98adea1dbe900bb Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:13:38 -0500 Subject: [PATCH 03/53] =?UTF-8?q?refactor(api):=20use=20sessionmaker=20in?= =?UTF-8?q?=20trigger=20provider=20service=20&=20dataset=E2=80=A6=20(#3477?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/core/rag/retrieval/dataset_retrieval.py | 5 ++--- .../trigger/trigger_provider_service.py | 22 ++++++------------- .../rag/retrieval/test_dataset_retrieval.py | 6 +++-- .../services/test_trigger_provider_service.py | 16 ++++++++------ 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 4e9b53b83e..0f3351fd68 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -15,7 +15,7 @@ from graphon.model_runtime.entities.message_entities import PromptMessage, Promp from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel from sqlalchemy import and_, func, literal, or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from core.app.app_config.entities import ( DatasetEntity, @@ -884,7 +884,7 @@ class DatasetRetrieval: self._send_trace_task(message_id, documents, timer) return - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # Collect all document_ids and batch fetch DatasetDocuments document_ids = { doc.metadata["document_id"] @@ -975,7 +975,6 @@ class DatasetRetrieval: {DocumentSegment.hit_count: DocumentSegment.hit_count + 1}, synchronize_session=False, ) - session.commit() self._send_trace_task(message_id, documents, timer) diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 008d8bdb8a..ae74f7a8cd 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from typing import Any from sqlalchemy import desc, func -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -146,7 +146,7 @@ class TriggerProviderService: """ try: provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Use distributed lock to prevent race conditions lock_key = f"trigger_provider_create_lock:{tenant_id}_{provider_id}" with redis_client.lock(lock_key, timeout=20): @@ -205,7 +205,6 @@ class TriggerProviderService: subscription.id = subscription_id or str(uuid.uuid4()) session.add(subscription) - session.commit() return { "result": "success", @@ -241,7 +240,7 @@ class TriggerProviderService: :param expires_at: Optional new expiration timestamp :return: Success response with updated subscription info """ - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Use distributed lock to prevent race conditions on the same subscription lock_key = f"trigger_subscription_update_lock:{tenant_id}_{subscription_id}" with redis_client.lock(lock_key, timeout=20): @@ -302,8 +301,6 @@ class TriggerProviderService: if expires_at is not None: subscription.expires_at = expires_at - session.commit() - # Clear subscription cache delete_cache_for_subscription( tenant_id=tenant_id, @@ -404,7 +401,7 @@ class TriggerProviderService: :param subscription_id: Subscription instance ID :return: New token info """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: subscription = session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() if not subscription: @@ -448,7 +445,6 @@ class TriggerProviderService: # Update credentials subscription.credentials = dict(encrypter.encrypt(dict(refreshed_credentials.credentials))) subscription.credential_expires_at = refreshed_credentials.expires_at - session.commit() # Clear cache cache.delete() @@ -478,7 +474,7 @@ class TriggerProviderService: """ now_ts: int = int(now if now is not None else _time.time()) - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: subscription: TriggerSubscription | None = ( session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() ) @@ -531,7 +527,6 @@ class TriggerProviderService: # Persist refreshed properties and expires_at subscription.properties = dict(properties_encrypter.encrypt(dict(refreshed.properties))) subscription.expires_at = int(refreshed.expires_at) - session.commit() properties_cache.delete() logger.info( @@ -639,7 +634,7 @@ class TriggerProviderService: tenant_id=tenant_id, provider_id=provider_id ) - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # Find existing custom client params custom_client = ( session.query(TriggerOAuthTenantClient) @@ -683,8 +678,6 @@ class TriggerProviderService: if enabled is not None: custom_client.enabled = enabled - session.commit() - return {"result": "success"} @classmethod @@ -733,13 +726,12 @@ class TriggerProviderService: :param provider_id: Provider identifier :return: Success response """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: session.query(TriggerOAuthTenantClient).filter_by( tenant_id=tenant_id, provider=provider_id.provider_name, plugin_id=provider_id.plugin_id, ).delete() - session.commit() return {"result": "success"} diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index 40d138df90..b98fec3854 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -4909,15 +4909,17 @@ class TestInternalHooksCoverage: session_ctx.__enter__.return_value = session session_ctx.__exit__.return_value = False + sessionmaker_ctx = MagicMock() + sessionmaker_ctx.begin.return_value = session_ctx + with ( patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=Mock())), - patch("core.rag.retrieval.dataset_retrieval.Session", return_value=session_ctx), + patch("core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=sessionmaker_ctx), patch.object(retrieval, "_send_trace_task") as mock_trace, ): retrieval._on_retrieval_end(flask_app=app, documents=docs, message_id="m1", timer={"cost": 1}) query.update.assert_called_once() - session.commit.assert_called_once() mock_trace.assert_called_once() def test_retriever_variants(self, retrieval: DatasetRetrieval) -> None: diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index 81a3b181fd..350ff718c1 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -63,6 +63,12 @@ def mock_session(mocker: MockerFixture) -> MagicMock: mock_session_cm.__enter__.return_value = mock_session_instance mock_session_cm.__exit__.return_value = False mocker.patch("services.trigger.trigger_provider_service.Session", return_value=mock_session_cm) + mock_begin_cm = MagicMock() + mock_begin_cm.__enter__.return_value = mock_session_instance + mock_begin_cm.__exit__.return_value = False + mock_sessionmaker_instance = MagicMock() + mock_sessionmaker_instance.begin.return_value = mock_begin_cm + mocker.patch("services.trigger.trigger_provider_service.sessionmaker", return_value=mock_sessionmaker_instance) return mock_session_instance @@ -212,7 +218,6 @@ def test_add_trigger_subscription_should_create_subscription_successfully_for_ap # Assert assert result["result"] == "success" mock_session.add.assert_called_once() - mock_session.commit.assert_called_once() def test_add_trigger_subscription_should_store_empty_credentials_for_unauthorized_type( @@ -406,7 +411,7 @@ def test_update_trigger_subscription_should_update_fields_and_clear_cache( assert subscription.credentials == {"api_key": "new-key"} assert subscription.credential_expires_at == 100 assert subscription.expires_at == 200 - mock_session.commit.assert_called_once() + mock_delete_cache.assert_called_once() @@ -593,7 +598,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials( assert result == {"result": "success", "expires_at": 12345} assert subscription.credentials == {"access_token": "new"} assert subscription.credential_expires_at == 12345 - mock_session.commit.assert_called_once() + cache.delete.assert_called_once() @@ -664,7 +669,7 @@ def test_refresh_subscription_should_refresh_and_persist_properties( assert result == {"result": "success", "expires_at": 999} assert subscription.properties == {"p": "new-enc"} assert subscription.expires_at == 999 - mock_session.commit.assert_called_once() + prop_cache.delete.assert_called_once() @@ -838,7 +843,6 @@ def test_save_custom_oauth_client_params_should_create_record_and_clear_params_w assert fake_model.encrypted_oauth_params == "{}" assert fake_model.enabled is True mock_session.add.assert_called_once_with(fake_model) - mock_session.commit.assert_called_once() def test_save_custom_oauth_client_params_should_merge_hidden_values_and_delete_cache( @@ -870,7 +874,6 @@ def test_save_custom_oauth_client_params_should_merge_hidden_values_and_delete_c assert result == {"result": "success"} assert json.loads(custom_client.encrypted_oauth_params) == {"client_id": "new-id"} cache.delete.assert_called_once() - mock_session.commit.assert_called_once() def test_get_custom_oauth_client_params_should_return_empty_when_record_missing( @@ -921,7 +924,6 @@ def test_delete_custom_oauth_client_params_should_delete_record_and_commit( # Assert assert result == {"result": "success"} - mock_session.commit.assert_called_once() @pytest.mark.parametrize("exists", [True, False]) From 1a4eb47e1d0ebb3a6535163dcb36f95c52fe1b93 Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:14:44 +0200 Subject: [PATCH 04/53] refactor(api): tighten types in trivial lint and config fixes (#34773) Co-authored-by: tmimmanuel Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../datasource/vdb/analyticdb/analyticdb_vector.py | 5 +++-- .../vdb/analyticdb/analyticdb_vector_openapi.py | 2 +- .../vdb/analyticdb/analyticdb_vector_sql.py | 5 +++-- api/core/rag/datasource/vdb/chroma/chroma_vector.py | 11 ++++++----- api/core/rag/datasource/vdb/qdrant/qdrant_vector.py | 7 +++---- api/core/rag/datasource/vdb/relyt/relyt_vector.py | 2 +- .../unstructured/unstructured_doc_extractor.py | 7 +++++-- .../vdb/analyticdb/test_analyticdb_vector.py | 2 +- .../vdb/analyticdb/test_analyticdb_vector_openapi.py | 6 +++--- .../vdb/analyticdb/test_analyticdb_vector_sql.py | 4 ++-- 10 files changed, 28 insertions(+), 23 deletions(-) diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py index ddb549ba9d..79cc5f0344 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py +++ b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py @@ -37,11 +37,12 @@ class AnalyticdbVector(BaseVector): def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): dimension = len(embeddings[0]) - self.analyticdb_vector._create_collection_if_not_exists(dimension) + self.analyticdb_vector.create_collection_if_not_exists(dimension) self.analyticdb_vector.add_texts(texts, embeddings) - def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): + def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs) -> list[str]: self.analyticdb_vector.add_texts(documents, embeddings) + return [] def text_exists(self, id: str) -> bool: return self.analyticdb_vector.text_exists(id) diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py index fb6eaa370a..726ee8c050 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py +++ b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py @@ -123,7 +123,7 @@ class AnalyticdbVectorOpenAPI: else: raise ValueError(f"failed to create namespace {self.config.namespace}: {e}") - def _create_collection_if_not_exists(self, embedding_dimension: int): + def create_collection_if_not_exists(self, embedding_dimension: int): from alibabacloud_gpdb20160503 import models as gpdb_20160503_models from Tea.exceptions import TeaException diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py index 12126f32d6..41c33a3ab1 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py +++ b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py @@ -1,5 +1,6 @@ import json import uuid +from collections.abc import Iterator from contextlib import contextmanager from typing import Any @@ -74,7 +75,7 @@ class AnalyticdbVectorBySql: ) @contextmanager - def _get_cursor(self): + def _get_cursor(self) -> Iterator[Any]: assert self.pool is not None, "Connection pool is not initialized" conn = self.pool.getconn() cur = conn.cursor() @@ -130,7 +131,7 @@ class AnalyticdbVectorBySql: ) cur.execute(f"CREATE SCHEMA IF NOT EXISTS {self.config.namespace}") - def _create_collection_if_not_exists(self, embedding_dimension: int): + def create_collection_if_not_exists(self, embedding_dimension: int): cache_key = f"vector_indexing_{self._collection_name}" lock_name = f"{cache_key}_lock" with redis_client.lock(lock_name, timeout=20): diff --git a/api/core/rag/datasource/vdb/chroma/chroma_vector.py b/api/core/rag/datasource/vdb/chroma/chroma_vector.py index 73787c2f00..5b0cfbea15 100644 --- a/api/core/rag/datasource/vdb/chroma/chroma_vector.py +++ b/api/core/rag/datasource/vdb/chroma/chroma_vector.py @@ -2,7 +2,7 @@ import json from typing import Any, TypedDict import chromadb -from chromadb import QueryResult, Settings +from chromadb import QueryResult, Settings # pyright: ignore[reportPrivateImportUsage] from pydantic import BaseModel from configs import dify_config @@ -106,14 +106,15 @@ class ChromaVector(BaseVector): def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]: collection = self._client.get_or_create_collection(self._collection_name) document_ids_filter = kwargs.get("document_ids_filter") + results: QueryResult if document_ids_filter: - results: QueryResult = collection.query( + results = collection.query( query_embeddings=query_vector, n_results=kwargs.get("top_k", 4), where={"document_id": {"$in": document_ids_filter}}, # type: ignore ) else: - results: QueryResult = collection.query(query_embeddings=query_vector, n_results=kwargs.get("top_k", 4)) # type: ignore + results = collection.query(query_embeddings=query_vector, n_results=kwargs.get("top_k", 4)) # type: ignore score_threshold = float(kwargs.get("score_threshold") or 0.0) # Check if results contain data @@ -165,8 +166,8 @@ class ChromaVectorFactory(AbstractVectorFactory): config=ChromaConfig( host=dify_config.CHROMA_HOST or "", port=dify_config.CHROMA_PORT, - tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, - database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, + tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, # pyright: ignore[reportPrivateImportUsage] + database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, # pyright: ignore[reportPrivateImportUsage] auth_provider=dify_config.CHROMA_AUTH_PROVIDER, auth_credentials=dify_config.CHROMA_AUTH_CREDENTIALS, ), diff --git a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py b/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py index f4fcb975c3..b5ff87fc5d 100644 --- a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py +++ b/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py @@ -3,7 +3,7 @@ import os import uuid from collections.abc import Generator, Iterable, Sequence from itertools import islice -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import qdrant_client from flask import current_app @@ -32,7 +32,6 @@ from extensions.ext_redis import redis_client from models.dataset import Dataset, DatasetCollectionBinding if TYPE_CHECKING: - from qdrant_client import grpc # noqa from qdrant_client.conversions import common_types from qdrant_client.http import models as rest @@ -180,7 +179,7 @@ class QdrantVector(BaseVector): for batch_ids, points in self._generate_rest_batches( texts, embeddings, filtered_metadatas, uuids, 64, self._group_id ): - self._client.upsert(collection_name=self._collection_name, points=points) + self._client.upsert(collection_name=self._collection_name, points=cast("common_types.Points", points)) added_ids.extend(batch_ids) return added_ids @@ -472,7 +471,7 @@ class QdrantVector(BaseVector): def _reload_if_needed(self): if isinstance(self._client, QdrantLocal): - self._client._load() + self._client._load() # pyright: ignore[reportPrivateUsage] @classmethod def _document_from_scored_point( diff --git a/api/core/rag/datasource/vdb/relyt/relyt_vector.py b/api/core/rag/datasource/vdb/relyt/relyt_vector.py index e486375ec2..3ecc9867fa 100644 --- a/api/core/rag/datasource/vdb/relyt/relyt_vector.py +++ b/api/core/rag/datasource/vdb/relyt/relyt_vector.py @@ -26,7 +26,7 @@ from extensions.ext_redis import redis_client logger = logging.getLogger(__name__) -Base = declarative_base() # type: Any +Base: Any = declarative_base() class RelytConfig(BaseModel): diff --git a/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py b/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py index 7dd8beaa46..f9fbfbc409 100644 --- a/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py +++ b/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py @@ -19,12 +19,15 @@ class UnstructuredWordExtractor(BaseExtractor): def extract(self) -> list[Document]: from unstructured.__version__ import __version__ as __unstructured_version__ - from unstructured.file_utils.filetype import FileType, detect_filetype + from unstructured.file_utils.filetype import ( # pyright: ignore[reportPrivateImportUsage] + FileType, + detect_filetype, + ) unstructured_version = tuple(int(x) for x in __unstructured_version__.split(".")) # check the file extension try: - import magic # noqa: F401 + import magic # noqa: F401 # pyright: ignore[reportUnusedImport] is_doc = detect_filetype(self._file_path) == FileType.DOC except ImportError: diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py b/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py index 545565cdf4..d4fa4b3e8e 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py @@ -71,7 +71,7 @@ def test_vector_methods_delegate_to_underlying_implementation(): assert vector.search_by_full_text("hello", top_k=2) == runner.search_by_full_text.return_value vector.delete() - runner._create_collection_if_not_exists.assert_called_once_with(2) + runner.create_collection_if_not_exists.assert_called_once_with(2) runner.add_texts.assert_any_call(texts, [[0.1, 0.2]]) runner.delete_by_ids.assert_called_once_with(["d1"]) runner.delete_by_metadata_field.assert_called_once_with("document_id", "doc-1") diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py b/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py index 45777774d0..4f8653a926 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py @@ -249,7 +249,7 @@ def test_create_collection_if_not_exists_creates_when_missing(monkeypatch): vector._client = MagicMock() vector._client.describe_collection.side_effect = stubs.TeaException(statusCode=404) - vector._create_collection_if_not_exists(embedding_dimension=1024) + vector.create_collection_if_not_exists(embedding_dimension=1024) vector._client.create_collection.assert_called_once() openapi_module.redis_client.set.assert_called_once() @@ -268,7 +268,7 @@ def test_create_collection_if_not_exists_skips_when_cached(monkeypatch): vector.config = _config() vector._client = MagicMock() - vector._create_collection_if_not_exists(embedding_dimension=1024) + vector.create_collection_if_not_exists(embedding_dimension=1024) vector._client.describe_collection.assert_not_called() vector._client.create_collection.assert_not_called() @@ -290,7 +290,7 @@ def test_create_collection_if_not_exists_raises_on_non_404_errors(monkeypatch): vector._client.describe_collection.side_effect = stubs.TeaException(statusCode=500) with pytest.raises(ValueError, match="failed to create collection collection_1"): - vector._create_collection_if_not_exists(embedding_dimension=512) + vector.create_collection_if_not_exists(embedding_dimension=512) def test_openapi_add_delete_and_search_methods(monkeypatch): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py b/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py index 8f1206696b..f798ef8bd1 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py @@ -374,7 +374,7 @@ def test_create_collection_if_not_exists_creates_table_indexes_and_cache(monkeyp vector._get_cursor = _cursor_context - vector._create_collection_if_not_exists(embedding_dimension=3) + vector.create_collection_if_not_exists(embedding_dimension=3) assert any("CREATE TABLE IF NOT EXISTS dify.collection" in call.args[0] for call in cursor.execute.call_args_list) assert any("CREATE INDEX collection_embedding_idx" in call.args[0] for call in cursor.execute.call_args_list) @@ -404,7 +404,7 @@ def test_create_collection_if_not_exists_raises_for_non_existing_error(monkeypat vector._get_cursor = _cursor_context with pytest.raises(RuntimeError, match="permission denied"): - vector._create_collection_if_not_exists(embedding_dimension=3) + vector.create_collection_if_not_exists(embedding_dimension=3) def test_delete_methods_raise_when_error_is_not_missing_table(): From 289f091bf911371e7840f5860e91dda4fb6286b3 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:15:19 -0500 Subject: [PATCH 05/53] refactor: migrate session.query to select API in delete conversation task (#34772) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/tasks/delete_conversation_task.py | 29 ++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/api/tasks/delete_conversation_task.py b/api/tasks/delete_conversation_task.py index 9664b8ac73..0b392f6096 100644 --- a/api/tasks/delete_conversation_task.py +++ b/api/tasks/delete_conversation_task.py @@ -3,6 +3,7 @@ import time import click from celery import shared_task +from sqlalchemy import delete from core.db.session_factory import session_factory from models import ConversationVariable @@ -29,29 +30,21 @@ def delete_conversation_related_data(conversation_id: str): with session_factory.create_session() as session: try: - session.query(MessageAnnotation).where(MessageAnnotation.conversation_id == conversation_id).delete( - synchronize_session=False + session.execute(delete(MessageAnnotation).where(MessageAnnotation.conversation_id == conversation_id)) + + session.execute(delete(MessageFeedback).where(MessageFeedback.conversation_id == conversation_id)) + + session.execute( + delete(ToolConversationVariables).where(ToolConversationVariables.conversation_id == conversation_id) ) - session.query(MessageFeedback).where(MessageFeedback.conversation_id == conversation_id).delete( - synchronize_session=False - ) + session.execute(delete(ToolFile).where(ToolFile.conversation_id == conversation_id)) - session.query(ToolConversationVariables).where( - ToolConversationVariables.conversation_id == conversation_id - ).delete(synchronize_session=False) + session.execute(delete(ConversationVariable).where(ConversationVariable.conversation_id == conversation_id)) - session.query(ToolFile).where(ToolFile.conversation_id == conversation_id).delete(synchronize_session=False) + session.execute(delete(Message).where(Message.conversation_id == conversation_id)) - session.query(ConversationVariable).where(ConversationVariable.conversation_id == conversation_id).delete( - synchronize_session=False - ) - - session.query(Message).where(Message.conversation_id == conversation_id).delete(synchronize_session=False) - - session.query(PinnedConversation).where(PinnedConversation.conversation_id == conversation_id).delete( - synchronize_session=False - ) + session.execute(delete(PinnedConversation).where(PinnedConversation.conversation_id == conversation_id)) session.commit() From 02a9f0abca96abf118b18ace0b13b9908f7660da Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:15:58 -0500 Subject: [PATCH 06/53] refactor(api): use sessionmaker in core app generators & pipelines (#34771) --- api/core/app/apps/advanced_chat/app_runner.py | 5 ++-- .../advanced_chat/generate_task_pipeline.py | 11 +++----- .../apps/workflow/generate_task_pipeline.py | 11 +++----- api/core/app/llm/quota.py | 5 ++-- .../easy_ui_based_generate_task_pipeline.py | 8 +++--- .../test_app_runner_conversation_variables.py | 18 ++++++++----- .../test_app_runner_input_moderation.py | 10 +++++++ .../test_generate_task_pipeline_core.py | 27 ++++++++++--------- 8 files changed, 49 insertions(+), 46 deletions(-) diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index a884a1c7f9..7b4cb98bd4 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -10,7 +10,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variable_loader import VariableLoader from graphon.variables.variables import Variable from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfig from core.app.apps.base_app_queue_manager import AppQueueManager @@ -363,7 +363,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): :return: List of conversation variables ready for use """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: existing_variables = self._load_existing_conversation_variables(session) if not existing_variables: @@ -376,7 +376,6 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): # Convert to Variable objects for use in the workflow conversation_variables = [var.to_variable() for var in existing_variables] - session.commit() return cast(list[Variable], conversation_variables) def _load_existing_conversation_variables(self, session: Session) -> list[ConversationVariable]: diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 5203de225c..0ce9ddce9e 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -16,7 +16,7 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from graphon.nodes import BuiltinNodeTypes from graphon.runtime import GraphRuntimeState from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom @@ -328,13 +328,8 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): @contextmanager def _database_session(self): """Context manager for database sessions.""" - with Session(db.engine, expire_on_commit=False) as session: - try: - yield session - session.commit() - except Exception: - session.rollback() - raise + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: + yield session def _ensure_workflow_initialized(self): """Fluent validation for workflow state.""" diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 49af169e88..f1b8b08eaa 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -7,7 +7,7 @@ from typing import Union from graphon.entities import WorkflowStartReason from graphon.enums import WorkflowExecutionStatus from graphon.runtime import GraphRuntimeState -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME from core.app.apps.base_app_queue_manager import AppQueueManager @@ -252,13 +252,8 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): @contextmanager def _database_session(self): """Context manager for database sessions.""" - with Session(db.engine, expire_on_commit=False) as session: - try: - yield session - session.commit() - except Exception: - session.rollback() - raise + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: + yield session def _ensure_workflow_initialized(self): """Fluent validation for workflow state.""" diff --git a/api/core/app/llm/quota.py b/api/core/app/llm/quota.py index 182f1b767d..a454217768 100644 --- a/api/core/app/llm/quota.py +++ b/api/core/app/llm/quota.py @@ -1,6 +1,6 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from sqlalchemy import update -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from configs import dify_config from core.entities.model_entities import ModelStatus @@ -73,7 +73,7 @@ def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LL pool_type="paid", ) else: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: stmt = ( update(Provider) .where( @@ -90,4 +90,3 @@ def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LL ) ) session.execute(stmt) - session.commit() diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index 9df78a7830..6bb177fe02 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -12,7 +12,7 @@ from graphon.model_runtime.entities.message_entities import ( ) from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom @@ -266,9 +266,8 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): event = message.event if isinstance(event, QueueErrorEvent): - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: err = self.handle_error(event=event, session=session, message_id=self._message_id) - session.commit() yield self.error_to_stream_response(err) break elif isinstance(event, QueueStopEvent | QueueMessageEndEvent): @@ -288,10 +287,9 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): answer=output_moderation_answer ) - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # Save message self._save_message(session=session, trace_manager=trace_manager) - session.commit() message_end_resp = self._message_end_to_stream_response() yield message_end_resp elif isinstance(event, QueueRetrieverResourcesEvent): diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py index 061719d15a..1fb0dc6cf1 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py @@ -134,6 +134,7 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( + patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, @@ -150,7 +151,9 @@ class TestAdvancedChatAppRunnerConversationVariables: patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, ): # Setup mocks - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_session_class.return_value.__enter__.return_value = MagicMock() mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() @@ -177,7 +180,6 @@ class TestAdvancedChatAppRunnerConversationVariables: # Note: Since we're mocking ConversationVariable.from_variable, # we can't directly check the id, but we can verify add_all was called assert mock_session.add_all.called, "Session add_all should have been called" - assert mock_session.commit.called, "Session commit should have been called" def test_no_variables_creates_all(self): """Test that all conversation variables are created when none exist in DB.""" @@ -278,6 +280,7 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( + patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, @@ -295,7 +298,9 @@ class TestAdvancedChatAppRunnerConversationVariables: patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, ): # Setup mocks - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_session_class.return_value.__enter__.return_value = MagicMock() mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() @@ -326,7 +331,6 @@ class TestAdvancedChatAppRunnerConversationVariables: # Verify that all variables were created assert len(added_items) == 2, "Should have added both variables" assert mock_session.add_all.called, "Session add_all should have been called" - assert mock_session.commit.called, "Session commit should have been called" def test_all_variables_exist_no_changes(self): """Test that no changes are made when all variables already exist in DB.""" @@ -429,6 +433,7 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( + patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, @@ -445,7 +450,9 @@ class TestAdvancedChatAppRunnerConversationVariables: patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, ): # Setup mocks - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_session_class.return_value.__enter__.return_value = MagicMock() mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() @@ -465,4 +472,3 @@ class TestAdvancedChatAppRunnerConversationVariables: # Verify that no variables were added assert not mock_session.add_all.called, "Session add_all should not have been called" - assert mock_session.commit.called, "Session commit should still be called" diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py index 079df0b4e6..5d8faee897 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py @@ -93,6 +93,16 @@ def _patch_common_run_deps(runner: AdvancedChatAppRunner): scalar=lambda *a, **k: MagicMock(), ), ), + sessionmaker=MagicMock( + return_value=MagicMock( + begin=MagicMock( + return_value=MagicMock( + __enter__=lambda s: MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: []))), + __exit__=lambda *a, **k: False, + ), + ), + ), + ), select=MagicMock(), db=MagicMock(engine=MagicMock()), RedisChannel=MagicMock(), diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index dabd2594b4..d91bb85aee 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import contextmanager from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus @@ -610,33 +611,33 @@ class TestWorkflowGenerateTaskPipeline: def test_database_session_rolls_back_on_error(self, monkeypatch): pipeline = _make_pipeline() - calls = {"commit": 0, "rollback": 0} - - class _Session: - def __init__(self, *args, **kwargs): - _ = args, kwargs + calls = {"enter": 0, "exit_exc": None} + class _BeginContext: def __enter__(self): - return self + calls["enter"] += 1 + return MagicMock() def __exit__(self, exc_type, exc, tb): + calls["exit_exc"] = exc_type return False - def commit(self): - calls["commit"] += 1 + class _Sessionmaker: + def __init__(self, *args, **kwargs): + pass - def rollback(self): - calls["rollback"] += 1 + def begin(self): + return _BeginContext() - monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.Session", _Session) + monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker) monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object())) with pytest.raises(RuntimeError, match="db error"): with pipeline._database_session(): raise RuntimeError("db error") - assert calls["commit"] == 0 - assert calls["rollback"] == 1 + assert calls["enter"] == 1 + assert calls["exit_exc"] is RuntimeError def test_node_retry_and_started_handlers_cover_none_and_value(self): pipeline = _make_pipeline() From 1d971d32406046c4d58e1b4de90ac3cea7967a8b Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:18:26 -0500 Subject: [PATCH 07/53] refactor(api): use sessionmaker in plugin & trigger services (#34764) --- .../plugin/plugin_auto_upgrade_service.py | 10 ++++------ api/services/plugin/plugin_permission_service.py | 7 +++---- api/services/trigger/app_trigger_service.py | 5 ++--- api/services/trigger/trigger_service.py | 7 ++----- api/services/trigger/webhook_service.py | 6 ++---- .../plugin/test_plugin_auto_upgrade_service.py | 13 +++++-------- .../plugin/test_plugin_permission_service.py | 12 +++++------- .../unit_tests/services/test_webhook_service.py | 16 +++++++++++++++- 8 files changed, 38 insertions(+), 38 deletions(-) diff --git a/api/services/plugin/plugin_auto_upgrade_service.py b/api/services/plugin/plugin_auto_upgrade_service.py index 174bed488d..adbed87c3c 100644 --- a/api/services/plugin/plugin_auto_upgrade_service.py +++ b/api/services/plugin/plugin_auto_upgrade_service.py @@ -1,4 +1,4 @@ -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from models.account import TenantPluginAutoUpgradeStrategy @@ -7,7 +7,7 @@ from models.account import TenantPluginAutoUpgradeStrategy class PluginAutoUpgradeService: @staticmethod def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: return ( session.query(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) @@ -23,7 +23,7 @@ class PluginAutoUpgradeService: exclude_plugins: list[str], include_plugins: list[str], ) -> bool: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: exist_strategy = ( session.query(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) @@ -46,12 +46,11 @@ class PluginAutoUpgradeService: exist_strategy.exclude_plugins = exclude_plugins exist_strategy.include_plugins = include_plugins - session.commit() return True @staticmethod def exclude_plugin(tenant_id: str, plugin_id: str) -> bool: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: exist_strategy = ( session.query(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) @@ -83,5 +82,4 @@ class PluginAutoUpgradeService: exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE exist_strategy.exclude_plugins = [plugin_id] - session.commit() return True diff --git a/api/services/plugin/plugin_permission_service.py b/api/services/plugin/plugin_permission_service.py index 60fa269640..55276d6f99 100644 --- a/api/services/plugin/plugin_permission_service.py +++ b/api/services/plugin/plugin_permission_service.py @@ -1,4 +1,4 @@ -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from models.account import TenantPluginPermission @@ -7,7 +7,7 @@ from models.account import TenantPluginPermission class PluginPermissionService: @staticmethod def get_permission(tenant_id: str) -> TenantPluginPermission | None: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: return session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).first() @staticmethod @@ -16,7 +16,7 @@ class PluginPermissionService: install_permission: TenantPluginPermission.InstallPermission, debug_permission: TenantPluginPermission.DebugPermission, ): - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: permission = ( session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).first() ) @@ -30,5 +30,4 @@ class PluginPermissionService: permission.install_permission = install_permission permission.debug_permission = debug_permission - session.commit() return True diff --git a/api/services/trigger/app_trigger_service.py b/api/services/trigger/app_trigger_service.py index 6d5a719f63..723d29e947 100644 --- a/api/services/trigger/app_trigger_service.py +++ b/api/services/trigger/app_trigger_service.py @@ -8,7 +8,7 @@ This service centralizes all AppTrigger-related business logic. import logging from sqlalchemy import update -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from models.enums import AppTriggerStatus @@ -34,13 +34,12 @@ class AppTriggerService: """ try: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: session.execute( update(AppTrigger) .where(AppTrigger.tenant_id == tenant_id, AppTrigger.status == AppTriggerStatus.ENABLED) .values(status=AppTriggerStatus.RATE_LIMITED) ) - session.commit() logger.info("Marked all enabled triggers as rate limited for tenant %s", tenant_id) except Exception: logger.exception("Failed to mark all enabled triggers as rate limited for tenant %s", tenant_id) diff --git a/api/services/trigger/trigger_service.py b/api/services/trigger/trigger_service.py index d72c041609..5a5d13b96d 100644 --- a/api/services/trigger/trigger_service.py +++ b/api/services/trigger/trigger_service.py @@ -8,7 +8,7 @@ from flask import Request, Response from graphon.entities.graph_config import NodeConfigDict from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.entities.request import TriggerDispatchResponse, TriggerInvokeEventResponse @@ -215,7 +215,7 @@ class TriggerService: not_found_in_cache.append(node_info) continue - with Session(db.engine) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: try: # lock the concurrent plugin trigger creation redis_client.lock(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:apps:{app.id}:lock", timeout=10) @@ -260,7 +260,6 @@ class TriggerService: cache.model_dump_json(), ex=60 * 60, ) - session.commit() # Update existing records if subscription_id changed for node_info in nodes_in_graph: @@ -290,14 +289,12 @@ class TriggerService: cache.model_dump_json(), ex=60 * 60, ) - session.commit() # delete the nodes not found in the graph for node_id in nodes_id_in_db: if node_id not in nodes_id_in_graph: session.delete(nodes_id_in_db[node_id]) redis_client.delete(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{app.id}:{node_id}") - session.commit() except Exception: logger.exception("Failed to sync plugin trigger relationships for app %s", app.id) raise diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index f72c69a33e..8e629deb32 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -12,7 +12,7 @@ from graphon.file import FileTransferMethod from graphon.variables.types import ArrayValidation, SegmentType from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from werkzeug.datastructures import FileStorage from werkzeug.exceptions import RequestEntityTooLarge @@ -912,7 +912,7 @@ class WebhookService: logger.warning("Failed to acquire lock for webhook sync, app %s", app.id) raise RuntimeError("Failed to acquire lock for webhook trigger synchronization") - with Session(db.engine) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # fetch the non-cached nodes from DB all_records = session.scalars( select(WorkflowWebhookTrigger).where( @@ -941,14 +941,12 @@ class WebhookService: redis_client.set( f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:{node_id}", cache.model_dump_json(), ex=60 * 60 ) - session.commit() # delete the nodes not found in the graph for node_id in nodes_id_in_db: if node_id not in nodes_id_in_graph: session.delete(nodes_id_in_db[node_id]) redis_client.delete(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:{node_id}") - session.commit() except Exception: logger.exception("Failed to sync webhook relationships for app %s", app.id) raise diff --git a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py index edb50d09a6..45156958b6 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py @@ -6,12 +6,12 @@ MODULE = "services.plugin.plugin_auto_upgrade_service" def _patched_session(): - """Patch Session(db.engine) to return a mock session as context manager.""" + """Patch sessionmaker(bind=db.engine).begin() to return a mock session as context manager.""" session = MagicMock() - session_cls = MagicMock() - session_cls.return_value.__enter__ = MagicMock(return_value=session) - session_cls.return_value.__exit__ = MagicMock(return_value=False) - patcher = patch(f"{MODULE}.Session", session_cls) + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__ = MagicMock(return_value=session) + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + patcher = patch(f"{MODULE}.sessionmaker", mock_sessionmaker) db_patcher = patch(f"{MODULE}.db") return patcher, db_patcher, session @@ -61,7 +61,6 @@ class TestChangeStrategy: assert result is True session.add.assert_called_once() - session.commit.assert_called_once() def test_updates_existing_strategy(self): p1, p2, session = _patched_session() @@ -86,7 +85,6 @@ class TestChangeStrategy: assert existing.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL assert existing.exclude_plugins == ["p1"] assert existing.include_plugins == ["p2"] - session.commit.assert_called_once() class TestExcludePlugin: @@ -127,7 +125,6 @@ class TestExcludePlugin: assert result is True assert existing.exclude_plugins == ["p-existing", "p-new"] - session.commit.assert_called_once() def test_removes_from_include_list_in_partial_mode(self): p1, p2, session = _patched_session() diff --git a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py index 69091110db..40f4c6a8d2 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py @@ -6,12 +6,12 @@ MODULE = "services.plugin.plugin_permission_service" def _patched_session(): - """Patch Session(db.engine) to return a mock session as context manager.""" + """Patch sessionmaker(bind=db.engine).begin() to return a mock session as context manager.""" session = MagicMock() - session_cls = MagicMock() - session_cls.return_value.__enter__ = MagicMock(return_value=session) - session_cls.return_value.__exit__ = MagicMock(return_value=False) - patcher = patch(f"{MODULE}.Session", session_cls) + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__ = MagicMock(return_value=session) + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + patcher = patch(f"{MODULE}.sessionmaker", mock_sessionmaker) db_patcher = patch(f"{MODULE}.db") return patcher, db_patcher, session @@ -55,7 +55,6 @@ class TestChangePermission: ) session.add.assert_called_once() - session.commit.assert_called_once() def test_updates_existing_permission(self): p1, p2, session = _patched_session() @@ -71,5 +70,4 @@ class TestChangePermission: assert existing.install_permission == TenantPluginPermission.InstallPermission.ADMINS assert existing.debug_permission == TenantPluginPermission.DebugPermission.ADMINS - session.commit.assert_called_once() session.add.assert_not_called() diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 78049182ad..1b5252fc64 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -617,6 +617,20 @@ class _SessionContext: return False +class _SessionmakerContext: + def __init__(self, session: Any) -> None: + self._session = session + + def begin(self) -> "_SessionmakerContext": + return self + + def __enter__(self) -> Any: + return self._session + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + @pytest.fixture def flask_app() -> Flask: return Flask(__name__) @@ -625,6 +639,7 @@ def flask_app() -> Flask: def _patch_session(monkeypatch: pytest.MonkeyPatch, session: Any) -> None: monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=MagicMock(), session=MagicMock())) monkeypatch.setattr(service_module, "Session", lambda *args, **kwargs: _SessionContext(session)) + monkeypatch.setattr(service_module, "sessionmaker", lambda *args, **kwargs: _SessionmakerContext(session)) def _workflow_trigger(**kwargs: Any) -> WorkflowWebhookTrigger: @@ -1241,7 +1256,6 @@ def test_sync_webhook_relationships_should_create_missing_records_and_delete_sta # Assert assert len(fake_session.added) == 1 assert len(fake_session.deleted) == 1 - assert fake_session.commit_count == 2 redis_set_mock.assert_called_once() redis_delete_mock.assert_called_once() lock.release.assert_called_once() From 540289e6c69a21f05962a580f77c2bdb3dff3797 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:19:03 -0500 Subject: [PATCH 08/53] refactor: migrate session.query to select API in delete segment and regenerate summary tasks (#34763) --- api/tasks/delete_segment_from_index_task.py | 16 ++++++------ api/tasks/regenerate_summary_index_task.py | 28 ++++++++++----------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/api/tasks/delete_segment_from_index_task.py b/api/tasks/delete_segment_from_index_task.py index a6a2dcebc8..306a23aeda 100644 --- a/api/tasks/delete_segment_from_index_task.py +++ b/api/tasks/delete_segment_from_index_task.py @@ -3,7 +3,7 @@ import time import click from celery import shared_task -from sqlalchemy import delete +from sqlalchemy import delete, select from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -29,12 +29,12 @@ def delete_segment_from_index_task( start_at = time.perf_counter() with session_factory.create_session() as session: try: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logging.warning("Dataset %s not found, skipping index cleanup", dataset_id) return - dataset_document = session.query(Document).where(Document.id == document_id).first() + dataset_document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if not dataset_document: return @@ -60,11 +60,9 @@ def delete_segment_from_index_task( ) if dataset.is_multimodal: # delete segment attachment binding - segment_attachment_bindings = ( - session.query(SegmentAttachmentBinding) - .where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) - .all() - ) + segment_attachment_bindings = session.scalars( + select(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) + ).all() if segment_attachment_bindings: attachment_ids = [binding.attachment_id for binding in segment_attachment_bindings] index_processor.clean(dataset=dataset, node_ids=attachment_ids, with_keywords=False) @@ -77,7 +75,7 @@ def delete_segment_from_index_task( session.execute(segment_attachment_bind_delete_stmt) # delete upload file - session.query(UploadFile).where(UploadFile.id.in_(attachment_ids)).delete(synchronize_session=False) + session.execute(delete(UploadFile).where(UploadFile.id.in_(attachment_ids))) session.commit() end_at = time.perf_counter() diff --git a/api/tasks/regenerate_summary_index_task.py b/api/tasks/regenerate_summary_index_task.py index 6f490ab7ea..e794195c92 100644 --- a/api/tasks/regenerate_summary_index_task.py +++ b/api/tasks/regenerate_summary_index_task.py @@ -47,7 +47,7 @@ def regenerate_summary_index_task( try: with session_factory.create_session() as session: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logger.error(click.style(f"Dataset not found: {dataset_id}", fg="red")) return @@ -84,8 +84,8 @@ def regenerate_summary_index_task( # For embedding_model change: directly query all segments with existing summaries # Don't require document indexing_status == "completed" # Include summaries with status "completed" or "error" (if they have content) - segments_with_summaries = ( - session.query(DocumentSegment, DocumentSegmentSummary) + segments_with_summaries = session.execute( + select(DocumentSegment, DocumentSegmentSummary) .join( DocumentSegmentSummary, DocumentSegment.id == DocumentSegmentSummary.chunk_id, @@ -110,8 +110,7 @@ def regenerate_summary_index_task( DatasetDocument.doc_form != IndexStructureType.QA_INDEX, # Skip qa_model documents ) .order_by(DocumentSegment.document_id.asc(), DocumentSegment.position.asc()) - .all() - ) + ).all() if not segments_with_summaries: logger.info( @@ -215,8 +214,8 @@ def regenerate_summary_index_task( try: # Get all segments with existing summaries - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .join( DocumentSegmentSummary, DocumentSegment.id == DocumentSegmentSummary.chunk_id, @@ -229,8 +228,7 @@ def regenerate_summary_index_task( DocumentSegmentSummary.dataset_id == dataset_id, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if not segments: continue @@ -245,13 +243,13 @@ def regenerate_summary_index_task( summary_record = None try: # Get existing summary record - summary_record = ( - session.query(DocumentSegmentSummary) - .filter_by( - chunk_id=segment.id, - dataset_id=dataset_id, + summary_record = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset_id, ) - .first() + .limit(1) ) if not summary_record: From d6d9b04c416b3575fb9468717922fe3580e4c911 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:19:36 -0500 Subject: [PATCH 09/53] refactor: migrate session.query to select API in add document and clean document tasks (#34761) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/tasks/add_document_to_index_task.py | 29 ++++++++++++------------- api/tasks/clean_document_task.py | 16 ++++++++------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/api/tasks/add_document_to_index_task.py b/api/tasks/add_document_to_index_task.py index ae55c9ee03..c9d4673c0a 100644 --- a/api/tasks/add_document_to_index_task.py +++ b/api/tasks/add_document_to_index_task.py @@ -3,6 +3,7 @@ import time import click from celery import shared_task +from sqlalchemy import delete, select, update from core.db.session_factory import session_factory from core.rag.index_processor.constant.doc_type import DocType @@ -30,7 +31,9 @@ def add_document_to_index_task(dataset_document_id: str): start_at = time.perf_counter() with session_factory.create_session() as session: - dataset_document = session.query(DatasetDocument).where(DatasetDocument.id == dataset_document_id).first() + dataset_document = session.scalar( + select(DatasetDocument).where(DatasetDocument.id == dataset_document_id).limit(1) + ) if not dataset_document: logger.info(click.style(f"Document not found: {dataset_document_id}", fg="red")) return @@ -45,15 +48,14 @@ def add_document_to_index_task(dataset_document_id: str): if not dataset: raise Exception(f"Document {dataset_document.id} dataset {dataset_document.dataset_id} doesn't exist.") - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.status == SegmentStatus.COMPLETED, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() documents = [] multimodal_documents = [] @@ -104,18 +106,15 @@ def add_document_to_index_task(dataset_document_id: str): index_processor.load(dataset, documents, multimodal_documents=multimodal_documents) # delete auto disable log - session.query(DatasetAutoDisableLog).where( - DatasetAutoDisableLog.document_id == dataset_document.id - ).delete() + session.execute( + delete(DatasetAutoDisableLog).where(DatasetAutoDisableLog.document_id == dataset_document.id) + ) # update segment to enable - session.query(DocumentSegment).where(DocumentSegment.document_id == dataset_document.id).update( - { - DocumentSegment.enabled: True, - DocumentSegment.disabled_at: None, - DocumentSegment.disabled_by: None, - DocumentSegment.updated_at: naive_utc_now(), - } + session.execute( + update(DocumentSegment) + .where(DocumentSegment.document_id == dataset_document.id) + .values(enabled=True, disabled_at=None, disabled_by=None, updated_at=naive_utc_now()) ) session.commit() diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index a017e9114b..a657cd553a 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -32,7 +32,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i with session_factory.create_session() as session: try: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Document has no dataset") @@ -63,7 +63,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i if index_node_ids: index_processor = IndexProcessorFactory(doc_form).init_index_processor() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if dataset: index_processor.clean( dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True @@ -94,7 +94,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i with session_factory.create_session() as session, session.begin(): if file_id: - file = session.query(UploadFile).where(UploadFile.id == file_id).first() + file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if file: try: storage.delete(file.key) @@ -124,10 +124,12 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i with session_factory.create_session() as session, session.begin(): # delete dataset metadata binding - session.query(DatasetMetadataBinding).where( - DatasetMetadataBinding.dataset_id == dataset_id, - DatasetMetadataBinding.document_id == document_id, - ).delete() + session.execute( + delete(DatasetMetadataBinding).where( + DatasetMetadataBinding.dataset_id == dataset_id, + DatasetMetadataBinding.document_id == document_id, + ) + ) end_at = time.perf_counter() logger.info( From 5821511114e0cfeea0c373375fb840a3f550bba1 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:20:25 -0500 Subject: [PATCH 10/53] refactor: migrate session.query to select API in batch clean and disable segments tasks (#34760) --- api/tasks/batch_clean_document_task.py | 18 +++++++---- api/tasks/disable_segments_from_index_task.py | 32 ++++++++----------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/api/tasks/batch_clean_document_task.py b/api/tasks/batch_clean_document_task.py index 66aafc30b9..56c371fcc1 100644 --- a/api/tasks/batch_clean_document_task.py +++ b/api/tasks/batch_clean_document_task.py @@ -1,9 +1,11 @@ import logging import time +from typing import cast import click from celery import shared_task from sqlalchemy import delete, select +from sqlalchemy.engine import CursorResult from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -92,14 +94,16 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form # ============ Step 3: Delete metadata binding (separate short transaction) ============ try: with session_factory.create_session() as session: - deleted_count = int( - session.query(DatasetMetadataBinding) - .where( - DatasetMetadataBinding.dataset_id == dataset_id, - DatasetMetadataBinding.document_id.in_(document_ids), - ) - .delete(synchronize_session=False) + result = cast( + CursorResult, + session.execute( + delete(DatasetMetadataBinding).where( + DatasetMetadataBinding.dataset_id == dataset_id, + DatasetMetadataBinding.document_id.in_(document_ids), + ) + ), ) + deleted_count = result.rowcount session.commit() logger.debug("Deleted %d metadata bindings for dataset_id: %s", deleted_count, dataset_id) except Exception: diff --git a/api/tasks/disable_segments_from_index_task.py b/api/tasks/disable_segments_from_index_task.py index 3cc267e821..86e96ea3f0 100644 --- a/api/tasks/disable_segments_from_index_task.py +++ b/api/tasks/disable_segments_from_index_task.py @@ -3,7 +3,7 @@ import time import click from celery import shared_task -from sqlalchemy import select +from sqlalchemy import select, update from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -27,12 +27,12 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen start_at = time.perf_counter() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logger.info(click.style(f"Dataset {dataset_id} not found, pass.", fg="cyan")) return - dataset_document = session.query(DatasetDocument).where(DatasetDocument.id == document_id).first() + dataset_document = session.scalar(select(DatasetDocument).where(DatasetDocument.id == document_id).limit(1)) if not dataset_document: logger.info(click.style(f"Document {document_id} not found, pass.", fg="cyan")) @@ -58,11 +58,9 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen index_node_ids = [segment.index_node_id for segment in segments] if dataset.is_multimodal: segment_ids = [segment.id for segment in segments] - segment_attachment_bindings = ( - session.query(SegmentAttachmentBinding) - .where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) - .all() - ) + segment_attachment_bindings = session.scalars( + select(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) + ).all() if segment_attachment_bindings: attachment_ids = [binding.attachment_id for binding in segment_attachment_bindings] index_node_ids.extend(attachment_ids) @@ -87,16 +85,14 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen logger.info(click.style(f"Segments removed from index latency: {end_at - start_at}", fg="green")) except Exception: # update segment error msg - session.query(DocumentSegment).where( - DocumentSegment.id.in_(segment_ids), - DocumentSegment.dataset_id == dataset_id, - DocumentSegment.document_id == document_id, - ).update( - { - "disabled_at": None, - "disabled_by": None, - "enabled": True, - } + session.execute( + update(DocumentSegment) + .where( + DocumentSegment.id.in_(segment_ids), + DocumentSegment.dataset_id == dataset_id, + DocumentSegment.document_id == document_id, + ) + .values(disabled_at=None, disabled_by=None, enabled=True) ) session.commit() finally: From 5aa4e23f54ea1bb143f40a051112d5696e3173af Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:21:28 -0500 Subject: [PATCH 11/53] refactor(api): use sessionmaker in end user, retention & cleanup services (#34765) --- .../clear_free_plan_tenant_expired_logs.py | 9 ++-- api/services/end_user_service.py | 11 ++--- .../conversation/messages_clean_service.py | 10 ++--- ...est_clear_free_plan_tenant_expired_logs.py | 45 +++++++++---------- 4 files changed, 33 insertions(+), 42 deletions(-) diff --git a/api/services/clear_free_plan_tenant_expired_logs.py b/api/services/clear_free_plan_tenant_expired_logs.py index b4a7fa051f..b0f7efaccd 100644 --- a/api/services/clear_free_plan_tenant_expired_logs.py +++ b/api/services/clear_free_plan_tenant_expired_logs.py @@ -120,7 +120,7 @@ class ClearFreePlanTenantExpiredLogs: apps = db.session.scalars(select(App).where(App.tenant_id == tenant_id)).all() app_ids = [app.id for app in apps] while True: - with Session(db.engine).no_autoflush as session: + with sessionmaker(bind=db.engine, autoflush=False).begin() as session: messages = ( session.query(Message) .where( @@ -152,7 +152,6 @@ class ClearFreePlanTenantExpiredLogs: ).delete(synchronize_session=False) cls._clear_message_related_tables(session, tenant_id, message_ids) - session.commit() click.echo( click.style( @@ -161,7 +160,7 @@ class ClearFreePlanTenantExpiredLogs: ) while True: - with Session(db.engine).no_autoflush as session: + with sessionmaker(bind=db.engine, autoflush=False).begin() as session: conversations = ( session.query(Conversation) .where( @@ -190,7 +189,6 @@ class ClearFreePlanTenantExpiredLogs: session.query(Conversation).where( Conversation.id.in_(conversation_ids), ).delete(synchronize_session=False) - session.commit() click.echo( click.style( @@ -294,7 +292,7 @@ class ClearFreePlanTenantExpiredLogs: break while True: - with Session(db.engine).no_autoflush as session: + with sessionmaker(bind=db.engine, autoflush=False).begin() as session: workflow_app_logs = ( session.query(WorkflowAppLog) .where( @@ -326,7 +324,6 @@ class ClearFreePlanTenantExpiredLogs: session.query(WorkflowAppLog).where(WorkflowAppLog.id.in_(workflow_app_log_ids)).delete( synchronize_session=False ) - session.commit() click.echo( click.style( diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index 29ada270ec..749d8dbc30 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping from sqlalchemy import case, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db @@ -24,7 +24,7 @@ class EndUserService: when an end-user ID is known. """ - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: return session.scalar( select(EndUser) .where( @@ -54,7 +54,7 @@ class EndUserService: if not user_id: user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Query with ORDER BY to prioritize exact type matches while maintaining backward compatibility # This single query approach is more efficient than separate queries end_user = session.scalar( @@ -82,7 +82,6 @@ class EndUserService: user_id, ) end_user.type = type - session.commit() else: # Create new end user if none exists end_user = EndUser( @@ -94,7 +93,6 @@ class EndUserService: external_user_id=user_id, ) session.add(end_user) - session.commit() return end_user @@ -135,7 +133,7 @@ class EndUserService: if not unique_app_ids: return result - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Fetch existing end users for all target apps in a single query existing_end_users: list[EndUser] = list( session.scalars( @@ -174,7 +172,6 @@ class EndUserService: ) session.add_all(new_end_users) - session.commit() for eu in new_end_users: result[eu.app_id] = eu diff --git a/api/services/retention/conversation/messages_clean_service.py b/api/services/retention/conversation/messages_clean_service.py index 0e0dbab2d1..1e9f0bf149 100644 --- a/api/services/retention/conversation/messages_clean_service.py +++ b/api/services/retention/conversation/messages_clean_service.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, TypedDict, cast import sqlalchemy as sa from sqlalchemy import delete, select, tuple_ from sqlalchemy.engine import CursorResult -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from extensions.ext_database import db @@ -369,7 +369,7 @@ class MessagesCleanService: batch_deleted_messages = 0 # Step 1: Fetch a batch of messages using cursor - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: fetch_messages_start = time.monotonic() msg_stmt = ( select(Message.id, Message.app_id, Message.created_at) @@ -477,7 +477,7 @@ class MessagesCleanService: # Step 4: Batch delete messages and their relations if not self._dry_run: - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: delete_relations_start = time.monotonic() # Delete related records first self._batch_delete_message_relations(session, message_ids_to_delete) @@ -489,9 +489,7 @@ class MessagesCleanService: delete_result = cast(CursorResult, session.execute(delete_stmt)) messages_deleted = delete_result.rowcount delete_messages_ms = int((time.monotonic() - delete_messages_start) * 1000) - commit_start = time.monotonic() - session.commit() - commit_ms = int((time.monotonic() - commit_start) * 1000) + commit_ms = 0 stats["total_deleted"] += messages_deleted batch_deleted_messages = messages_deleted diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index f393a4b10b..3e989c55a3 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -275,48 +275,46 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) - msg_session_1.query.side_effect = lambda model: ( make_query_with_batches([[msg1], []]) if model == service_module.Message else MagicMock() ) - msg_session_1.commit.return_value = None - msg_session_2 = MagicMock() msg_session_2.query.side_effect = lambda model: ( make_query_with_batches([[]]) if model == service_module.Message else MagicMock() ) - msg_session_2.commit.return_value = None conv_session_1 = MagicMock() conv_session_1.query.side_effect = lambda model: ( make_query_with_batches([[conv1], []]) if model == service_module.Conversation else MagicMock() ) - conv_session_1.commit.return_value = None conv_session_2 = MagicMock() conv_session_2.query.side_effect = lambda model: ( make_query_with_batches([[]]) if model == service_module.Conversation else MagicMock() ) - conv_session_2.commit.return_value = None wal_session_1 = MagicMock() wal_session_1.query.side_effect = lambda model: ( make_query_with_batches([[log1], []]) if model == service_module.WorkflowAppLog else MagicMock() ) - wal_session_1.commit.return_value = None wal_session_2 = MagicMock() wal_session_2.query.side_effect = lambda model: ( make_query_with_batches([[]]) if model == service_module.WorkflowAppLog else MagicMock() ) - wal_session_2.commit.return_value = None session_wrappers = [ - _session_wrapper_for_no_autoflush(msg_session_1), - _session_wrapper_for_no_autoflush(msg_session_2), - _session_wrapper_for_no_autoflush(conv_session_1), - _session_wrapper_for_no_autoflush(conv_session_2), - _session_wrapper_for_no_autoflush(wal_session_1), - _session_wrapper_for_no_autoflush(wal_session_2), + _sessionmaker_wrapper_for_begin(msg_session_1), + _sessionmaker_wrapper_for_begin(msg_session_2), + _sessionmaker_wrapper_for_begin(conv_session_1), + _sessionmaker_wrapper_for_begin(conv_session_2), + _sessionmaker_wrapper_for_begin(wal_session_1), + _sessionmaker_wrapper_for_begin(wal_session_2), ] - monkeypatch.setattr(service_module, "Session", lambda _engine: session_wrappers.pop(0)) + def fake_sessionmaker(*args, **kwargs): + if kwargs.get("autoflush") is False: + return session_wrappers.pop(0) + return object() + + monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) def fake_select(*_args, **_kwargs): stmt = MagicMock() @@ -333,8 +331,6 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) - run_repo = MagicMock() run_repo.get_expired_runs_batch.side_effect = [[SimpleNamespace(id="wr-1", to_dict=lambda: {"id": "wr-1"})], []] run_repo.delete_runs_by_ids.return_value = 1 - - monkeypatch.setattr(service_module, "sessionmaker", lambda **_kwargs: object()) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", @@ -574,13 +570,18 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte q_empty.limit.return_value = q_empty q_empty.all.return_value = [] empty_session.query.return_value = q_empty - empty_session.commit.return_value = None session_wrappers = [ - _session_wrapper_for_no_autoflush(empty_session), - _session_wrapper_for_no_autoflush(empty_session), - _session_wrapper_for_no_autoflush(empty_session), + _sessionmaker_wrapper_for_begin(empty_session), + _sessionmaker_wrapper_for_begin(empty_session), + _sessionmaker_wrapper_for_begin(empty_session), ] - monkeypatch.setattr(service_module, "Session", lambda _engine: session_wrappers.pop(0)) + + def fake_sessionmaker(*args, **kwargs): + if kwargs.get("autoflush") is False: + return session_wrappers.pop(0) + return object() + + monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) def fake_select(*_args, **_kwargs): stmt = MagicMock() @@ -606,8 +607,6 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte [], ] run_repo.delete_runs_by_ids.return_value = 2 - - monkeypatch.setattr(service_module, "sessionmaker", lambda **_kwargs: object()) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", From 8f46c9113c3b3c22a89cae7bc8375ca6d3e4e016 Mon Sep 17 00:00:00 2001 From: Jake Armstrong <65635253+jakearmstrong59@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:23:04 +0200 Subject: [PATCH 12/53] refactor(api): deduplicate ImportMode and ImportStatus enums from rag_pipeline_dsl_service (#34759) --- .../rag_pipeline/rag_pipeline_dsl_service.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/api/services/rag_pipeline/rag_pipeline_dsl_service.py b/api/services/rag_pipeline/rag_pipeline_dsl_service.py index 04156713f4..e42c020925 100644 --- a/api/services/rag_pipeline/rag_pipeline_dsl_service.py +++ b/api/services/rag_pipeline/rag_pipeline_dsl_service.py @@ -5,7 +5,6 @@ import logging import uuid from collections.abc import Mapping from datetime import UTC, datetime -from enum import StrEnum from typing import cast from urllib.parse import urlparse from uuid import uuid4 @@ -38,6 +37,7 @@ from models import Account from models.dataset import Dataset, DatasetCollectionBinding, Pipeline from models.enums import CollectionBindingType, DatasetRuntimeMode from models.workflow import Workflow, WorkflowType +from services.app_dsl_service import ImportMode, ImportStatus from services.entities.knowledge_entities.rag_pipeline_entities import ( IconInfo, KnowledgeConfiguration, @@ -54,18 +54,6 @@ DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB CURRENT_DSL_VERSION = "0.1.0" -class ImportMode(StrEnum): - YAML_CONTENT = "yaml-content" - YAML_URL = "yaml-url" - - -class ImportStatus(StrEnum): - COMPLETED = "completed" - COMPLETED_WITH_WARNINGS = "completed-with-warnings" - PENDING = "pending" - FAILED = "failed" - - class RagPipelineImportInfo(BaseModel): id: str status: ImportStatus From e6715a2dbec60f1bec26e506531219b1324ae902 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:27:10 -0700 Subject: [PATCH 13/53] refactor: convert FileTransferMethod if/elif to match/case (#30001) (#34769) --- api/models/model.py | 87 +++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/api/models/model.py b/api/models/model.py index 43ddf344d2..12865c4d22 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -1632,52 +1632,53 @@ class Message(Base): files: list[File] = [] for message_file in message_files: - if message_file.transfer_method == FileTransferMethod.LOCAL_FILE: - if message_file.upload_file_id is None: - raise ValueError(f"MessageFile {message_file.id} is a local file but has no upload_file_id") - file = file_factory.build_from_mapping( - mapping={ + match message_file.transfer_method: + case FileTransferMethod.LOCAL_FILE: + if message_file.upload_file_id is None: + raise ValueError(f"MessageFile {message_file.id} is a local file but has no upload_file_id") + file = file_factory.build_from_mapping( + mapping={ + "id": message_file.id, + "type": message_file.type, + "transfer_method": message_file.transfer_method, + "upload_file_id": message_file.upload_file_id, + }, + tenant_id=current_app.tenant_id, + access_controller=_get_file_access_controller(), + ) + case FileTransferMethod.REMOTE_URL: + if message_file.url is None: + raise ValueError(f"MessageFile {message_file.id} is a remote url but has no url") + file = file_factory.build_from_mapping( + mapping={ + "id": message_file.id, + "type": message_file.type, + "transfer_method": message_file.transfer_method, + "upload_file_id": message_file.upload_file_id, + "url": message_file.url, + }, + tenant_id=current_app.tenant_id, + access_controller=_get_file_access_controller(), + ) + case FileTransferMethod.TOOL_FILE: + if message_file.upload_file_id is None: + assert message_file.url is not None + message_file.upload_file_id = message_file.url.split("/")[-1].split(".")[0] + mapping = { "id": message_file.id, "type": message_file.type, "transfer_method": message_file.transfer_method, - "upload_file_id": message_file.upload_file_id, - }, - tenant_id=current_app.tenant_id, - access_controller=_get_file_access_controller(), - ) - elif message_file.transfer_method == FileTransferMethod.REMOTE_URL: - if message_file.url is None: - raise ValueError(f"MessageFile {message_file.id} is a remote url but has no url") - file = file_factory.build_from_mapping( - mapping={ - "id": message_file.id, - "type": message_file.type, - "transfer_method": message_file.transfer_method, - "upload_file_id": message_file.upload_file_id, - "url": message_file.url, - }, - tenant_id=current_app.tenant_id, - access_controller=_get_file_access_controller(), - ) - elif message_file.transfer_method == FileTransferMethod.TOOL_FILE: - if message_file.upload_file_id is None: - assert message_file.url is not None - message_file.upload_file_id = message_file.url.split("/")[-1].split(".")[0] - mapping = { - "id": message_file.id, - "type": message_file.type, - "transfer_method": message_file.transfer_method, - "tool_file_id": message_file.upload_file_id, - } - file = file_factory.build_from_mapping( - mapping=mapping, - tenant_id=current_app.tenant_id, - access_controller=_get_file_access_controller(), - ) - else: - raise ValueError( - f"MessageFile {message_file.id} has an invalid transfer_method {message_file.transfer_method}" - ) + "tool_file_id": message_file.upload_file_id, + } + file = file_factory.build_from_mapping( + mapping=mapping, + tenant_id=current_app.tenant_id, + access_controller=_get_file_access_controller(), + ) + case FileTransferMethod.DATASOURCE_FILE: + raise ValueError( + f"MessageFile {message_file.id} has an invalid transfer_method {message_file.transfer_method}" + ) files.append(file) result = cast( From bd257777a0ef237d3db14132c2b9ce42055c2d43 Mon Sep 17 00:00:00 2001 From: Jake Armstrong <65635253+jakearmstrong59@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:49:04 +0200 Subject: [PATCH 14/53] refactor(api): deduplicate workflow controller schemas into controller_schemas.py (#34755) --- api/controllers/common/controller_schemas.py | 16 ++++++++++++++++ api/controllers/console/app/workflow.py | 17 +---------------- .../rag_pipeline/rag_pipeline_workflow.py | 17 +---------------- 3 files changed, 18 insertions(+), 32 deletions(-) diff --git a/api/controllers/common/controller_schemas.py b/api/controllers/common/controller_schemas.py index e13bf025fc..39e3b5857d 100644 --- a/api/controllers/common/controller_schemas.py +++ b/api/controllers/common/controller_schemas.py @@ -48,11 +48,27 @@ class SavedMessageCreatePayload(BaseModel): # --- Workflow schemas --- +class DefaultBlockConfigQuery(BaseModel): + q: str | None = None + + +class WorkflowListQuery(BaseModel): + page: int = Field(default=1, ge=1, le=99999) + limit: int = Field(default=10, ge=1, le=100) + user_id: str | None = None + named_only: bool = False + + class WorkflowRunPayload(BaseModel): inputs: dict[str, Any] files: list[dict[str, Any]] | None = None +class WorkflowUpdatePayload(BaseModel): + marked_name: str | None = Field(default=None, max_length=20) + marked_comment: str | None = Field(default=None, max_length=100) + + # --- Audio schemas --- diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index dcd24d2200..da8d25c2eb 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services +from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.console import console_ns from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.workflow_run import workflow_run_node_execution_model @@ -142,10 +143,6 @@ class PublishWorkflowPayload(BaseModel): marked_comment: str | None = Field(default=None, max_length=100) -class DefaultBlockConfigQuery(BaseModel): - q: str | None = None - - class ConvertToWorkflowPayload(BaseModel): name: str | None = None icon_type: str | None = None @@ -153,18 +150,6 @@ class ConvertToWorkflowPayload(BaseModel): icon_background: str | None = None -class WorkflowListQuery(BaseModel): - page: int = Field(default=1, ge=1, le=99999) - limit: int = Field(default=10, ge=1, le=100) - user_id: str | None = None - named_only: bool = False - - -class WorkflowUpdatePayload(BaseModel): - marked_name: str | None = Field(default=None, max_length=20) - marked_comment: str | None = Field(default=None, max_length=100) - - class DraftWorkflowTriggerRunPayload(BaseModel): node_id: str diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 70dfe47d7f..6c02646c22 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services +from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( @@ -94,22 +95,6 @@ class PublishedWorkflowRunPayload(DraftWorkflowRunPayload): original_document_id: str | None = None -class DefaultBlockConfigQuery(BaseModel): - q: str | None = None - - -class WorkflowListQuery(BaseModel): - page: int = Field(default=1, ge=1, le=99999) - limit: int = Field(default=10, ge=1, le=100) - user_id: str | None = None - named_only: bool = False - - -class WorkflowUpdatePayload(BaseModel): - marked_name: str | None = Field(default=None, max_length=20) - marked_comment: str | None = Field(default=None, max_length=100) - - class NodeIdQuery(BaseModel): node_id: str From a8fa552b3a1f794238cc74381a53bb7e5c932cf3 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:04:47 -0700 Subject: [PATCH 15/53] refactor: convert importStatus if/elif to match/case (#30001) (#34780) --- api/controllers/console/app/app_import.py | 12 +++++++----- .../datasets/rag_pipeline/rag_pipeline_import.py | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index 16e1fa3245..06192936f1 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -92,11 +92,13 @@ class AppImportApi(Resource): EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") # Return appropriate status code based on result status = result.status - if status == ImportStatus.FAILED: - return result.model_dump(mode="json"), 400 - elif status == ImportStatus.PENDING: - return result.model_dump(mode="json"), 202 - return result.model_dump(mode="json"), 200 + match status: + case ImportStatus.FAILED: + return result.model_dump(mode="json"), 400 + case ImportStatus.PENDING: + return result.model_dump(mode="json"), 202 + case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS: + return result.model_dump(mode="json"), 200 @console_ns.route("/apps/imports//confirm") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py index 732a6dc446..76a8c136e4 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py @@ -83,11 +83,13 @@ class RagPipelineImportApi(Resource): # Return appropriate status code based on result status = result.status - if status == ImportStatus.FAILED: - return result.model_dump(mode="json"), 400 - elif status == ImportStatus.PENDING: - return result.model_dump(mode="json"), 202 - return result.model_dump(mode="json"), 200 + match status: + case ImportStatus.FAILED: + return result.model_dump(mode="json"), 400 + case ImportStatus.PENDING: + return result.model_dump(mode="json"), 202 + case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS: + return result.model_dump(mode="json"), 200 @console_ns.route("/rag/pipelines/imports//confirm") From ce68f2cdc66a0c2139220812358fb45bb96212f0 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:16:44 -0700 Subject: [PATCH 16/53] refactor: convert webapp auth type if/elif to match/case (#30001) (#34782) --- api/controllers/web/passport.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 6a2e0b65fb..66082893b8 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -138,12 +138,15 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded: if not app_model or app_model.status != "normal" or not app_model.enable_site: raise NotFound() - if auth_type == WebAppAuthType.PUBLIC: - return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded) - elif auth_type == WebAppAuthType.EXTERNAL and user_auth_type != "external": - raise WebAppAuthRequiredError("Please login as external user.") - elif auth_type == WebAppAuthType.INTERNAL and user_auth_type != "internal": - raise WebAppAuthRequiredError("Please login as internal user.") + match auth_type: + case WebAppAuthType.PUBLIC: + return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded) + case WebAppAuthType.EXTERNAL: + if user_auth_type != "external": + raise WebAppAuthRequiredError("Please login as external user.") + case WebAppAuthType.INTERNAL: + if user_auth_type != "internal": + raise WebAppAuthRequiredError("Please login as internal user.") end_user = None if end_user_id: From 47b9d48f703de72373ea496fe4132b8dd2c1b7cb Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:17:22 -0700 Subject: [PATCH 17/53] refactor: convert ToolProviderType if/elif to match/case (#30001) (#34768) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Asuka Minato --- api/core/tools/tool_manager.py | 393 ++++++++++++++++----------------- 1 file changed, 194 insertions(+), 199 deletions(-) diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index d45d45c520..2593e381cf 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -205,16 +205,160 @@ class ToolManager: :return: the tool """ - if provider_type == ToolProviderType.BUILT_IN: - # check if the builtin tool need credentials - provider_controller = cls.get_builtin_provider(provider_id, tenant_id) + match provider_type: + case ToolProviderType.BUILT_IN: + provider_controller = cls.get_builtin_provider(provider_id, tenant_id) - builtin_tool = provider_controller.get_tool(tool_name) - if not builtin_tool: - raise ToolProviderNotFoundError(f"builtin tool {tool_name} not found") + builtin_tool = provider_controller.get_tool(tool_name) + if not builtin_tool: + raise ToolProviderNotFoundError(f"builtin tool {tool_name} not found") + + if not provider_controller.need_credentials: + return builtin_tool.fork_tool_runtime( + runtime=ToolRuntime( + tenant_id=tenant_id, + user_id=user_id, + credentials={}, + invoke_from=invoke_from, + tool_invoke_from=tool_invoke_from, + ) + ) + builtin_provider = None + if isinstance(provider_controller, PluginToolProviderController): + provider_id_entity = ToolProviderID(provider_id) + if is_valid_uuid(credential_id): + try: + builtin_provider_stmt = select(BuiltinToolProvider).where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.id == credential_id, + ) + builtin_provider = db.session.scalar(builtin_provider_stmt) + except Exception as e: + builtin_provider = None + logger.info("Error getting builtin provider %s:%s", credential_id, e, exc_info=True) + if builtin_provider is None: + raise ToolProviderNotFoundError(f"provider has been deleted: {credential_id}") + + if builtin_provider is None: + with Session(db.engine) as session: + builtin_provider = session.scalar( + sa.select(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, + (BuiltinToolProvider.provider == str(provider_id_entity)) + | (BuiltinToolProvider.provider == provider_id_entity.provider_name), + ) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + ) + if builtin_provider is None: + raise ToolProviderNotFoundError(f"no default provider for {provider_id}") + else: + builtin_provider = db.session.scalar( + select(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_id) + ) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + .limit(1) + ) + + if builtin_provider is None: + raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") + + from core.helper.credential_utils import check_credential_policy_compliance + + check_credential_policy_compliance( + credential_id=builtin_provider.id, + provider=provider_id, + credential_type=PluginCredentialType.TOOL, + check_existence=False, + ) + + encrypter, cache = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + x.to_basic_provider_config() + for x in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) + ], + cache=ToolProviderCredentialsCache( + tenant_id=tenant_id, provider=provider_id, credential_id=builtin_provider.id + ), + ) + + decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(builtin_provider.credentials) + + if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()): + # TODO: circular import + from core.plugin.impl.oauth import OAuthHandler + from services.tools.builtin_tools_manage_service import BuiltinToolManageService + + tool_provider = ToolProviderID(provider_id) + provider_name = tool_provider.provider_name + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback" + system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id) + + oauth_handler = OAuthHandler() + refreshed_credentials = oauth_handler.refresh_credentials( + tenant_id=tenant_id, + user_id=builtin_provider.user_id, + plugin_id=tool_provider.plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=system_credentials or {}, + credentials=decrypted_credentials, + ) + # update the credentials + builtin_provider.encrypted_credentials = json.dumps( + encrypter.encrypt(refreshed_credentials.credentials) + ) + builtin_provider.expires_at = refreshed_credentials.expires_at + db.session.commit() + decrypted_credentials = refreshed_credentials.credentials + cache.delete() - if not provider_controller.need_credentials: return builtin_tool.fork_tool_runtime( + runtime=ToolRuntime( + tenant_id=tenant_id, + user_id=user_id, + credentials=dict(decrypted_credentials), + credential_type=builtin_provider.credential_type, + runtime_parameters={}, + invoke_from=invoke_from, + tool_invoke_from=tool_invoke_from, + ) + ) + + case ToolProviderType.API: + api_provider, credentials = cls.get_api_provider_controller(tenant_id, provider_id) + encrypter, _ = create_tool_provider_encrypter( + tenant_id=tenant_id, + controller=api_provider, + ) + return api_provider.get_tool(tool_name).fork_tool_runtime( + runtime=ToolRuntime( + tenant_id=tenant_id, + user_id=user_id, + credentials=dict(encrypter.decrypt(credentials)), + invoke_from=invoke_from, + tool_invoke_from=tool_invoke_from, + ) + ) + case ToolProviderType.WORKFLOW: + workflow_provider_stmt = select(WorkflowToolProvider).where( + WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id + ) + with Session(db.engine, expire_on_commit=False) as session, session.begin(): + workflow_provider = session.scalar(workflow_provider_stmt) + + if workflow_provider is None: + raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") + + controller = ToolTransformService.workflow_provider_to_controller(db_provider=workflow_provider) + controller_tools: list[WorkflowTool] = controller.get_tools(tenant_id=workflow_provider.tenant_id) + if controller_tools is None or len(controller_tools) == 0: + raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") + + return controller.get_tools(tenant_id=workflow_provider.tenant_id)[0].fork_tool_runtime( runtime=ToolRuntime( tenant_id=tenant_id, user_id=user_id, @@ -223,177 +367,28 @@ class ToolManager: tool_invoke_from=tool_invoke_from, ) ) - builtin_provider = None - if isinstance(provider_controller, PluginToolProviderController): - provider_id_entity = ToolProviderID(provider_id) - # get specific credentials - if is_valid_uuid(credential_id): - try: - builtin_provider_stmt = select(BuiltinToolProvider).where( - BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.id == credential_id, - ) - builtin_provider = db.session.scalar(builtin_provider_stmt) - except Exception as e: - builtin_provider = None - logger.info("Error getting builtin provider %s:%s", credential_id, e, exc_info=True) - # if the provider has been deleted, raise an error - if builtin_provider is None: - raise ToolProviderNotFoundError(f"provider has been deleted: {credential_id}") - - # fallback to the default provider - if builtin_provider is None: - # use the default provider - with Session(db.engine) as session: - builtin_provider = session.scalar( - sa.select(BuiltinToolProvider) - .where( - BuiltinToolProvider.tenant_id == tenant_id, - (BuiltinToolProvider.provider == str(provider_id_entity)) - | (BuiltinToolProvider.provider == provider_id_entity.provider_name), - ) - .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) - ) - if builtin_provider is None: - raise ToolProviderNotFoundError(f"no default provider for {provider_id}") - else: - builtin_provider = db.session.scalar( - select(BuiltinToolProvider) - .where(BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_id)) - .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) - .limit(1) - ) - - if builtin_provider is None: - raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") - - # check if the credential is allowed to be used - from core.helper.credential_utils import check_credential_policy_compliance - - check_credential_policy_compliance( - credential_id=builtin_provider.id, - provider=provider_id, - credential_type=PluginCredentialType.TOOL, - check_existence=False, - ) - - encrypter, cache = create_provider_encrypter( - tenant_id=tenant_id, - config=[ - x.to_basic_provider_config() - for x in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) - ], - cache=ToolProviderCredentialsCache( - tenant_id=tenant_id, provider=provider_id, credential_id=builtin_provider.id - ), - ) - - # decrypt the credentials - decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(builtin_provider.credentials) - - # check if the credentials is expired - if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()): - # TODO: circular import - from core.plugin.impl.oauth import OAuthHandler - from services.tools.builtin_tools_manage_service import BuiltinToolManageService - - # refresh the credentials - tool_provider = ToolProviderID(provider_id) - provider_name = tool_provider.provider_name - redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback" - system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id) - - oauth_handler = OAuthHandler() - # refresh the credentials - refreshed_credentials = oauth_handler.refresh_credentials( - tenant_id=tenant_id, - user_id=builtin_provider.user_id, - plugin_id=tool_provider.plugin_id, - provider=provider_name, - redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, - credentials=decrypted_credentials, - ) - # update the credentials - builtin_provider.encrypted_credentials = json.dumps( - encrypter.encrypt(refreshed_credentials.credentials) - ) - builtin_provider.expires_at = refreshed_credentials.expires_at - db.session.commit() - decrypted_credentials = refreshed_credentials.credentials - cache.delete() - - return builtin_tool.fork_tool_runtime( - runtime=ToolRuntime( - tenant_id=tenant_id, - user_id=user_id, - credentials=dict(decrypted_credentials), - credential_type=builtin_provider.credential_type, - runtime_parameters={}, - invoke_from=invoke_from, - tool_invoke_from=tool_invoke_from, - ) - ) - - elif provider_type == ToolProviderType.API: - api_provider, credentials = cls.get_api_provider_controller(tenant_id, provider_id) - encrypter, _ = create_tool_provider_encrypter( - tenant_id=tenant_id, - controller=api_provider, - ) - return api_provider.get_tool(tool_name).fork_tool_runtime( - runtime=ToolRuntime( - tenant_id=tenant_id, - user_id=user_id, - credentials=dict(encrypter.decrypt(credentials)), - invoke_from=invoke_from, - tool_invoke_from=tool_invoke_from, - ) - ) - elif provider_type == ToolProviderType.WORKFLOW: - workflow_provider_stmt = select(WorkflowToolProvider).where( - WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id - ) - with Session(db.engine, expire_on_commit=False) as session, session.begin(): - workflow_provider = session.scalar(workflow_provider_stmt) - - if workflow_provider is None: - raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") - - controller = ToolTransformService.workflow_provider_to_controller(db_provider=workflow_provider) - controller_tools: list[WorkflowTool] = controller.get_tools(tenant_id=workflow_provider.tenant_id) - if controller_tools is None or len(controller_tools) == 0: - raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") - - return controller.get_tools(tenant_id=workflow_provider.tenant_id)[0].fork_tool_runtime( - runtime=ToolRuntime( - tenant_id=tenant_id, - user_id=user_id, - credentials={}, - invoke_from=invoke_from, - tool_invoke_from=tool_invoke_from, - ) - ) - elif provider_type == ToolProviderType.APP: - raise NotImplementedError("app provider not implemented") - elif provider_type == ToolProviderType.PLUGIN: - plugin_tool = cls.get_plugin_provider(provider_id, tenant_id).get_tool(tool_name) - runtime = getattr(plugin_tool, "runtime", None) - if runtime is not None: - runtime.user_id = user_id - runtime.invoke_from = invoke_from - runtime.tool_invoke_from = tool_invoke_from - return plugin_tool - elif provider_type == ToolProviderType.MCP: - mcp_tool = cls.get_mcp_provider_controller(tenant_id, provider_id).get_tool(tool_name) - runtime = getattr(mcp_tool, "runtime", None) - if runtime is not None: - runtime.user_id = user_id - runtime.invoke_from = invoke_from - runtime.tool_invoke_from = tool_invoke_from - return mcp_tool - else: - raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") + case ToolProviderType.APP: + raise NotImplementedError("app provider not implemented") + case ToolProviderType.PLUGIN: + plugin_tool = cls.get_plugin_provider(provider_id, tenant_id).get_tool(tool_name) + runtime = getattr(plugin_tool, "runtime", None) + if runtime is not None: + runtime.user_id = user_id + runtime.invoke_from = invoke_from + runtime.tool_invoke_from = tool_invoke_from + return plugin_tool + case ToolProviderType.MCP: + mcp_tool = cls.get_mcp_provider_controller(tenant_id, provider_id).get_tool(tool_name) + runtime = getattr(mcp_tool, "runtime", None) + if runtime is not None: + runtime.user_id = user_id + runtime.invoke_from = invoke_from + runtime.tool_invoke_from = tool_invoke_from + return mcp_tool + case ToolProviderType.DATASET_RETRIEVAL: + raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") + case _: + raise ToolProviderNotFoundError(f"provider type {provider_type} not found") @classmethod def get_agent_tool_runtime( @@ -1027,31 +1022,31 @@ class ToolManager: :param provider_id: the id of the provider :return: """ - provider_type = provider_type - provider_id = provider_id - if provider_type == ToolProviderType.BUILT_IN: - provider = ToolManager.get_builtin_provider(provider_id, tenant_id) - if isinstance(provider, PluginToolProviderController): + match provider_type: + case ToolProviderType.BUILT_IN: + provider = ToolManager.get_builtin_provider(provider_id, tenant_id) + if isinstance(provider, PluginToolProviderController): + try: + return cls.generate_plugin_tool_icon_url(tenant_id, provider.entity.identity.icon) + except Exception: + return {"background": "#252525", "content": "\ud83d\ude01"} + return cls.generate_builtin_tool_icon_url(provider_id) + case ToolProviderType.API: + return cls.generate_api_tool_icon_url(tenant_id, provider_id) + case ToolProviderType.WORKFLOW: + return cls.generate_workflow_tool_icon_url(tenant_id, provider_id) + case ToolProviderType.PLUGIN: + provider = ToolManager.get_plugin_provider(provider_id, tenant_id) try: return cls.generate_plugin_tool_icon_url(tenant_id, provider.entity.identity.icon) except Exception: return {"background": "#252525", "content": "\ud83d\ude01"} - return cls.generate_builtin_tool_icon_url(provider_id) - elif provider_type == ToolProviderType.API: - return cls.generate_api_tool_icon_url(tenant_id, provider_id) - elif provider_type == ToolProviderType.WORKFLOW: - return cls.generate_workflow_tool_icon_url(tenant_id, provider_id) - elif provider_type == ToolProviderType.PLUGIN: - provider = ToolManager.get_plugin_provider(provider_id, tenant_id) - try: - return cls.generate_plugin_tool_icon_url(tenant_id, provider.entity.identity.icon) - except Exception: - return {"background": "#252525", "content": "\ud83d\ude01"} - raise ValueError(f"plugin provider {provider_id} not found") - elif provider_type == ToolProviderType.MCP: - return cls.generate_mcp_tool_icon_url(tenant_id, provider_id) - else: - raise ValueError(f"provider type {provider_type} not found") + case ToolProviderType.MCP: + return cls.generate_mcp_tool_icon_url(tenant_id, provider_id) + case ToolProviderType.APP | ToolProviderType.DATASET_RETRIEVAL: + raise ValueError(f"provider type {provider_type} not found") + case _: + raise ValueError(f"provider type {provider_type} not found") @classmethod def _convert_tool_parameters_type( From 9c4f897b9a6c1c88b795f3366236a71467ba9844 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:36:28 -0700 Subject: [PATCH 18/53] refactor: convert segmentType if/elif to match/case in webhook_service.py (#30001) (#34770) --- api/services/trigger/webhook_service.py | 47 +++++++++++++++++-------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 8e629deb32..7b69ccfce7 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -597,21 +597,38 @@ class WebhookService: Raises: ValueError: If the value cannot be converted to the specified type """ - if param_type == SegmentType.STRING: - return value - elif param_type == SegmentType.NUMBER: - if not cls._can_convert_to_number(value): - raise ValueError(f"Cannot convert '{value}' to number") - numeric_value = float(value) - return int(numeric_value) if numeric_value.is_integer() else numeric_value - elif param_type == SegmentType.BOOLEAN: - lower_value = value.lower() - bool_map = {"true": True, "false": False, "1": True, "0": False, "yes": True, "no": False} - if lower_value not in bool_map: - raise ValueError(f"Cannot convert '{value}' to boolean") - return bool_map[lower_value] - else: - raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") + match param_type: + case SegmentType.STRING: + return value + case SegmentType.NUMBER: + if not cls._can_convert_to_number(value): + raise ValueError(f"Cannot convert '{value}' to number") + numeric_value = float(value) + return int(numeric_value) if numeric_value.is_integer() else numeric_value + case SegmentType.BOOLEAN: + lower_value = value.lower() + bool_map = {"true": True, "false": False, "1": True, "0": False, "yes": True, "no": False} + if lower_value not in bool_map: + raise ValueError(f"Cannot convert '{value}' to boolean") + return bool_map[lower_value] + case ( + SegmentType.OBJECT + | SegmentType.FILE + | SegmentType.ARRAY_ANY + | SegmentType.ARRAY_STRING + | SegmentType.ARRAY_NUMBER + | SegmentType.ARRAY_OBJECT + | SegmentType.ARRAY_FILE + | SegmentType.ARRAY_BOOLEAN + | SegmentType.SECRET + | SegmentType.INTEGER + | SegmentType.FLOAT + | SegmentType.NONE + | SegmentType.GROUP + ): + raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") + case _: + raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") @classmethod def _validate_json_value(cls, param_name: str, value: Any, param_type: SegmentType | str) -> Any: From 1898a3f8a5b6ac1a92aa1aff7fe1330c3f7e487e Mon Sep 17 00:00:00 2001 From: volcano303 <75143900+volcano303@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:36:57 +0200 Subject: [PATCH 19/53] test: migrate recommended_app_service tests to testcontainers (#34751) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../services/test_recommended_app_service.py | 388 +++++++++++ .../services/test_recommended_app_service.py | 628 ------------------ 2 files changed, 388 insertions(+), 628 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/services/test_recommended_app_service.py delete mode 100644 api/tests/unit_tests/services/test_recommended_app_service.py diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py new file mode 100644 index 0000000000..ccc4188dbf --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.model import AccountTrialAppRecord, TrialApp +from services import recommended_app_service as service_module +from services.recommended_app_service import RecommendedAppService + +# ── Helpers ──────────────────────────────────────────────────────────── + + +def _apps_response( + recommended_apps: list[dict] | None = None, + categories: list[str] | None = None, +) -> dict: + if recommended_apps is None: + recommended_apps = [ + {"id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"}, + {"id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"}, + ] + if categories is None: + categories = ["productivity", "communication", "utilities"] + return {"recommended_apps": recommended_apps, "categories": categories} + + +def _app_detail( + app_id: str = "app-123", + name: str = "Test App", + description: str = "Test description", + **kwargs: Any, +) -> dict: + detail: dict[str, Any] = { + "id": app_id, + "name": name, + "description": description, + "category": kwargs.get("category", "productivity"), + "icon": kwargs.get("icon", "🚀"), + "model_config": kwargs.get("model_config", {}), + } + detail.update(kwargs) + return detail + + +def _recommendation_detail(result: dict[str, Any] | None) -> dict[str, Any] | None: + return cast("dict[str, Any] | None", result) + + +def _mock_factory_for_apps( + monkeypatch: pytest.MonkeyPatch, + *, + mode: str, + result: dict[str, Any], + fallback_result: dict[str, Any] | None = None, +) -> tuple[MagicMock, MagicMock]: + retrieval_instance = MagicMock() + retrieval_instance.get_recommended_apps_and_categories.return_value = result + retrieval_factory = MagicMock(return_value=retrieval_instance) + monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", mode, raising=False) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=retrieval_factory), + ) + builtin_instance = MagicMock() + if fallback_result is not None: + builtin_instance.fetch_recommended_apps_from_builtin.return_value = fallback_result + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_buildin_recommend_app_retrieval", + MagicMock(return_value=builtin_instance), + ) + return retrieval_instance, builtin_instance + + +# ── Pure logic tests: get_recommended_apps_and_categories ────────────── + + +class TestRecommendedAppServiceGetApps: + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_success_with_apps(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + expected = _apps_response() + + mock_instance = MagicMock() + mock_instance.get_recommended_apps_and_categories.return_value = expected + mock_factory = MagicMock(return_value=mock_instance) + mock_factory_class.get_recommend_app_factory.return_value = mock_factory + + result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + + assert result == expected + assert len(result["recommended_apps"]) == 2 + assert len(result["categories"]) == 3 + mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") + mock_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_fallback_to_builtin_when_empty(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + empty_response = {"recommended_apps": [], "categories": []} + builtin_response = _apps_response( + recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}] + ) + + mock_remote_instance = MagicMock() + mock_remote_instance.get_recommended_apps_and_categories.return_value = empty_response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_remote_instance) + + mock_builtin_instance = MagicMock() + mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response + mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance + + result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN") + + assert result == builtin_response + assert result["recommended_apps"][0]["id"] == "builtin-1" + mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_fallback_when_none_recommended_apps(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" + none_response = {"recommended_apps": None, "categories": ["test"]} + builtin_response = _apps_response() + + mock_db_instance = MagicMock() + mock_db_instance.get_recommended_apps_and_categories.return_value = none_response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_db_instance) + + mock_builtin_instance = MagicMock() + mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response + mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance + + result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + + assert result == builtin_response + mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once() + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_different_languages(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" + + for language in ["en-US", "zh-CN", "ja-JP", "fr-FR"]: + lang_response = _apps_response( + recommended_apps=[{"id": f"app-{language}", "name": f"App {language}", "category": "test"}] + ) + mock_instance = MagicMock() + mock_instance.get_recommended_apps_and_categories.return_value = lang_response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = RecommendedAppService.get_recommended_apps_and_categories(language) + + assert result["recommended_apps"][0]["id"] == f"app-{language}" + mock_instance.get_recommended_apps_and_categories.assert_called_with(language) + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_uses_correct_factory_mode(self, mock_config, mock_factory_class): + for mode in ["remote", "builtin", "db"]: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode + response = _apps_response() + mock_instance = MagicMock() + mock_instance.get_recommended_apps_and_categories.return_value = response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + RecommendedAppService.get_recommended_apps_and_categories("en-US") + + mock_factory_class.get_recommend_app_factory.assert_called_with(mode) + + +# ── Pure logic tests: get_recommend_app_detail ───────────────────────── + + +class TestRecommendedAppServiceGetDetail: + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_success(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + expected = _app_detail(app_id="app-123", name="Productivity App", description="A great app") + + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = expected + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("app-123")) + + assert result == expected + assert result["id"] == "app-123" + mock_instance.get_recommend_app_detail.assert_called_once_with("app-123") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_different_modes(self, mock_config, mock_factory_class): + for mode in ["remote", "builtin", "db"]: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode + detail = _app_detail(app_id="test-app", name=f"App from {mode}") + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = detail + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("test-app")) + + assert result["name"] == f"App from {mode}" + mock_factory_class.get_recommend_app_factory.assert_called_with(mode) + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_returns_none_when_not_found(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = None + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("nonexistent")) + + assert result is None + mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_returns_empty_dict(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = {} + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("app-empty")) + + assert result == {} + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_complex_model_config(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + complex_config = { + "provider": "openai", + "model": "gpt-4", + "parameters": {"temperature": 0.7, "max_tokens": 2000, "top_p": 1.0}, + } + expected = _app_detail( + app_id="complex-app", + name="Complex App", + model_config=complex_config, + workflows=["workflow-1", "workflow-2"], + tools=["tool-1", "tool-2", "tool-3"], + ) + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = expected + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("complex-app")) + + assert result["model_config"] == complex_config + assert len(result["workflows"]) == 2 + assert len(result["tools"]) == 3 + + +# ── Integration tests: trial app features (real DB) ──────────────────── + + +class TestRecommendedAppServiceTrialFeatures: + def test_get_apps_should_not_query_trial_table_when_disabled( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): + expected = {"recommended_apps": [{"app_id": "app-1"}], "categories": ["all"]} + retrieval_instance, builtin_instance = _mock_factory_for_apps(monkeypatch, mode="remote", result=expected) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), + ) + + result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + + assert result == expected + retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") + builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called() + + def test_get_apps_should_enrich_can_trial_when_enabled( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): + app_id_1 = str(uuid.uuid4()) + app_id_2 = str(uuid.uuid4()) + tenant_id = str(uuid.uuid4()) + + # app_id_1 has a TrialApp record; app_id_2 does not + db_session_with_containers.add(TrialApp(app_id=app_id_1, tenant_id=tenant_id)) + db_session_with_containers.commit() + + remote_result = {"recommended_apps": [], "categories": []} + fallback_result = { + "recommended_apps": [{"app_id": app_id_1}, {"app_id": app_id_2}], + "categories": ["all"], + } + _, builtin_instance = _mock_factory_for_apps( + monkeypatch, mode="remote", result=remote_result, fallback_result=fallback_result + ) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + + result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP") + + builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") + assert result["recommended_apps"][0]["can_trial"] is True + assert result["recommended_apps"][1]["can_trial"] is False + + @pytest.mark.parametrize("has_trial_app", [True, False]) + def test_get_detail_should_set_can_trial_when_enabled( + self, + db_session_with_containers: Session, + monkeypatch: pytest.MonkeyPatch, + has_trial_app: bool, + ): + app_id = str(uuid.uuid4()) + tenant_id = str(uuid.uuid4()) + + if has_trial_app: + db_session_with_containers.add(TrialApp(app_id=app_id, tenant_id=tenant_id)) + db_session_with_containers.commit() + + detail = {"id": app_id, "name": "Test App"} + retrieval_instance = MagicMock() + retrieval_instance.get_recommend_app_detail.return_value = detail + retrieval_factory = MagicMock(return_value=retrieval_instance) + monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=retrieval_factory), + ) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + + result = cast(dict[str, Any], RecommendedAppService.get_recommend_app_detail(app_id)) + + assert result["id"] == app_id + assert result["can_trial"] is has_trial_app + + def test_add_trial_app_record_increments_count_for_existing(self, db_session_with_containers: Session): + app_id = str(uuid.uuid4()) + account_id = str(uuid.uuid4()) + + db_session_with_containers.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=3)) + db_session_with_containers.commit() + + RecommendedAppService.add_trial_app_record(app_id, account_id) + + db_session_with_containers.expire_all() + record = db_session_with_containers.scalar( + select(AccountTrialAppRecord) + .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id) + .limit(1) + ) + assert record is not None + assert record.count == 4 + + def test_add_trial_app_record_creates_new_record(self, db_session_with_containers: Session): + app_id = str(uuid.uuid4()) + account_id = str(uuid.uuid4()) + + RecommendedAppService.add_trial_app_record(app_id, account_id) + + db_session_with_containers.expire_all() + record = db_session_with_containers.scalar( + select(AccountTrialAppRecord) + .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id) + .limit(1) + ) + assert record is not None + assert record.app_id == app_id + assert record.account_id == account_id + assert record.count == 1 diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py deleted file mode 100644 index 12bc84db87..0000000000 --- a/api/tests/unit_tests/services/test_recommended_app_service.py +++ /dev/null @@ -1,628 +0,0 @@ -""" -Comprehensive unit tests for RecommendedAppService. - -This test suite provides complete coverage of recommended app operations in Dify, -following TDD principles with the Arrange-Act-Assert pattern. - -## Test Coverage - -### 1. Get Recommended Apps and Categories (TestRecommendedAppServiceGetApps) -Tests fetching recommended apps with categories: -- Successful retrieval with recommended apps -- Fallback to builtin when no recommended apps -- Different language support -- Factory mode selection (remote, builtin, db) -- Empty result handling - -### 2. Get Recommend App Detail (TestRecommendedAppServiceGetDetail) -Tests fetching individual app details: -- Successful app detail retrieval -- Different factory modes -- App not found scenarios -- Language-specific details - -## Testing Approach - -- **Mocking Strategy**: All external dependencies (dify_config, RecommendAppRetrievalFactory) - are mocked for fast, isolated unit tests -- **Factory Pattern**: Tests verify correct factory selection based on mode -- **Fixtures**: Mock objects are configured per test method -- **Assertions**: Each test verifies return values and factory method calls - -## Key Concepts - -**Factory Modes:** -- remote: Fetch from remote API -- builtin: Use built-in templates -- db: Fetch from database - -**Fallback Logic:** -- If remote/db returns no apps, fallback to builtin en-US templates -- Ensures users always see some recommended apps -""" - -from unittest.mock import MagicMock, patch - -import pytest - -from services.recommended_app_service import RecommendedAppService - - -class RecommendedAppServiceTestDataFactory: - """ - Factory for creating test data and mock objects. - - Provides reusable methods to create consistent mock objects for testing - recommended app operations. - """ - - @staticmethod - def create_recommended_apps_response( - recommended_apps: list[dict] | None = None, - categories: list[str] | None = None, - ) -> dict: - """ - Create a mock response for recommended apps. - - Args: - recommended_apps: List of recommended app dictionaries - categories: List of category names - - Returns: - Dictionary with recommended_apps and categories - """ - if recommended_apps is None: - recommended_apps = [ - { - "id": "app-1", - "name": "Test App 1", - "description": "Test description 1", - "category": "productivity", - }, - { - "id": "app-2", - "name": "Test App 2", - "description": "Test description 2", - "category": "communication", - }, - ] - if categories is None: - categories = ["productivity", "communication", "utilities"] - - return { - "recommended_apps": recommended_apps, - "categories": categories, - } - - @staticmethod - def create_app_detail_response( - app_id: str = "app-123", - name: str = "Test App", - description: str = "Test description", - **kwargs, - ) -> dict: - """ - Create a mock response for app detail. - - Args: - app_id: App identifier - name: App name - description: App description - **kwargs: Additional fields - - Returns: - Dictionary with app details - """ - detail = { - "id": app_id, - "name": name, - "description": description, - "category": kwargs.get("category", "productivity"), - "icon": kwargs.get("icon", "🚀"), - "model_config": kwargs.get("model_config", {}), - } - detail.update(kwargs) - return detail - - -@pytest.fixture -def factory(): - """Provide the test data factory to all tests.""" - return RecommendedAppServiceTestDataFactory - - -class TestRecommendedAppServiceGetApps: - """Test get_recommended_apps_and_categories operations.""" - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_success_with_apps(self, mock_config, mock_factory_class, factory): - """Test successful retrieval of recommended apps when apps are returned.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - - expected_response = factory.create_recommended_apps_response() - - # Mock factory and retrieval instance - mock_retrieval_instance = MagicMock() - mock_retrieval_instance.get_recommended_apps_and_categories.return_value = expected_response - - mock_factory = MagicMock() - mock_factory.return_value = mock_retrieval_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - assert result == expected_response - assert len(result["recommended_apps"]) == 2 - assert len(result["categories"]) == 3 - mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") - mock_retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_fallback_to_builtin_when_empty(self, mock_config, mock_factory_class, factory): - """Test fallback to builtin when no recommended apps are returned.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - - # Remote returns empty recommended_apps - empty_response = {"recommended_apps": [], "categories": []} - - # Builtin fallback response - builtin_response = factory.create_recommended_apps_response( - recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}] - ) - - # Mock remote retrieval instance (returns empty) - mock_remote_instance = MagicMock() - mock_remote_instance.get_recommended_apps_and_categories.return_value = empty_response - - mock_remote_factory = MagicMock() - mock_remote_factory.return_value = mock_remote_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_remote_factory - - # Mock builtin retrieval instance - mock_builtin_instance = MagicMock() - mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response - mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN") - - # Assert - assert result == builtin_response - assert len(result["recommended_apps"]) == 1 - assert result["recommended_apps"][0]["id"] == "builtin-1" - # Verify fallback was called with en-US (hardcoded) - mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_fallback_when_none_recommended_apps(self, mock_config, mock_factory_class, factory): - """Test fallback when recommended_apps key is None.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" - - # Response with None recommended_apps - none_response = {"recommended_apps": None, "categories": ["test"]} - - # Builtin fallback response - builtin_response = factory.create_recommended_apps_response() - - # Mock db retrieval instance (returns None) - mock_db_instance = MagicMock() - mock_db_instance.get_recommended_apps_and_categories.return_value = none_response - - mock_db_factory = MagicMock() - mock_db_factory.return_value = mock_db_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_db_factory - - # Mock builtin retrieval instance - mock_builtin_instance = MagicMock() - mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response - mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - assert result == builtin_response - mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once() - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_with_different_languages(self, mock_config, mock_factory_class, factory): - """Test retrieval with different language codes.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" - - languages = ["en-US", "zh-CN", "ja-JP", "fr-FR"] - - for language in languages: - # Create language-specific response - lang_response = factory.create_recommended_apps_response( - recommended_apps=[{"id": f"app-{language}", "name": f"App {language}", "category": "test"}] - ) - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommended_apps_and_categories.return_value = lang_response - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories(language) - - # Assert - assert result["recommended_apps"][0]["id"] == f"app-{language}" - mock_instance.get_recommended_apps_and_categories.assert_called_with(language) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_uses_correct_factory_mode(self, mock_config, mock_factory_class, factory): - """Test that correct factory is selected based on mode.""" - # Arrange - modes = ["remote", "builtin", "db"] - - for mode in modes: - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode - - response = factory.create_recommended_apps_response() - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommended_apps_and_categories.return_value = response - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - mock_factory_class.get_recommend_app_factory.assert_called_with(mode) - - -class TestRecommendedAppServiceGetDetail: - """Test get_recommend_app_detail operations.""" - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_success(self, mock_config, mock_factory_class, factory): - """Test successful retrieval of app detail.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - app_id = "app-123" - - expected_detail = factory.create_app_detail_response( - app_id=app_id, - name="Productivity App", - description="A great productivity app", - category="productivity", - ) - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = expected_detail - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result == expected_detail - assert result["id"] == app_id - assert result["name"] == "Productivity App" - mock_instance.get_recommend_app_detail.assert_called_once_with(app_id) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_with_different_modes(self, mock_config, mock_factory_class, factory): - """Test app detail retrieval with different factory modes.""" - # Arrange - modes = ["remote", "builtin", "db"] - app_id = "test-app" - - for mode in modes: - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode - - detail = factory.create_app_detail_response(app_id=app_id, name=f"App from {mode}") - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = detail - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result["name"] == f"App from {mode}" - mock_factory_class.get_recommend_app_factory.assert_called_with(mode) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_returns_none_when_not_found(self, mock_config, mock_factory_class, factory): - """Test that None is returned when app is not found.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - app_id = "nonexistent-app" - - # Mock retrieval instance returning None - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = None - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result is None - mock_instance.get_recommend_app_detail.assert_called_once_with(app_id) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_returns_empty_dict(self, mock_config, mock_factory_class, factory): - """Test handling of empty dict response.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" - app_id = "app-empty" - - # Mock retrieval instance returning empty dict - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = {} - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result == {} - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_with_complex_model_config(self, mock_config, mock_factory_class, factory): - """Test app detail with complex model configuration.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - app_id = "complex-app" - - complex_model_config = { - "provider": "openai", - "model": "gpt-4", - "parameters": { - "temperature": 0.7, - "max_tokens": 2000, - "top_p": 1.0, - }, - } - - expected_detail = factory.create_app_detail_response( - app_id=app_id, - name="Complex App", - model_config=complex_model_config, - workflows=["workflow-1", "workflow-2"], - tools=["tool-1", "tool-2", "tool-3"], - ) - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = expected_detail - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result["model_config"] == complex_model_config - assert len(result["workflows"]) == 2 - assert len(result["tools"]) == 3 - - -# === Merged from test_recommended_app_service_additional.py === - - -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import MagicMock - -import pytest - -from services import recommended_app_service as service_module -from services.recommended_app_service import RecommendedAppService - - -def _recommendation_detail(result: dict[str, Any] | None) -> dict[str, Any]: - return cast(dict[str, Any], result) - - -@pytest.fixture -def mocked_db_session(monkeypatch: pytest.MonkeyPatch) -> MagicMock: - # Arrange - session = MagicMock() - monkeypatch.setattr(service_module, "db", SimpleNamespace(session=session)) - - # Assert - return session - - -def _mock_factory_for_apps( - monkeypatch: pytest.MonkeyPatch, - *, - mode: str, - result: dict[str, Any], - fallback_result: dict[str, Any] | None = None, -) -> tuple[MagicMock, MagicMock]: - retrieval_instance = MagicMock() - retrieval_instance.get_recommended_apps_and_categories.return_value = result - retrieval_factory = MagicMock(return_value=retrieval_instance) - monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", mode, raising=False) - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_recommend_app_factory", - MagicMock(return_value=retrieval_factory), - ) - - builtin_instance = MagicMock() - if fallback_result is not None: - builtin_instance.fetch_recommended_apps_from_builtin.return_value = fallback_result - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_buildin_recommend_app_retrieval", - MagicMock(return_value=builtin_instance), - ) - return retrieval_instance, builtin_instance - - -def test_get_recommended_apps_and_categories_should_not_query_trial_table_when_trial_feature_disabled( - monkeypatch: pytest.MonkeyPatch, - mocked_db_session: MagicMock, -) -> None: - # Arrange - expected = {"recommended_apps": [{"app_id": "app-1"}], "categories": ["all"]} - retrieval_instance, builtin_instance = _mock_factory_for_apps( - monkeypatch, - mode="remote", - result=expected, - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), - ) - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - assert result == expected - retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") - builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called() - mocked_db_session.scalar.assert_not_called() - - -def test_get_recommended_apps_and_categories_should_fallback_and_enrich_can_trial_when_trial_feature_enabled( - monkeypatch: pytest.MonkeyPatch, - mocked_db_session: MagicMock, -) -> None: - # Arrange - remote_result = {"recommended_apps": [], "categories": []} - fallback_result = {"recommended_apps": [{"app_id": "app-1"}, {"app_id": "app-2"}], "categories": ["all"]} - _, builtin_instance = _mock_factory_for_apps( - monkeypatch, - mode="remote", - result=remote_result, - fallback_result=fallback_result, - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), - ) - mocked_db_session.scalar.side_effect = [SimpleNamespace(id="trial-app"), None] - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP") - - # Assert - builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") - assert result["recommended_apps"][0]["can_trial"] is True - assert result["recommended_apps"][1]["can_trial"] is False - assert mocked_db_session.scalar.call_count == 2 - - -@pytest.mark.parametrize( - ("trial_query_result", "expected_can_trial"), - [ - (SimpleNamespace(id="trial"), True), - (None, False), - ], -) -def test_get_recommend_app_detail_should_set_can_trial_when_trial_feature_enabled( - monkeypatch: pytest.MonkeyPatch, - mocked_db_session: MagicMock, - trial_query_result: Any, - expected_can_trial: bool, -) -> None: - # Arrange - detail = {"id": "app-1", "name": "Test App"} - retrieval_instance = MagicMock() - retrieval_instance.get_recommend_app_detail.return_value = detail - retrieval_factory = MagicMock(return_value=retrieval_instance) - monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False) - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_recommend_app_factory", - MagicMock(return_value=retrieval_factory), - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), - ) - mocked_db_session.scalar.return_value = trial_query_result - - # Act - result = cast(dict[str, Any], RecommendedAppService.get_recommend_app_detail("app-1")) - - # Assert - assert result["id"] == "app-1" - assert result["can_trial"] is expected_can_trial - mocked_db_session.scalar.assert_called_once() - - -def test_add_trial_app_record_should_increment_count_when_existing_record_found( - mocked_db_session: MagicMock, -) -> None: - # Arrange - existing_record = SimpleNamespace(count=3) - mocked_db_session.scalar.return_value = existing_record - - # Act - RecommendedAppService.add_trial_app_record("app-1", "account-1") - - # Assert - assert existing_record.count == 4 - mocked_db_session.scalar.assert_called_once() - mocked_db_session.commit.assert_called_once() - mocked_db_session.add.assert_not_called() - - -def test_add_trial_app_record_should_create_new_record_when_no_existing_record( - mocked_db_session: MagicMock, -) -> None: - # Arrange - mocked_db_session.scalar.return_value = None - - # Act - RecommendedAppService.add_trial_app_record("app-2", "account-2") - - # Assert - mocked_db_session.scalar.assert_called_once() - mocked_db_session.add.assert_called_once() - added = mocked_db_session.add.call_args.args[0] - assert added.app_id == "app-2" - assert added.account_id == "account-2" - assert added.count == 1 - mocked_db_session.commit.assert_called_once() From fd2843b0fb050491fdfaf0effa243465b069d6d3 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:42:13 -0700 Subject: [PATCH 20/53] refactor: convert file-transfer-method-tools if/elif to match/case (#30001) (#34783) --- api/core/tools/workflow_as_tool/tool.py | 28 ++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index a3fb4eda92..a17b7f108d 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -305,14 +305,15 @@ class WorkflowTool(Tool): "transfer_method": file.transfer_method.value, "type": file.type.value, } - if file.transfer_method == FileTransferMethod.TOOL_FILE: - file_dict["tool_file_id"] = resolve_file_record_id(file.reference) - elif file.transfer_method == FileTransferMethod.LOCAL_FILE: - file_dict["upload_file_id"] = resolve_file_record_id(file.reference) - elif file.transfer_method == FileTransferMethod.DATASOURCE_FILE: - file_dict["datasource_file_id"] = resolve_file_record_id(file.reference) - elif file.transfer_method == FileTransferMethod.REMOTE_URL: - file_dict["url"] = file.generate_url() + match file.transfer_method: + case FileTransferMethod.TOOL_FILE: + file_dict["tool_file_id"] = resolve_file_record_id(file.reference) + case FileTransferMethod.LOCAL_FILE: + file_dict["upload_file_id"] = resolve_file_record_id(file.reference) + case FileTransferMethod.DATASOURCE_FILE: + file_dict["datasource_file_id"] = resolve_file_record_id(file.reference) + case FileTransferMethod.REMOTE_URL: + file_dict["url"] = file.generate_url() files.append(file_dict) except Exception: @@ -357,8 +358,11 @@ class WorkflowTool(Tool): def _update_file_mapping(self, file_dict: dict): file_id = resolve_file_record_id(file_dict.get("reference") or file_dict.get("related_id")) transfer_method = FileTransferMethod.value_of(file_dict.get("transfer_method")) - if transfer_method == FileTransferMethod.TOOL_FILE: - file_dict["tool_file_id"] = file_id - elif transfer_method == FileTransferMethod.LOCAL_FILE: - file_dict["upload_file_id"] = file_id + match transfer_method: + case FileTransferMethod.TOOL_FILE: + file_dict["tool_file_id"] = file_id + case FileTransferMethod.LOCAL_FILE: + file_dict["upload_file_id"] = file_id + case FileTransferMethod.REMOTE_URL | FileTransferMethod.DATASOURCE_FILE: + pass return file_dict From 3325392cc571d44f8711d933eaa890fcf2d8ce34 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:51:43 -0700 Subject: [PATCH 21/53] refactor: convert segmentType workflow if/elif to match/case (#34785) --- api/models/workflow.py | 62 ++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/api/models/workflow.py b/api/models/workflow.py index 1063016370..8e8d2e6fd9 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -1625,21 +1625,22 @@ class WorkflowDraftVariable(Base): # Rebuild them through the file factory so tenant ownership, signed URLs, # and storage-backed metadata come from canonical records instead of the # serialized JSON blob. - if segment_type == SegmentType.FILE: - if isinstance(value, File): - return build_segment_with_type(segment_type, value) - elif isinstance(value, dict): - file = self._rebuild_file_types(value) - return build_segment_with_type(segment_type, file) - else: - raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") - if segment_type == SegmentType.ARRAY_FILE: - if not isinstance(value, list): - raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") - file_list = self._rebuild_file_types(value) - return build_segment_with_type(segment_type=segment_type, value=file_list) - - return build_segment_with_type(segment_type=segment_type, value=value) + match segment_type: + case SegmentType.FILE: + if isinstance(value, File): + return build_segment_with_type(segment_type, value) + elif isinstance(value, dict): + file = self._rebuild_file_types(value) + return build_segment_with_type(segment_type, file) + else: + raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") + case SegmentType.ARRAY_FILE: + if not isinstance(value, list): + raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") + file_list = self._rebuild_file_types(value) + return build_segment_with_type(segment_type=segment_type, value=file_list) + case _: + return build_segment_with_type(segment_type=segment_type, value=value) @staticmethod def rebuild_file_types(value: Any): @@ -1672,21 +1673,22 @@ class WorkflowDraftVariable(Base): # Extends `variable_factory.build_segment_with_type` functionality by # reconstructing `FileSegment`` or `ArrayFileSegment`` objects from # their serialized dictionary or list representations, respectively. - if segment_type == SegmentType.FILE: - if isinstance(value, File): - return build_segment_with_type(segment_type, value) - elif isinstance(value, dict): - file = cls.rebuild_file_types(value) - return build_segment_with_type(segment_type, file) - else: - raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") - if segment_type == SegmentType.ARRAY_FILE: - if not isinstance(value, list): - raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") - file_list = cls.rebuild_file_types(value) - return build_segment_with_type(segment_type=segment_type, value=file_list) - - return build_segment_with_type(segment_type=segment_type, value=value) + match segment_type: + case SegmentType.FILE: + if isinstance(value, File): + return build_segment_with_type(segment_type, value) + elif isinstance(value, dict): + file = cls.rebuild_file_types(value) + return build_segment_with_type(segment_type, file) + else: + raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") + case SegmentType.ARRAY_FILE: + if not isinstance(value, list): + raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") + file_list = cls.rebuild_file_types(value) + return build_segment_with_type(segment_type=segment_type, value=file_list) + case _: + return build_segment_with_type(segment_type=segment_type, value=value) def get_value(self) -> Segment: """Decode the serialized value into its corresponding `Segment` object. From 1c7cf44af47c9ee77572e5fec9ecbfbdb067d5be Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:11:47 -0700 Subject: [PATCH 22/53] refactor: convert SegmentType controllers if/elif to match/case (#30001) (#34784) --- .../console/app/workflow_draft_variable.py | 39 ++++++++++--------- .../rag_pipeline_draft_variable.py | 39 ++++++++++--------- .../workflow/nodes/trigger_webhook/node.py | 29 +++++++------- 3 files changed, 57 insertions(+), 50 deletions(-) diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index f6d076320c..9771d6f1e5 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -384,24 +384,27 @@ class VariableApi(Resource): new_value = None if raw_value is not None: - if variable.value_type == SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=app_model.tenant_id, - access_controller=_file_access_controller, - ) - elif variable.value_type == SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=app_model.tenant_id, - access_controller=_file_access_controller, - ) + match variable.value_type: + case SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + raw_value = build_from_mapping( + mapping=raw_value, + tenant_id=app_model.tenant_id, + access_controller=_file_access_controller, + ) + case SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + if len(raw_value) > 0 and not isinstance(raw_value[0], dict): + raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") + raw_value = build_from_mappings( + mappings=raw_value, + tenant_id=app_model.tenant_id, + access_controller=_file_access_controller, + ) + case _: + pass new_value = build_segment_with_type(variable.value_type, raw_value) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py index 93feec0019..3549f9542d 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py @@ -223,24 +223,27 @@ class RagPipelineVariableApi(Resource): new_value = None if raw_value is not None: - if variable.value_type == SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=pipeline.tenant_id, - access_controller=_file_access_controller, - ) - elif variable.value_type == SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=pipeline.tenant_id, - access_controller=_file_access_controller, - ) + match variable.value_type: + case SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + raw_value = build_from_mapping( + mapping=raw_value, + tenant_id=pipeline.tenant_id, + access_controller=_file_access_controller, + ) + case SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + if len(raw_value) > 0 and not isinstance(raw_value[0], dict): + raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") + raw_value = build_from_mappings( + mappings=raw_value, + tenant_id=pipeline.tenant_id, + access_controller=_file_access_controller, + ) + case _: + pass new_value = build_segment_with_type(variable.value_type, raw_value) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index ebaac93934..6a0d633627 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -155,24 +155,25 @@ class TriggerWebhookNode(Node[WebhookData]): outputs[param_name] = raw_data continue - if param_type == SegmentType.FILE: - # Get File object (already processed by webhook controller) - files = webhook_data.get("files", {}) - if files and isinstance(files, dict): - file = files.get(param_name) - if file and isinstance(file, dict): - file_var = self.generate_file_var(param_name, file) - if file_var: - outputs[param_name] = file_var + match param_type: + case SegmentType.FILE: + # Get File object (already processed by webhook controller) + files = webhook_data.get("files", {}) + if files and isinstance(files, dict): + file = files.get(param_name) + if file and isinstance(file, dict): + file_var = self.generate_file_var(param_name, file) + if file_var: + outputs[param_name] = file_var + else: + outputs[param_name] = files else: outputs[param_name] = files else: outputs[param_name] = files - else: - outputs[param_name] = files - else: - # Get regular body parameter - outputs[param_name] = webhook_data.get("body", {}).get(param_name) + case _: + # Get regular body parameter + outputs[param_name] = webhook_data.get("body", {}).get(param_name) # Include raw webhook data for debugging/advanced use outputs["_webhook_raw"] = webhook_data From 2275c5b1a36a1359ae6cd5519c5748a69ca31372 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:43:52 -0700 Subject: [PATCH 23/53] refactor: convert file-transfer-method-pipeline if/elif to match/case (#30001) (#34788) Co-authored-by: Asuka Minato --- .../app/task_pipeline/message_file_utils.py | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/api/core/app/task_pipeline/message_file_utils.py b/api/core/app/task_pipeline/message_file_utils.py index b23a33923b..77310baf74 100644 --- a/api/core/app/task_pipeline/message_file_utils.py +++ b/api/core/app/task_pipeline/message_file_utils.py @@ -40,41 +40,44 @@ def prepare_file_dict(message_file: MessageFile, upload_files_map: dict[str, Upl size = 0 extension = "" - if message_file.transfer_method == FileTransferMethod.REMOTE_URL: - url = message_file.url - if message_file.url: - filename = message_file.url.split("/")[-1].split("?")[0] - if "." in filename: - extension = "." + filename.rsplit(".", 1)[1] - elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE: - if upload_file: - url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id)) - filename = upload_file.name - mime_type = upload_file.mime_type or "application/octet-stream" - size = upload_file.size or 0 - extension = f".{upload_file.extension}" if upload_file.extension else "" - elif message_file.upload_file_id: - url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id)) - elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url: - if message_file.url.startswith(("http://", "https://")): + match message_file.transfer_method: + case FileTransferMethod.REMOTE_URL: url = message_file.url - filename = message_file.url.split("/")[-1].split("?")[0] - if "." in filename: - extension = "." + filename.rsplit(".", 1)[1] - else: - url_parts = message_file.url.split("/") - if url_parts: - file_part = url_parts[-1].split("?")[0] - if "." in file_part: - tool_file_id, ext = file_part.rsplit(".", 1) - extension = f".{ext}" - if len(extension) > MAX_TOOL_FILE_EXTENSION_LENGTH: + if message_file.url: + filename = message_file.url.split("/")[-1].split("?")[0] + if "." in filename: + extension = "." + filename.rsplit(".", 1)[1] + case FileTransferMethod.LOCAL_FILE: + if upload_file: + url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id)) + filename = upload_file.name + mime_type = upload_file.mime_type or "application/octet-stream" + size = upload_file.size or 0 + extension = f".{upload_file.extension}" if upload_file.extension else "" + elif message_file.upload_file_id: + url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id)) + case FileTransferMethod.TOOL_FILE if message_file.url: + if message_file.url.startswith(("http://", "https://")): + url = message_file.url + filename = message_file.url.split("/")[-1].split("?")[0] + if "." in filename: + extension = "." + filename.rsplit(".", 1)[1] + else: + url_parts = message_file.url.split("/") + if url_parts: + file_part = url_parts[-1].split("?")[0] + if "." in file_part: + tool_file_id, ext = file_part.rsplit(".", 1) + extension = f".{ext}" + if len(extension) > MAX_TOOL_FILE_EXTENSION_LENGTH: + extension = ".bin" + else: + tool_file_id = file_part extension = ".bin" - else: - tool_file_id = file_part - extension = ".bin" - url = sign_tool_file(tool_file_id=tool_file_id, extension=extension) - filename = file_part + url = sign_tool_file(tool_file_id=tool_file_id, extension=extension) + filename = file_part + case FileTransferMethod.TOOL_FILE | FileTransferMethod.DATASOURCE_FILE: + pass transfer_method_value = message_file.transfer_method.value remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else "" From 3ea88dfc7fc6b76e1fc9f959a277a46931637b83 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:27:19 -0700 Subject: [PATCH 24/53] refactor: convert appMode controllers if/elif to match/case (#30001) (#34789) --- api/controllers/console/human_input_form.py | 13 +++++++------ api/controllers/web/workflow_events.py | 13 +++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/api/controllers/console/human_input_form.py b/api/controllers/console/human_input_form.py index e37e78c966..5d79e1b5e9 100644 --- a/api/controllers/console/human_input_form.py +++ b/api/controllers/console/human_input_form.py @@ -168,12 +168,13 @@ class ConsoleWorkflowEventsApi(Resource): else: msg_generator = MessageGenerator() generator: BaseAppGenerator - if app.mode == AppMode.ADVANCED_CHAT: - generator = AdvancedChatAppGenerator() - elif app.mode == AppMode.WORKFLOW: - generator = WorkflowAppGenerator() - else: - raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") + match app.mode: + case AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + case AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + case _: + raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" diff --git a/api/controllers/web/workflow_events.py b/api/controllers/web/workflow_events.py index 61568e70e6..474f9c0957 100644 --- a/api/controllers/web/workflow_events.py +++ b/api/controllers/web/workflow_events.py @@ -72,12 +72,13 @@ class WorkflowEventsApi(WebApiResource): app_mode = AppMode.value_of(app_model.mode) msg_generator = MessageGenerator() generator: BaseAppGenerator - if app_mode == AppMode.ADVANCED_CHAT: - generator = AdvancedChatAppGenerator() - elif app_mode == AppMode.WORKFLOW: - generator = WorkflowAppGenerator() - else: - raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") + match app_mode: + case AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + case AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + case _: + raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" From 0bdd1267fb09fdb73389422097b67fb956ca14b6 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:28:03 -0700 Subject: [PATCH 25/53] refactor: convert appmode plugin if/elif to match/case (#30001) (#34790) --- api/core/mcp/server/streamable_http.py | 37 +++--- api/core/plugin/backwards_invocation/app.py | 126 ++++++++++---------- 2 files changed, 81 insertions(+), 82 deletions(-) diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py index 8de002ae55..72171d1536 100644 --- a/api/core/mcp/server/streamable_http.py +++ b/api/core/mcp/server/streamable_http.py @@ -187,15 +187,16 @@ def build_parameter_schema( def prepare_tool_arguments(app: App, arguments: dict[str, Any]) -> ToolArgumentsDict: """Prepare arguments based on app mode""" - if app.mode == AppMode.WORKFLOW: - return {"inputs": arguments} - elif app.mode == AppMode.COMPLETION: - return {"query": "", "inputs": arguments} - else: - # Chat modes - create a copy to avoid modifying original dict - args_copy = arguments.copy() - query = args_copy.pop("query", "") - return {"query": query, "inputs": args_copy} + match app.mode: + case AppMode.WORKFLOW: + return {"inputs": arguments} + case AppMode.COMPLETION: + return {"query": "", "inputs": arguments} + case _: + # Chat modes - create a copy to avoid modifying original dict + args_copy = arguments.copy() + query = args_copy.pop("query", "") + return {"query": query, "inputs": args_copy} def extract_answer_from_response(app: App, response: Any) -> str: @@ -229,17 +230,13 @@ def process_streaming_response(response: RateLimitGenerator) -> str: def process_mapping_response(app: App, response: Mapping) -> str: """Process mapping response based on app mode""" - if app.mode in { - AppMode.ADVANCED_CHAT, - AppMode.COMPLETION, - AppMode.CHAT, - AppMode.AGENT_CHAT, - }: - return response.get("answer", "") - elif app.mode == AppMode.WORKFLOW: - return json.dumps(response["data"]["outputs"], ensure_ascii=False) - else: - raise ValueError("Invalid app mode: " + str(app.mode)) + match app.mode: + case AppMode.ADVANCED_CHAT | AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT: + return response.get("answer", "") + case AppMode.WORKFLOW: + return json.dumps(response["data"]["outputs"], ensure_ascii=False) + case _: + raise ValueError("Invalid app mode: " + str(app.mode)) def convert_input_form_to_parameters( diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index be11d2223c..e2d2be92cb 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -72,17 +72,18 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): conversation_id = conversation_id or "" - if app.mode in {AppMode.ADVANCED_CHAT, AppMode.AGENT_CHAT, AppMode.CHAT}: - if not query: - raise ValueError("missing query") + match app.mode: + case AppMode.ADVANCED_CHAT | AppMode.AGENT_CHAT | AppMode.CHAT: + if not query: + raise ValueError("missing query") - return cls.invoke_chat_app(app, user, conversation_id, query, stream, inputs, files) - elif app.mode == AppMode.WORKFLOW: - return cls.invoke_workflow_app(app, user, stream, inputs, files) - elif app.mode == AppMode.COMPLETION: - return cls.invoke_completion_app(app, user, stream, inputs, files) - - raise ValueError("unexpected app type") + return cls.invoke_chat_app(app, user, conversation_id, query, stream, inputs, files) + case AppMode.WORKFLOW: + return cls.invoke_workflow_app(app, user, stream, inputs, files) + case AppMode.COMPLETION: + return cls.invoke_completion_app(app, user, stream, inputs, files) + case _: + raise ValueError("unexpected app type") @classmethod def invoke_chat_app( @@ -98,60 +99,61 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): """ invoke chat app """ - if app.mode == AppMode.ADVANCED_CHAT: - workflow = app.workflow - if not workflow: + match app.mode: + case AppMode.ADVANCED_CHAT: + workflow = app.workflow + if not workflow: + raise ValueError("unexpected app type") + + pause_config = PauseStateLayerConfig( + session_factory=db.engine, + state_owner_user_id=workflow.created_by, + ) + + return AdvancedChatAppGenerator().generate( + app_model=app, + workflow=workflow, + user=user, + args={ + "inputs": inputs, + "query": query, + "files": files, + "conversation_id": conversation_id, + }, + invoke_from=InvokeFrom.SERVICE_API, + workflow_run_id=str(uuid.uuid4()), + streaming=stream, + pause_state_config=pause_config, + ) + case AppMode.AGENT_CHAT: + return AgentChatAppGenerator().generate( + app_model=app, + user=user, + args={ + "inputs": inputs, + "query": query, + "files": files, + "conversation_id": conversation_id, + }, + invoke_from=InvokeFrom.SERVICE_API, + streaming=stream, + ) + case AppMode.CHAT: + return ChatAppGenerator().generate( + app_model=app, + user=user, + args={ + "inputs": inputs, + "query": query, + "files": files, + "conversation_id": conversation_id, + }, + invoke_from=InvokeFrom.SERVICE_API, + streaming=stream, + ) + case _: raise ValueError("unexpected app type") - pause_config = PauseStateLayerConfig( - session_factory=db.engine, - state_owner_user_id=workflow.created_by, - ) - - return AdvancedChatAppGenerator().generate( - app_model=app, - workflow=workflow, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - workflow_run_id=str(uuid.uuid4()), - streaming=stream, - pause_state_config=pause_config, - ) - elif app.mode == AppMode.AGENT_CHAT: - return AgentChatAppGenerator().generate( - app_model=app, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - ) - elif app.mode == AppMode.CHAT: - return ChatAppGenerator().generate( - app_model=app, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - ) - else: - raise ValueError("unexpected app type") - @classmethod def invoke_workflow_app( cls, From 7ca5b726a2f462cc8e1c462c6083e0cb975be83f Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:28:19 -0700 Subject: [PATCH 26/53] refactor: convert ProviderQuota if/elif to match/case (#30001) (#34791) --- api/core/app/llm/quota.py | 63 ++++++++++++++++++------------------ api/core/provider_manager.py | 57 ++++++++++++++++---------------- 2 files changed, 61 insertions(+), 59 deletions(-) diff --git a/api/core/app/llm/quota.py b/api/core/app/llm/quota.py index a454217768..0bb10190c4 100644 --- a/api/core/app/llm/quota.py +++ b/api/core/app/llm/quota.py @@ -57,36 +57,37 @@ def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LL used_quota = 1 if used_quota is not None and system_configuration.current_quota_type is not None: - if system_configuration.current_quota_type == ProviderQuotaType.TRIAL: - from services.credit_pool_service import CreditPoolService + match system_configuration.current_quota_type: + case ProviderQuotaType.TRIAL: + from services.credit_pool_service import CreditPoolService - CreditPoolService.check_and_deduct_credits( - tenant_id=tenant_id, - credits_required=used_quota, - ) - elif system_configuration.current_quota_type == ProviderQuotaType.PAID: - from services.credit_pool_service import CreditPoolService - - CreditPoolService.check_and_deduct_credits( - tenant_id=tenant_id, - credits_required=used_quota, - pool_type="paid", - ) - else: - with sessionmaker(bind=db.engine).begin() as session: - stmt = ( - update(Provider) - .where( - Provider.tenant_id == tenant_id, - # TODO: Use provider name with prefix after the data migration. - Provider.provider_name == ModelProviderID(model_instance.provider).provider_name, - Provider.provider_type == ProviderType.SYSTEM.value, - Provider.quota_type == system_configuration.current_quota_type, - Provider.quota_limit > Provider.quota_used, - ) - .values( - quota_used=Provider.quota_used + used_quota, - last_used=naive_utc_now(), - ) + CreditPoolService.check_and_deduct_credits( + tenant_id=tenant_id, + credits_required=used_quota, ) - session.execute(stmt) + case ProviderQuotaType.PAID: + from services.credit_pool_service import CreditPoolService + + CreditPoolService.check_and_deduct_credits( + tenant_id=tenant_id, + credits_required=used_quota, + pool_type="paid", + ) + case ProviderQuotaType.FREE: + with sessionmaker(bind=db.engine).begin() as session: + stmt = ( + update(Provider) + .where( + Provider.tenant_id == tenant_id, + # TODO: Use provider name with prefix after the data migration. + Provider.provider_name == ModelProviderID(model_instance.provider).provider_name, + Provider.provider_type == ProviderType.SYSTEM.value, + Provider.quota_type == system_configuration.current_quota_type, + Provider.quota_limit > Provider.quota_used, + ) + .values( + quota_used=Provider.quota_used + used_quota, + last_used=naive_utc_now(), + ) + ) + session.execute(stmt) diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 552de66f8b..e3b3f83c20 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -961,36 +961,37 @@ class ProviderManager: raise ValueError("quota_used is None") if provider_record.quota_limit is None: raise ValueError("quota_limit is None") - if provider_quota.quota_type == ProviderQuotaType.TRIAL and trail_pool is not None: - quota_configuration = QuotaConfiguration( - quota_type=provider_quota.quota_type, - quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, - quota_used=trail_pool.quota_used, - quota_limit=trail_pool.quota_limit, - is_valid=trail_pool.quota_limit > trail_pool.quota_used or trail_pool.quota_limit == -1, - restrict_models=provider_quota.restrict_models, - ) + match provider_quota.quota_type: + case ProviderQuotaType.TRIAL if trail_pool is not None: + quota_configuration = QuotaConfiguration( + quota_type=provider_quota.quota_type, + quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, + quota_used=trail_pool.quota_used, + quota_limit=trail_pool.quota_limit, + is_valid=trail_pool.quota_limit > trail_pool.quota_used or trail_pool.quota_limit == -1, + restrict_models=provider_quota.restrict_models, + ) - elif provider_quota.quota_type == ProviderQuotaType.PAID and paid_pool is not None: - quota_configuration = QuotaConfiguration( - quota_type=provider_quota.quota_type, - quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, - quota_used=paid_pool.quota_used, - quota_limit=paid_pool.quota_limit, - is_valid=paid_pool.quota_limit > paid_pool.quota_used or paid_pool.quota_limit == -1, - restrict_models=provider_quota.restrict_models, - ) + case ProviderQuotaType.PAID if paid_pool is not None: + quota_configuration = QuotaConfiguration( + quota_type=provider_quota.quota_type, + quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, + quota_used=paid_pool.quota_used, + quota_limit=paid_pool.quota_limit, + is_valid=paid_pool.quota_limit > paid_pool.quota_used or paid_pool.quota_limit == -1, + restrict_models=provider_quota.restrict_models, + ) - else: - quota_configuration = QuotaConfiguration( - quota_type=provider_quota.quota_type, - quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, - quota_used=provider_record.quota_used, - quota_limit=provider_record.quota_limit, - is_valid=provider_record.quota_limit > provider_record.quota_used - or provider_record.quota_limit == -1, - restrict_models=provider_quota.restrict_models, - ) + case _: + quota_configuration = QuotaConfiguration( + quota_type=provider_quota.quota_type, + quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, + quota_used=provider_record.quota_used, + quota_limit=provider_record.quota_limit, + is_valid=provider_record.quota_limit > provider_record.quota_used + or provider_record.quota_limit == -1, + restrict_models=provider_quota.restrict_models, + ) quota_configurations.append(quota_configuration) From 9308287fea9c834407af91918c3d921f7eca6502 Mon Sep 17 00:00:00 2001 From: BrianWang1990 Date: Thu, 9 Apr 2026 10:49:40 +0800 Subject: [PATCH 27/53] fix: copy button not working on API Server and API Key pages (#34515) Co-authored-by: Brian Wang Co-authored-by: test Co-authored-by: BrianWang1990 <512dabing99@163.com> Co-authored-by: Stephen Zhou Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> --- pnpm-lock.yaml | 42 ++--------- pnpm-workspace.yaml | 2 +- .../copy-feedback/__tests__/index.spec.tsx | 2 +- .../components/base/copy-feedback/index.tsx | 2 +- .../base/copy-icon/__tests__/index.spec.tsx | 2 +- web/app/components/base/copy-icon/index.tsx | 2 +- .../input-with-copy/__tests__/index.spec.tsx | 2 +- .../components/base/input-with-copy/index.tsx | 2 +- web/hooks/noop.ts | 7 ++ web/hooks/use-clipboard.ts | 72 +++++++++++++++++++ ...what-you-are-doing-or-you-will-be-fired.ts | 44 ++++++++++++ web/hooks/use-typescript-happy-callback.ts | 10 +++ web/package.json | 2 +- web/vitest.setup.ts | 5 +- 14 files changed, 150 insertions(+), 46 deletions(-) create mode 100644 web/hooks/noop.ts create mode 100644 web/hooks/use-clipboard.ts create mode 100644 web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts create mode 100644 web/hooks/use-typescript-happy-callback.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8c0970a6..ee3794d88d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ catalogs: class-variance-authority: specifier: 0.7.1 version: 0.7.1 + client-only: + specifier: 0.0.1 + version: 0.0.1 clsx: specifier: 2.1.1 version: 2.1.1 @@ -324,9 +327,6 @@ catalogs: fast-deep-equal: specifier: 3.1.3 version: 3.1.3 - foxact: - specifier: 0.3.0 - version: 0.3.0 happy-dom: specifier: 20.8.9 version: 20.8.9 @@ -736,6 +736,9 @@ importers: class-variance-authority: specifier: 'catalog:' version: 0.7.1 + client-only: + specifier: 'catalog:' + version: 0.0.1 clsx: specifier: 'catalog:' version: 2.1.1 @@ -781,9 +784,6 @@ importers: fast-deep-equal: specifier: 'catalog:' version: 3.1.3 - foxact: - specifier: 'catalog:' - version: 0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) hast-util-to-jsx-runtime: specifier: 'catalog:' version: 2.3.6 @@ -5871,9 +5871,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-bus@1.0.0: - resolution: {integrity: sha512-uPcWKbj/BJU3Tbw9XqhHqET4/LBOhvv3/SJWr7NksxA6TC5YqBpaZgawE9R+WpYFCBFSAE4Vun+xQS6w4ABdlA==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5986,17 +5983,6 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - foxact@0.3.0: - resolution: {integrity: sha512-CSlMlC0KlKQQEO83iLeQCLuT1V0OqnMWj7mjLstIDV8baMe1w4F7z3cz3/T+6Z8W12jqkQj07rwlw4Gi39knGg==} - peerDependencies: - react: '*' - react-dom: '*' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -7710,9 +7696,6 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -13552,8 +13535,6 @@ snapshots: esutils@2.0.3: {} - event-target-bus@1.0.0: {} - events@3.3.0: {} expand-template@2.0.3: @@ -13661,15 +13642,6 @@ snapshots: dependencies: fd-package-json: 2.0.0 - foxact@0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - client-only: 0.0.1 - event-target-bus: 1.0.0 - server-only: 0.0.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - fs-constants@1.0.0: optional: true @@ -15905,8 +15877,6 @@ snapshots: seroval@1.5.1: {} - server-only@0.0.1: {} - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6fe023066a..b7918fff1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -129,6 +129,7 @@ catalog: ahooks: 3.9.7 autoprefixer: 10.4.27 class-variance-authority: 0.7.1 + client-only: 0.0.1 clsx: 2.1.1 cmdk: 1.1.1 code-inspector-plugin: 1.5.1 @@ -154,7 +155,6 @@ catalog: eslint-plugin-sonarjs: 4.0.2 eslint-plugin-storybook: 10.3.5 fast-deep-equal: 3.1.3 - foxact: 0.3.0 happy-dom: 20.8.9 hast-util-to-jsx-runtime: 2.3.6 hono: 4.12.12 diff --git a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx index 322a9970af..8cc22693b6 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ const mockCopy = vi.fn() const mockReset = vi.fn() let mockCopied = false -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: mockCopy, reset: mockReset, diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 80b35eb3a8..5210066670 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -3,11 +3,11 @@ import { RiClipboardFill, RiClipboardLine, } from '@remixicon/react' -import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Tooltip from '@/app/components/base/tooltip' +import { useClipboard } from '@/hooks/use-clipboard' import copyStyle from './style.module.css' type Props = { diff --git a/web/app/components/base/copy-icon/__tests__/index.spec.tsx b/web/app/components/base/copy-icon/__tests__/index.spec.tsx index 3db76ef606..1ce9e6dbf5 100644 --- a/web/app/components/base/copy-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-icon/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ const copy = vi.fn() const reset = vi.fn() let copied = false -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy, reset, diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index 78c0fcb8c3..15332592d0 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -1,7 +1,7 @@ 'use client' -import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useClipboard } from '@/hooks/use-clipboard' import Tooltip from '../tooltip' type Props = { diff --git a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx index 201c419444..33ebec5cbc 100644 --- a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx +++ b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ const mockCopy = vi.fn() let mockCopied = false const mockReset = vi.fn() -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: mockCopy, copied: mockCopied, diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index e85a7bd6f4..33db47baaa 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { InputProps } from '../input' -import { useClipboard } from 'foxact/use-clipboard' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useClipboard } from '@/hooks/use-clipboard' import { cn } from '@/utils/classnames' import ActionButton from '../action-button' import Tooltip from '../tooltip' diff --git a/web/hooks/noop.ts b/web/hooks/noop.ts new file mode 100644 index 0000000000..9cf6f968dc --- /dev/null +++ b/web/hooks/noop.ts @@ -0,0 +1,7 @@ +type Noop = { + // eslint-disable-next-line ts/no-explicit-any + (...args: any[]): any +} + +/** @see https://foxact.skk.moe/noop */ +export const noop: Noop = () => { /* noop */ } diff --git a/web/hooks/use-clipboard.ts b/web/hooks/use-clipboard.ts new file mode 100644 index 0000000000..6d24c04027 --- /dev/null +++ b/web/hooks/use-clipboard.ts @@ -0,0 +1,72 @@ +import { useRef, useState } from 'react' +import { writeTextToClipboard } from '@/utils/clipboard' +import { noop } from './noop' +import { useStableHandler } from './use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired' +import { useCallback } from './use-typescript-happy-callback' +import 'client-only' + +type UseClipboardOption = { + timeout?: number + usePromptAsFallback?: boolean + promptFallbackText?: string + onCopyError?: (error: Error) => void +} + +/** @see https://foxact.skk.moe/use-clipboard */ +export function useClipboard({ + timeout = 1000, + usePromptAsFallback = false, + promptFallbackText = 'Failed to copy to clipboard automatically, please manually copy the text below.', + onCopyError, +}: UseClipboardOption = {}) { + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + const copyTimeoutRef = useRef(null) + + const stablizedOnCopyError = useStableHandler<[e: Error], void>(onCopyError || noop) + + const handleCopyResult = useCallback((isCopied: boolean) => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + if (isCopied) { + copyTimeoutRef.current = window.setTimeout(() => setCopied(false), timeout) + } + setCopied(isCopied) + }, [timeout]) + + const handleCopyError = useCallback((e: Error) => { + setError(e) + stablizedOnCopyError(e) + }, [stablizedOnCopyError]) + + const copy = useCallback(async (valueToCopy: string) => { + try { + await writeTextToClipboard(valueToCopy) + } + catch (e) { + if (usePromptAsFallback) { + try { + // eslint-disable-next-line no-alert -- prompt as fallback in case of copy error + window.prompt(promptFallbackText, valueToCopy) + } + catch (e2) { + handleCopyError(e2 as Error) + } + } + else { + handleCopyError(e as Error) + } + } + }, [handleCopyResult, promptFallbackText, handleCopyError, usePromptAsFallback]) + + const reset = useCallback(() => { + setCopied(false) + setError(null) + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + }, []) + + return { copy, reset, error, copied } +} diff --git a/web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts b/web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts new file mode 100644 index 0000000000..227f4fd1fb --- /dev/null +++ b/web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts @@ -0,0 +1,44 @@ +import * as reactExports from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' + +// useIsomorphicInsertionEffect +const useInsertionEffect + = typeof window === 'undefined' + // useInsertionEffect is only available in React 18+ + + ? useEffect + : reactExports.useInsertionEffect || useLayoutEffect + +/** + * @see https://foxact.skk.moe/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired + * Similar to useCallback, with a few subtle differences: + * - The returned function is a stable reference, and will always be the same between renders + * - No dependency lists required + * - Properties or state accessed within the callback will always be "current" + */ +// eslint-disable-next-line ts/no-explicit-any +export function useStableHandler( + callback: (...args: Args) => Result, +): typeof callback { + // Keep track of the latest callback: + // eslint-disable-next-line ts/no-explicit-any + const latestRef = useRef(shouldNotBeInvokedBeforeMount as any) + useInsertionEffect(() => { + latestRef.current = callback + }, [callback]) + + return useCallback((...args) => { + const fn = latestRef.current + return fn(...args) + }, []) +} + +/** + * Render methods should be pure, especially when concurrency is used, + * so we will throw this error if the callback is called while rendering. + */ +function shouldNotBeInvokedBeforeMount() { + throw new Error( + 'foxact: the stablized handler cannot be invoked before the component has mounted.', + ) +} diff --git a/web/hooks/use-typescript-happy-callback.ts b/web/hooks/use-typescript-happy-callback.ts new file mode 100644 index 0000000000..db3ba372c0 --- /dev/null +++ b/web/hooks/use-typescript-happy-callback.ts @@ -0,0 +1,10 @@ +import { useCallback as useCallbackFromReact } from 'react' + +/** @see https://foxact.skk.moe/use-typescript-happy-callback */ +const useTypeScriptHappyCallback: ( + fn: (...args: Args) => R, + deps: React.DependencyList, +) => (...args: Args) => R = useCallbackFromReact + +/** @see https://foxact.skk.moe/use-typescript-happy-callback */ +export const useCallback = useTypeScriptHappyCallback diff --git a/web/package.json b/web/package.json index d2a9e88f4a..8bc31dce31 100644 --- a/web/package.json +++ b/web/package.json @@ -85,6 +85,7 @@ "abcjs": "catalog:", "ahooks": "catalog:", "class-variance-authority": "catalog:", + "client-only": "catalog:", "clsx": "catalog:", "cmdk": "catalog:", "copy-to-clipboard": "catalog:", @@ -100,7 +101,6 @@ "emoji-mart": "catalog:", "es-toolkit": "catalog:", "fast-deep-equal": "catalog:", - "foxact": "catalog:", "hast-util-to-jsx-runtime": "catalog:", "html-entities": "catalog:", "html-to-image": "catalog:", diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index b17a59bab6..b945f675f7 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -83,11 +83,12 @@ afterEach(async () => { }) }) -// mock foxact/use-clipboard - not available in test environment -vi.mock('foxact/use-clipboard', () => ({ +// mock custom clipboard hook - wraps writeTextToClipboard with fallback +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: vi.fn(), copied: false, + reset: vi.fn(), }), })) From 27e484e7f83803d68a3ad47f69ab2925dd0b095b Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 9 Apr 2026 11:08:25 +0800 Subject: [PATCH 28/53] feat: redis add retry logic (#34566) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/.env.example | 7 + api/configs/middleware/cache/redis_config.py | 31 ++++ api/extensions/ext_redis.py | 67 +++++++-- api/tests/unit_tests/extensions/test_redis.py | 138 +++++++++++++----- docker/.env.example | 14 ++ docker/docker-compose.yaml | 6 + 6 files changed, 217 insertions(+), 46 deletions(-) diff --git a/api/.env.example b/api/.env.example index c6541731e6..2c1a755059 100644 --- a/api/.env.example +++ b/api/.env.example @@ -71,6 +71,13 @@ REDIS_USE_CLUSTERS=false REDIS_CLUSTERS= REDIS_CLUSTERS_PASSWORD= +REDIS_RETRY_RETRIES=3 +REDIS_RETRY_BACKOFF_BASE=1.0 +REDIS_RETRY_BACKOFF_CAP=10.0 +REDIS_SOCKET_TIMEOUT=5.0 +REDIS_SOCKET_CONNECT_TIMEOUT=5.0 +REDIS_HEALTH_CHECK_INTERVAL=30 + # celery configuration CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BACKEND=redis diff --git a/api/configs/middleware/cache/redis_config.py b/api/configs/middleware/cache/redis_config.py index 3b91207545..b49275758a 100644 --- a/api/configs/middleware/cache/redis_config.py +++ b/api/configs/middleware/cache/redis_config.py @@ -117,6 +117,37 @@ class RedisConfig(BaseSettings): default=None, ) + REDIS_RETRY_RETRIES: NonNegativeInt = Field( + description="Maximum number of retries per Redis command on " + "transient failures (ConnectionError, TimeoutError, socket.timeout)", + default=3, + ) + + REDIS_RETRY_BACKOFF_BASE: PositiveFloat = Field( + description="Base delay in seconds for exponential backoff between retries", + default=1.0, + ) + + REDIS_RETRY_BACKOFF_CAP: PositiveFloat = Field( + description="Maximum backoff delay in seconds between retries", + default=10.0, + ) + + REDIS_SOCKET_TIMEOUT: PositiveFloat | None = Field( + description="Socket timeout in seconds for Redis read/write operations", + default=5.0, + ) + + REDIS_SOCKET_CONNECT_TIMEOUT: PositiveFloat | None = Field( + description="Socket timeout in seconds for Redis connection establishment", + default=5.0, + ) + + REDIS_HEALTH_CHECK_INTERVAL: NonNegativeInt = Field( + description="Interval in seconds between Redis connection health checks (0 to disable)", + default=30, + ) + @field_validator("REDIS_MAX_CONNECTIONS", mode="before") @classmethod def _empty_string_to_none_for_max_conns(cls, v): diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index 5f528dbf9e..b9e592cadb 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -7,10 +7,12 @@ from typing import TYPE_CHECKING, Any, Union import redis from redis import RedisError +from redis.backoff import ExponentialWithJitterBackoff # type: ignore from redis.cache import CacheConfig from redis.client import PubSub from redis.cluster import ClusterNode, RedisCluster from redis.connection import Connection, SSLConnection +from redis.retry import Retry from redis.sentinel import Sentinel from configs import dify_config @@ -158,8 +160,41 @@ def _get_cache_configuration() -> CacheConfig | None: return CacheConfig() +def _get_retry_policy() -> Retry: + """Build the shared retry policy for Redis connections.""" + return Retry( + backoff=ExponentialWithJitterBackoff( + base=dify_config.REDIS_RETRY_BACKOFF_BASE, + cap=dify_config.REDIS_RETRY_BACKOFF_CAP, + ), + retries=dify_config.REDIS_RETRY_RETRIES, + ) + + +def _get_connection_health_params() -> dict[str, Any]: + """Get connection health and retry parameters for standalone and Sentinel Redis clients.""" + return { + "retry": _get_retry_policy(), + "socket_timeout": dify_config.REDIS_SOCKET_TIMEOUT, + "socket_connect_timeout": dify_config.REDIS_SOCKET_CONNECT_TIMEOUT, + "health_check_interval": dify_config.REDIS_HEALTH_CHECK_INTERVAL, + } + + +def _get_cluster_connection_health_params() -> dict[str, Any]: + """Get retry and timeout parameters for Redis Cluster clients. + + RedisCluster does not support ``health_check_interval`` as a constructor + keyword (it is silently stripped by ``cleanup_kwargs``), so it is excluded + here. Only ``retry``, ``socket_timeout``, and ``socket_connect_timeout`` + are passed through. + """ + params = _get_connection_health_params() + return {k: v for k, v in params.items() if k != "health_check_interval"} + + def _get_base_redis_params() -> dict[str, Any]: - """Get base Redis connection parameters.""" + """Get base Redis connection parameters including retry and health policy.""" return { "username": dify_config.REDIS_USERNAME, "password": dify_config.REDIS_PASSWORD or None, @@ -169,6 +204,7 @@ def _get_base_redis_params() -> dict[str, Any]: "decode_responses": False, "protocol": dify_config.REDIS_SERIALIZATION_PROTOCOL, "cache_config": _get_cache_configuration(), + **_get_connection_health_params(), } @@ -215,6 +251,7 @@ def _create_cluster_client() -> Union[redis.Redis, RedisCluster]: "password": dify_config.REDIS_CLUSTERS_PASSWORD, "protocol": dify_config.REDIS_SERIALIZATION_PROTOCOL, "cache_config": _get_cache_configuration(), + **_get_cluster_connection_health_params(), } if dify_config.REDIS_MAX_CONNECTIONS: cluster_kwargs["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS @@ -226,7 +263,8 @@ def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis """Create standalone Redis client.""" connection_class, ssl_kwargs = _get_ssl_configuration() - redis_params.update( + params = {**redis_params} + params.update( { "host": dify_config.REDIS_HOST, "port": dify_config.REDIS_PORT, @@ -235,28 +273,31 @@ def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis ) if dify_config.REDIS_MAX_CONNECTIONS: - redis_params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS + params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS if ssl_kwargs: - redis_params.update(ssl_kwargs) + params.update(ssl_kwargs) - pool = redis.ConnectionPool(**redis_params) + pool = redis.ConnectionPool(**params) client: redis.Redis = redis.Redis(connection_pool=pool) return client def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis | RedisCluster: max_conns = dify_config.REDIS_MAX_CONNECTIONS - if use_clusters: - if max_conns: - return RedisCluster.from_url(pubsub_url, max_connections=max_conns) - else: - return RedisCluster.from_url(pubsub_url) + if use_clusters: + health_params = _get_cluster_connection_health_params() + kwargs: dict[str, Any] = {**health_params} + if max_conns: + kwargs["max_connections"] = max_conns + return RedisCluster.from_url(pubsub_url, **kwargs) + + health_params = _get_connection_health_params() + kwargs = {**health_params} if max_conns: - return redis.Redis.from_url(pubsub_url, max_connections=max_conns) - else: - return redis.Redis.from_url(pubsub_url) + kwargs["max_connections"] = max_conns + return redis.Redis.from_url(pubsub_url, **kwargs) def init_app(app: DifyApp): diff --git a/api/tests/unit_tests/extensions/test_redis.py b/api/tests/unit_tests/extensions/test_redis.py index 933fa32894..5e9be4ab9b 100644 --- a/api/tests/unit_tests/extensions/test_redis.py +++ b/api/tests/unit_tests/extensions/test_redis.py @@ -1,53 +1,125 @@ +from unittest.mock import patch + from redis import RedisError +from redis.retry import Retry -from extensions.ext_redis import redis_fallback +from extensions.ext_redis import ( + _get_base_redis_params, + _get_cluster_connection_health_params, + _get_connection_health_params, + redis_fallback, +) -def test_redis_fallback_success(): - @redis_fallback(default_return=None) - def test_func(): - return "success" +class TestGetConnectionHealthParams: + @patch("extensions.ext_redis.dify_config") + def test_includes_all_health_params(self, mock_config): + mock_config.REDIS_RETRY_RETRIES = 3 + mock_config.REDIS_RETRY_BACKOFF_BASE = 1.0 + mock_config.REDIS_RETRY_BACKOFF_CAP = 10.0 + mock_config.REDIS_SOCKET_TIMEOUT = 5.0 + mock_config.REDIS_SOCKET_CONNECT_TIMEOUT = 5.0 + mock_config.REDIS_HEALTH_CHECK_INTERVAL = 30 - assert test_func() == "success" + params = _get_connection_health_params() + + assert "retry" in params + assert "socket_timeout" in params + assert "socket_connect_timeout" in params + assert "health_check_interval" in params + assert isinstance(params["retry"], Retry) + assert params["retry"]._retries == 3 + assert params["socket_timeout"] == 5.0 + assert params["socket_connect_timeout"] == 5.0 + assert params["health_check_interval"] == 30 -def test_redis_fallback_error(): - @redis_fallback(default_return="fallback") - def test_func(): - raise RedisError("Redis error") +class TestGetClusterConnectionHealthParams: + @patch("extensions.ext_redis.dify_config") + def test_excludes_health_check_interval(self, mock_config): + mock_config.REDIS_RETRY_RETRIES = 3 + mock_config.REDIS_RETRY_BACKOFF_BASE = 1.0 + mock_config.REDIS_RETRY_BACKOFF_CAP = 10.0 + mock_config.REDIS_SOCKET_TIMEOUT = 5.0 + mock_config.REDIS_SOCKET_CONNECT_TIMEOUT = 5.0 + mock_config.REDIS_HEALTH_CHECK_INTERVAL = 30 - assert test_func() == "fallback" + params = _get_cluster_connection_health_params() + + assert "retry" in params + assert "socket_timeout" in params + assert "socket_connect_timeout" in params + assert "health_check_interval" not in params -def test_redis_fallback_none_default(): - @redis_fallback() - def test_func(): - raise RedisError("Redis error") +class TestGetBaseRedisParams: + @patch("extensions.ext_redis.dify_config") + def test_includes_retry_and_health_params(self, mock_config): + mock_config.REDIS_USERNAME = None + mock_config.REDIS_PASSWORD = None + mock_config.REDIS_DB = 0 + mock_config.REDIS_SERIALIZATION_PROTOCOL = 3 + mock_config.REDIS_ENABLE_CLIENT_SIDE_CACHE = False + mock_config.REDIS_RETRY_RETRIES = 3 + mock_config.REDIS_RETRY_BACKOFF_BASE = 1.0 + mock_config.REDIS_RETRY_BACKOFF_CAP = 10.0 + mock_config.REDIS_SOCKET_TIMEOUT = 5.0 + mock_config.REDIS_SOCKET_CONNECT_TIMEOUT = 5.0 + mock_config.REDIS_HEALTH_CHECK_INTERVAL = 30 - assert test_func() is None + params = _get_base_redis_params() + + assert "retry" in params + assert isinstance(params["retry"], Retry) + assert params["socket_timeout"] == 5.0 + assert params["socket_connect_timeout"] == 5.0 + assert params["health_check_interval"] == 30 + # Existing params still present + assert params["db"] == 0 + assert params["encoding"] == "utf-8" -def test_redis_fallback_with_args(): - @redis_fallback(default_return=0) - def test_func(x, y): - raise RedisError("Redis error") +class TestRedisFallback: + def test_redis_fallback_success(self): + @redis_fallback(default_return=None) + def test_func(): + return "success" - assert test_func(1, 2) == 0 + assert test_func() == "success" + def test_redis_fallback_error(self): + @redis_fallback(default_return="fallback") + def test_func(): + raise RedisError("Redis error") -def test_redis_fallback_with_kwargs(): - @redis_fallback(default_return={}) - def test_func(x=None, y=None): - raise RedisError("Redis error") + assert test_func() == "fallback" - assert test_func(x=1, y=2) == {} + def test_redis_fallback_none_default(self): + @redis_fallback() + def test_func(): + raise RedisError("Redis error") + assert test_func() is None -def test_redis_fallback_preserves_function_metadata(): - @redis_fallback(default_return=None) - def test_func(): - """Test function docstring""" - pass + def test_redis_fallback_with_args(self): + @redis_fallback(default_return=0) + def test_func(x, y): + raise RedisError("Redis error") - assert test_func.__name__ == "test_func" - assert test_func.__doc__ == "Test function docstring" + assert test_func(1, 2) == 0 + + def test_redis_fallback_with_kwargs(self): + @redis_fallback(default_return={}) + def test_func(x=None, y=None): + raise RedisError("Redis error") + + assert test_func(x=1, y=2) == {} + + def test_redis_fallback_preserves_function_metadata(self): + @redis_fallback(default_return=None) + def test_func(): + """Test function docstring""" + pass + + assert test_func.__name__ == "test_func" + assert test_func.__doc__ == "Test function docstring" diff --git a/docker/.env.example b/docker/.env.example index c046f6d378..f6da6c568d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -373,6 +373,20 @@ REDIS_USE_CLUSTERS=false REDIS_CLUSTERS= REDIS_CLUSTERS_PASSWORD= +# Redis connection and retry configuration +# max redis retry +REDIS_RETRY_RETRIES=3 +# Base delay (in seconds) for exponential backoff on retries +REDIS_RETRY_BACKOFF_BASE=1.0 +# Cap (in seconds) for exponential backoff on retries +REDIS_RETRY_BACKOFF_CAP=10.0 +# Timeout (in seconds) for Redis socket operations +REDIS_SOCKET_TIMEOUT=5.0 +# Timeout (in seconds) for establishing a Redis connection +REDIS_SOCKET_CONNECT_TIMEOUT=5.0 +# Interval (in seconds) for Redis health checks +REDIS_HEALTH_CHECK_INTERVAL=30 + # ------------------------------ # Celery Configuration # ------------------------------ diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3f6a13e78e..dbadc58f89 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -100,6 +100,12 @@ x-shared-env: &shared-api-worker-env REDIS_USE_CLUSTERS: ${REDIS_USE_CLUSTERS:-false} REDIS_CLUSTERS: ${REDIS_CLUSTERS:-} REDIS_CLUSTERS_PASSWORD: ${REDIS_CLUSTERS_PASSWORD:-} + REDIS_RETRY_RETRIES: ${REDIS_RETRY_RETRIES:-3} + REDIS_RETRY_BACKOFF_BASE: ${REDIS_RETRY_BACKOFF_BASE:-1.0} + REDIS_RETRY_BACKOFF_CAP: ${REDIS_RETRY_BACKOFF_CAP:-10.0} + REDIS_SOCKET_TIMEOUT: ${REDIS_SOCKET_TIMEOUT:-5.0} + REDIS_SOCKET_CONNECT_TIMEOUT: ${REDIS_SOCKET_CONNECT_TIMEOUT:-5.0} + REDIS_HEALTH_CHECK_INTERVAL: ${REDIS_HEALTH_CHECK_INTERVAL:-30} CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://:difyai123456@redis:6379/1} CELERY_BACKEND: ${CELERY_BACKEND:-redis} BROKER_USE_SSL: ${BROKER_USE_SSL:-false} From 51dcf4ce84d54652e719a23cc10880ab9a3b4ed2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:27:21 +0000 Subject: [PATCH 29/53] chore(deps): bump litellm from 1.82.6 to 1.83.0 in /api (#34544) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index dab420fc87..2f6581a199 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "numpy~=1.26.4", "openpyxl~=3.1.5", "opik~=1.10.37", - "litellm==1.82.6", # Pinned to avoid madoka dependency issue + "litellm==1.83.0", # Pinned to avoid madoka dependency issue "opentelemetry-api==1.40.0", "opentelemetry-distro==0.61b0", "opentelemetry-exporter-otlp==1.40.0", diff --git a/api/uv.lock b/api/uv.lock index 5015f76224..b1145eac56 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1507,7 +1507,7 @@ requires-dist = [ { name = "json-repair", specifier = ">=0.55.1" }, { name = "langfuse", specifier = ">=3.0.0,<5.0.0" }, { name = "langsmith", specifier = "~=0.7.16" }, - { name = "litellm", specifier = "==1.82.6" }, + { name = "litellm", specifier = "==1.83.0" }, { name = "markdown", specifier = "~=3.10.2" }, { name = "mlflow-skinny", specifier = ">=3.0.0" }, { name = "numpy", specifier = "~=1.26.4" }, @@ -3121,7 +3121,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.82.6" +version = "1.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3137,9 +3137,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" }, + { url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" }, ] [[package]] From 4c6b8f9229f4b73c8c246b4a88d3a3f8c1e230f2 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Thu, 9 Apr 2026 11:31:13 +0800 Subject: [PATCH 30/53] test: add e2e scenarios for app creation and sign-out (#34285) Signed-off-by: majiayu000 <1835304752@qq.com> --- e2e/features/apps/create-chatbot-app.feature | 11 ++++++++ e2e/features/apps/create-workflow-app.feature | 10 ++++++++ e2e/features/auth/sign-out.feature | 8 ++++++ .../step-definitions/apps/create-app.steps.ts | 24 ++++++++++++++++++ .../step-definitions/auth/sign-out.steps.ts | 25 +++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 e2e/features/apps/create-chatbot-app.feature create mode 100644 e2e/features/apps/create-workflow-app.feature create mode 100644 e2e/features/auth/sign-out.feature create mode 100644 e2e/features/step-definitions/auth/sign-out.steps.ts diff --git a/e2e/features/apps/create-chatbot-app.feature b/e2e/features/apps/create-chatbot-app.feature new file mode 100644 index 0000000000..4f506e4f40 --- /dev/null +++ b/e2e/features/apps/create-chatbot-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated +Feature: Create Chatbot app + Scenario: Create a new Chatbot app and redirect to the configuration page + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I expand the beginner app types + And I select the "Chatbot" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the app configuration page diff --git a/e2e/features/apps/create-workflow-app.feature b/e2e/features/apps/create-workflow-app.feature new file mode 100644 index 0000000000..b88d94d899 --- /dev/null +++ b/e2e/features/apps/create-workflow-app.feature @@ -0,0 +1,10 @@ +@apps @authenticated +Feature: Create Workflow app + Scenario: Create a new Workflow app and redirect to the workflow editor + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I select the "Workflow" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the workflow editor diff --git a/e2e/features/auth/sign-out.feature b/e2e/features/auth/sign-out.feature new file mode 100644 index 0000000000..0f377ea133 --- /dev/null +++ b/e2e/features/auth/sign-out.feature @@ -0,0 +1,8 @@ +@auth @authenticated +Feature: Sign out + Scenario: Sign out from the apps console + Given I am signed in as the default E2E admin + When I open the apps console + And I open the account menu + And I sign out + Then I should be on the sign-in page diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index b8e76c6f06..6bc9ae30b6 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -24,6 +24,30 @@ When('I confirm app creation', async function (this: DifyWorld) { await createButton.click() }) +When('I select the {string} app type', async function (this: DifyWorld, appType: string) { + const dialog = this.getPage().getByRole('dialog') + const appTypeTitle = dialog.getByText(appType, { exact: true }) + + await expect(appTypeTitle).toBeVisible() + await appTypeTitle.click() +}) + +When('I expand the beginner app types', async function (this: DifyWorld) { + const page = this.getPage() + const toggle = page.getByRole('button', { name: 'More basic app types' }) + + await expect(toggle).toBeVisible() + await toggle.click() +}) + Then('I should land on the app editor', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/(workflow|configuration)(?:\?.*)?$/) }) + +Then('I should land on the workflow editor', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/workflow(?:\?.*)?$/) +}) + +Then('I should land on the app configuration page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/configuration(?:\?.*)?$/) +}) diff --git a/e2e/features/step-definitions/auth/sign-out.steps.ts b/e2e/features/step-definitions/auth/sign-out.steps.ts new file mode 100644 index 0000000000..935b73c3af --- /dev/null +++ b/e2e/features/step-definitions/auth/sign-out.steps.ts @@ -0,0 +1,25 @@ +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import type { DifyWorld } from '../../support/world' + +When('I open the account menu', async function (this: DifyWorld) { + const page = this.getPage() + const trigger = page.getByRole('button', { name: 'Account' }) + + await expect(trigger).toBeVisible() + await trigger.click() +}) + +When('I sign out', async function (this: DifyWorld) { + const page = this.getPage() + + await expect(page.getByText('Log out')).toBeVisible() + await page.getByText('Log out').click() +}) + +Then('I should be on the sign-in page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/signin/) + await expect(this.getPage().getByRole('button', { name: /^Sign in$/i })).toBeVisible({ + timeout: 30_000, + }) +}) From 8782787a9e829975d6acd4effd0b98469c65af16 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:40:07 -0700 Subject: [PATCH 31/53] refactor: convert TelemetryCase if/elif to match/case (#3001) (#34797) --- api/enterprise/telemetry/metric_handler.py | 83 +++++++++++----------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/api/enterprise/telemetry/metric_handler.py b/api/enterprise/telemetry/metric_handler.py index ffd9a7e2b5..9cda0bf90a 100644 --- a/api/enterprise/telemetry/metric_handler.py +++ b/api/enterprise/telemetry/metric_handler.py @@ -68,46 +68,49 @@ class EnterpriseMetricHandler: # Route to appropriate handler based on case case = envelope.case - if case == TelemetryCase.APP_CREATED: - self._on_app_created(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "app_created"}) - elif case == TelemetryCase.APP_UPDATED: - self._on_app_updated(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "app_updated"}) - elif case == TelemetryCase.APP_DELETED: - self._on_app_deleted(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "app_deleted"}) - elif case == TelemetryCase.FEEDBACK_CREATED: - self._on_feedback_created(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "feedback_created"}) - elif case == TelemetryCase.MESSAGE_RUN: - self._on_message_run(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "message_run"}) - elif case == TelemetryCase.TOOL_EXECUTION: - self._on_tool_execution(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "tool_execution"}) - elif case == TelemetryCase.MODERATION_CHECK: - self._on_moderation_check(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "moderation_check"}) - elif case == TelemetryCase.SUGGESTED_QUESTION: - self._on_suggested_question(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "suggested_question"}) - elif case == TelemetryCase.DATASET_RETRIEVAL: - self._on_dataset_retrieval(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "dataset_retrieval"}) - elif case == TelemetryCase.GENERATE_NAME: - self._on_generate_name(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "generate_name"}) - elif case == TelemetryCase.PROMPT_GENERATION: - self._on_prompt_generation(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "prompt_generation"}) - else: - logger.warning( - "Unknown telemetry case: %s (tenant_id=%s, event_id=%s)", - case, - envelope.tenant_id, - envelope.event_id, - ) + match case: + case TelemetryCase.APP_CREATED: + self._on_app_created(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "app_created"}) + case TelemetryCase.APP_UPDATED: + self._on_app_updated(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "app_updated"}) + case TelemetryCase.APP_DELETED: + self._on_app_deleted(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "app_deleted"}) + case TelemetryCase.FEEDBACK_CREATED: + self._on_feedback_created(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "feedback_created"}) + case TelemetryCase.MESSAGE_RUN: + self._on_message_run(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "message_run"}) + case TelemetryCase.TOOL_EXECUTION: + self._on_tool_execution(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "tool_execution"}) + case TelemetryCase.MODERATION_CHECK: + self._on_moderation_check(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "moderation_check"}) + case TelemetryCase.SUGGESTED_QUESTION: + self._on_suggested_question(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "suggested_question"}) + case TelemetryCase.DATASET_RETRIEVAL: + self._on_dataset_retrieval(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "dataset_retrieval"}) + case TelemetryCase.GENERATE_NAME: + self._on_generate_name(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "generate_name"}) + case TelemetryCase.PROMPT_GENERATION: + self._on_prompt_generation(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "prompt_generation"}) + case TelemetryCase.WORKFLOW_RUN | TelemetryCase.NODE_EXECUTION | TelemetryCase.DRAFT_NODE_EXECUTION: + pass + case _: + logger.warning( + "Unknown telemetry case: %s (tenant_id=%s, event_id=%s)", + case, + envelope.tenant_id, + envelope.event_id, + ) def _is_duplicate(self, envelope: TelemetryEnvelope) -> bool: """Check if this event has already been processed. From c19a822e1b4982e611c1c9e5fcd9bf7158b2bcc4 Mon Sep 17 00:00:00 2001 From: Jake Armstrong <65635253+jakearmstrong59@users.noreply.github.com> Date: Thu, 9 Apr 2026 06:13:04 +0200 Subject: [PATCH 32/53] refactor: deduplicate DefaultRetrievalModelDict TypedDict into retrieval_service.py (#34758) --- .../dataset_multi_retriever_tool.py | 3 +-- .../dataset_retriever/dataset_retriever_tool.py | 17 ++--------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py b/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py index c72bdf02ed..03e3c5918d 100644 --- a/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py +++ b/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py @@ -7,14 +7,13 @@ from sqlalchemy import select from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler from core.model_manager import ModelManager -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.entities import RetrievalSourceMetadata from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.models.document import Document as RagDocument from core.rag.rerank.rerank_model import RerankModelRunner from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import DatasetRetrieverBaseTool -from core.tools.utils.dataset_retriever.dataset_retriever_tool import DefaultRetrievalModelDict from extensions.ext_database import db from models.dataset import Dataset, Document, DocumentSegment diff --git a/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py b/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py index a346eb53c4..6a189fa6aa 100644 --- a/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py +++ b/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py @@ -1,11 +1,10 @@ -from typing import NotRequired, TypedDict, cast +from typing import cast from pydantic import BaseModel, Field from sqlalchemy import select from core.app.app_config.entities import DatasetRetrieveConfigEntity, ModelConfig -from core.rag.data_post_processor.data_post_processor import RerankingModelDict, WeightsDict -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.entities import DocumentContext, RetrievalSourceMetadata from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.models.document import Document as RetrievalDocument @@ -17,18 +16,6 @@ from models.dataset import Dataset from models.dataset import Document as DatasetDocument from services.external_knowledge_service import ExternalDatasetService - -class DefaultRetrievalModelDict(TypedDict): - search_method: RetrievalMethod - reranking_enable: bool - reranking_model: RerankingModelDict - reranking_mode: NotRequired[str] - weights: NotRequired[WeightsDict | None] - score_threshold: NotRequired[float] - top_k: int - score_threshold_enabled: bool - - default_retrieval_model: DefaultRetrievalModelDict = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, From be1f4b34f88a0802545966162a94ae74caa332e7 Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:42:39 -0500 Subject: [PATCH 33/53] refactor(api): use sessionmaker in workflow & RAG pipeline services (#34805) --- api/services/rag_pipeline/rag_pipeline.py | 6 ++---- api/services/workflow_draft_variable_service.py | 3 +-- api/services/workflow_service.py | 6 ++---- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index b330e1a46a..f6d80f9a6e 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -555,7 +555,7 @@ class RagPipelineService: workflow_node_execution.id ) - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=pipeline.id, @@ -569,7 +569,6 @@ class RagPipelineService: process_data=workflow_node_execution.process_data, outputs=workflow_node_execution.outputs, ) - session.commit() if isinstance(workflow_node_execution_db_model, WorkflowNodeExecutionModel): enqueue_draft_node_execution_trace( execution=workflow_node_execution_db_model, @@ -1325,7 +1324,7 @@ class RagPipelineService: # Convert node_execution to WorkflowNodeExecution after save workflow_node_execution_db_model = repository._to_db_model(workflow_node_execution) # type: ignore - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=pipeline.id, @@ -1339,7 +1338,6 @@ class RagPipelineService: process_data=workflow_node_execution.process_data, outputs=workflow_node_execution.outputs, ) - session.commit() enqueue_draft_node_execution_trace( execution=workflow_node_execution_db_model, outputs=workflow_node_execution.outputs, diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 9ed60bf86b..1c1b94ae9d 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -1075,9 +1075,8 @@ class DraftVariableSaver: ) engine = bind = self._session.get_bind() assert isinstance(engine, Engine) - with Session(bind=engine, expire_on_commit=False) as session: + with sessionmaker(bind=engine, expire_on_commit=False).begin() as session: session.add(variable_file) - session.commit() return truncation_result.result, variable_file diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index eaffb60c63..1e3feeed29 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -837,7 +837,7 @@ class WorkflowService: with sessionmaker(db.engine).begin() as session: outputs = workflow_node_execution.load_full_outputs(session, storage) - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=app_model.id, @@ -848,7 +848,6 @@ class WorkflowService: user=account, ) draft_var_saver.save(process_data=node_execution.process_data, outputs=outputs) - session.commit() enqueue_draft_node_execution_trace( execution=workflow_node_execution, @@ -977,7 +976,7 @@ class WorkflowService: enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config) enclosing_node_id = enclosing_node_type_and_id[1] if enclosing_node_type_and_id else None - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=app_model.id, @@ -988,7 +987,6 @@ class WorkflowService: enclosing_node_id=enclosing_node_id, ) draft_var_saver.save(outputs=outputs, process_data={}) - session.commit() return outputs From a76a8876d14559672daae5f7cf19f8701fcbd43e Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:43:13 -0500 Subject: [PATCH 34/53] refactor(api): use sessionmaker in datasource provider service (#34811) --- api/services/datasource_provider_service.py | 32 +++++++------------ .../test_datasource_provider_service.py | 17 ++++------ 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/api/services/datasource_provider_service.py b/api/services/datasource_provider_service.py index faa978afdc..d5f8cd30bd 100644 --- a/api/services/datasource_provider_service.py +++ b/api/services/datasource_provider_service.py @@ -5,7 +5,7 @@ from typing import Any from graphon.model_runtime.entities.provider_entities import FormType from sqlalchemy import func, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -53,13 +53,12 @@ class DatasourceProviderService: """ remove oauth custom client params """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: session.query(DatasourceOauthTenantParamConfig).filter_by( tenant_id=tenant_id, provider=datasource_provider_id.provider_name, plugin_id=datasource_provider_id.plugin_id, ).delete() - session.commit() def decrypt_datasource_provider_credentials( self, @@ -109,7 +108,7 @@ class DatasourceProviderService: """ get credential by id """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: if credential_id: datasource_provider = ( session.query(DatasourceProvider).filter_by(tenant_id=tenant_id, id=credential_id).first() @@ -156,7 +155,6 @@ class DatasourceProviderService: datasource_provider=datasource_provider, ) datasource_provider.expires_at = refreshed_credentials.expires_at - session.commit() return self.decrypt_datasource_provider_credentials( tenant_id=tenant_id, @@ -174,7 +172,7 @@ class DatasourceProviderService: """ get all datasource credentials by provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: datasource_providers = ( session.query(DatasourceProvider) .filter_by(tenant_id=tenant_id, provider=provider, plugin_id=plugin_id) @@ -224,7 +222,6 @@ class DatasourceProviderService: provider=provider, ) real_credentials_list.append(real_credentials) - session.commit() return real_credentials_list @@ -234,7 +231,7 @@ class DatasourceProviderService: """ update datasource provider name """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: target_provider = ( session.query(DatasourceProvider) .filter_by( @@ -266,7 +263,6 @@ class DatasourceProviderService: raise ValueError("Authorization name is already exists") target_provider.name = name - session.commit() return def set_default_datasource_provider( @@ -275,7 +271,7 @@ class DatasourceProviderService: """ set default datasource provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # get provider target_provider = ( session.query(DatasourceProvider) @@ -300,7 +296,6 @@ class DatasourceProviderService: # set new default provider target_provider.is_default = True - session.commit() return {"result": "success"} def setup_oauth_custom_client_params( @@ -315,7 +310,7 @@ class DatasourceProviderService: """ if client_params is None and enabled is None: return - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: tenant_oauth_client_params = ( session.query(DatasourceOauthTenantParamConfig) .filter_by( @@ -349,7 +344,6 @@ class DatasourceProviderService: if enabled is not None: tenant_oauth_client_params.enabled = enabled - session.commit() def is_system_oauth_params_exist(self, datasource_provider_id: DatasourceProviderID) -> bool: """ @@ -488,7 +482,7 @@ class DatasourceProviderService: """ update datasource oauth provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.OAUTH2.value}" with redis_client.lock(lock, timeout=20): target_provider = ( @@ -535,7 +529,6 @@ class DatasourceProviderService: target_provider.expires_at = expire_at target_provider.encrypted_credentials = credentials target_provider.avatar_url = avatar_url or target_provider.avatar_url - session.commit() def add_datasource_oauth_provider( self, @@ -550,7 +543,7 @@ class DatasourceProviderService: add datasource oauth provider """ credential_type = CredentialType.OAUTH2 - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{credential_type.value}" with redis_client.lock(lock, timeout=60): db_provider_name = name @@ -604,7 +597,6 @@ class DatasourceProviderService: expires_at=expire_at, ) session.add(datasource_provider) - session.commit() def add_datasource_api_key_provider( self, @@ -623,7 +615,7 @@ class DatasourceProviderService: provider_name = provider_id.provider_name plugin_id = provider_id.plugin_id - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.API_KEY}" with redis_client.lock(lock, timeout=20): db_provider_name = name or self.generate_next_datasource_provider_name( @@ -670,7 +662,6 @@ class DatasourceProviderService: encrypted_credentials=credentials, ) session.add(datasource_provider) - session.commit() def extract_secret_variables(self, tenant_id: str, provider_id: str, credential_type: CredentialType) -> list[str]: """ @@ -926,7 +917,7 @@ class DatasourceProviderService: update datasource credentials. """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: datasource_provider = ( session.query(DatasourceProvider) .filter_by(tenant_id=tenant_id, id=auth_id, provider=provider, plugin_id=plugin_id) @@ -980,7 +971,6 @@ class DatasourceProviderService: encrypted_credentials[key] = value datasource_provider.encrypted_credentials = encrypted_credentials - session.commit() def remove_datasource_credentials(self, tenant_id: str, auth_id: str, provider: str, plugin_id: str) -> None: """ diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index bc4120e2af..70ecc158d6 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -40,7 +40,10 @@ class TestDatasourceProviderService: q returns itself for .filter_by(), .order_by(), .where() so any SQLAlchemy chaining pattern works without multiple brittle sub-mocks. """ - with patch("services.datasource_provider_service.Session") as mock_cls: + with ( + patch("services.datasource_provider_service.Session") as mock_cls, + patch("services.datasource_provider_service.sessionmaker") as mock_sm, + ): sess = MagicMock(spec=Session) q = MagicMock() @@ -63,6 +66,8 @@ class TestDatasourceProviderService: mock_cls.return_value.__enter__.return_value = sess mock_cls.return_value.no_autoflush.__enter__.return_value = sess + mock_sm.return_value.begin.return_value.__enter__.return_value = sess + mock_sm.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) yield sess @@ -266,7 +271,6 @@ class TestDatasourceProviderService: patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}), ): service.get_datasource_credentials("t1", "prov", "org/plug") - mock_db_session.commit.assert_called_once() def test_should_return_decrypted_credentials_when_api_key_not_expired(self, service, mock_db_session, mock_user): """API key credentials with expires_at=-1 skip refresh and return directly.""" @@ -333,7 +337,6 @@ class TestDatasourceProviderService: p.name = "same" mock_db_session.query().first.return_value = p service.update_datasource_provider_name("t1", make_id(), "same", "cred-id") - mock_db_session.commit.assert_not_called() def test_should_raise_value_error_when_name_already_exists(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) @@ -352,7 +355,6 @@ class TestDatasourceProviderService: mock_db_session.query().count.return_value = 0 service.update_datasource_provider_name("t1", make_id(), "new_name", "some-id") assert p.name == "new_name" - mock_db_session.commit.assert_called_once() # ----------------------------------------------------------------------- # set_default_datasource_provider (lines 277-303) @@ -370,7 +372,6 @@ class TestDatasourceProviderService: mock_db_session.query().first.return_value = target service.set_default_datasource_provider("t1", make_id(), "new-id") assert target.is_default is True - mock_db_session.commit.assert_called_once() # ----------------------------------------------------------------------- # get_oauth_encrypter (lines 404-420) @@ -460,7 +461,6 @@ class TestDatasourceProviderService: with patch.object(service, "extract_secret_variables", return_value=[]): service.add_datasource_oauth_provider("new", "t1", make_id(), "http://cb", 9999, {}) mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() def test_should_auto_rename_when_oauth_provider_name_conflicts(self, service, mock_db_session): """Conflict on name results in auto-incremented name, not an error.""" @@ -512,7 +512,6 @@ class TestDatasourceProviderService: mock_db_session.query().count.return_value = 0 with patch.object(service, "extract_secret_variables", return_value=[]): service.reauthorize_datasource_oauth_provider("n", "t1", make_id(), "u", 1, {}, "oid") - mock_db_session.commit.assert_called_once() def test_should_auto_rename_when_reauth_name_conflicts(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) @@ -523,7 +522,6 @@ class TestDatasourceProviderService: service.reauthorize_datasource_oauth_provider( "conflict_name", "t1", make_id(), "u", 9999, {"tok": "v"}, "cred-id" ) - mock_db_session.commit.assert_called_once() def test_should_encrypt_secret_fields_when_reauthorizing(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) @@ -571,7 +569,6 @@ class TestDatasourceProviderService: ): service.add_datasource_api_key_provider(None, "t1", make_id(), {"sk": "v"}) mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() def test_should_acquire_redis_lock_when_adding_api_key_provider(self, service, mock_db_session, mock_user): mock_db_session.query().count.return_value = 0 @@ -747,7 +744,6 @@ class TestDatasourceProviderService: # encrypter must have been called with the new secret value self._enc.encrypt_token.assert_called() # commit must be called exactly once - mock_db_session.commit.assert_called_once() # ----------------------------------------------------------------------- # remove_datasource_credentials (lines 980-997) @@ -758,7 +754,6 @@ class TestDatasourceProviderService: mock_db_session.scalar.return_value = p service.remove_datasource_credentials("t1", "id", "prov", "org/plug") mock_db_session.delete.assert_called_once_with(p) - mock_db_session.commit.assert_called_once() def test_should_do_nothing_when_credential_not_found_on_remove(self, service, mock_db_session): """No error raised; no delete called when record doesn't exist (lines 994 branch).""" From f5ea61e93ed53755000694151744a8b534c163ab Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:44:13 -0500 Subject: [PATCH 35/53] refactor: migrate session.query to select API in document indexing sync task (#34813) --- api/tasks/document_indexing_sync_task.py | 16 +++++++++------- .../tasks/test_document_indexing_sync_task.py | 11 +++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/api/tasks/document_indexing_sync_task.py b/api/tasks/document_indexing_sync_task.py index f99e90062f..90c80be3a1 100644 --- a/api/tasks/document_indexing_sync_task.py +++ b/api/tasks/document_indexing_sync_task.py @@ -32,7 +32,9 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): tenant_id = None with session_factory.create_session() as session, session.begin(): - document = session.query(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).first() + document = session.scalar( + select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) + ) if not document: logger.info(click.style(f"Document not found: {document_id}", fg="red")) @@ -42,7 +44,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): logger.info(click.style(f"Document {document_id} is already being processed, skipping", fg="yellow")) return - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Dataset not found") @@ -87,7 +89,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): ) with session_factory.create_session() as session, session.begin(): - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if document: document.indexing_status = IndexingStatus.ERROR document.error = "Datasource credential not found. Please reconnect your Notion workspace." @@ -112,7 +114,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): try: index_processor = IndexProcessorFactory(index_type).init_index_processor() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if dataset: index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) logger.info(click.style(f"Cleaned vector index for document {document_id}", fg="green")) @@ -120,7 +122,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): logger.exception("Failed to clean vector index for document %s", document_id) with session_factory.create_session() as session, session.begin(): - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if not document: logger.warning(click.style(f"Document {document_id} not found during sync", fg="yellow")) return @@ -140,7 +142,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): try: indexing_runner = IndexingRunner() with session_factory.create_session() as session: - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if document: indexing_runner.run([document]) end_at = time.perf_counter() @@ -150,7 +152,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): except Exception as e: logger.exception("document_indexing_sync_task failed for document_id: %s", document_id) with session_factory.create_session() as session, session.begin(): - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if document: document.indexing_status = IndexingStatus.ERROR document.error = str(e) diff --git a/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py b/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py index f49f4535af..41d3068a10 100644 --- a/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py +++ b/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py @@ -80,7 +80,7 @@ def mock_db_session(mock_document, mock_dataset): with patch("tasks.document_indexing_sync_task.session_factory", autospec=True) as mock_session_factory: session = MagicMock() session.scalars.return_value.all.return_value = [] - session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset] + session.scalar.side_effect = [mock_document, mock_dataset] begin_cm = MagicMock() begin_cm.__enter__.return_value = session @@ -242,14 +242,13 @@ class TestDataSourceInfoSerialization: # DB session mock — shared across all ``session_factory.create_session()`` calls session = MagicMock() session.scalars.return_value.all.return_value = [] - # .where() path: session 1 reads document + dataset, session 2 reads dataset - session.query.return_value.where.return_value.first.side_effect = [ + # All .first() calls are now session.scalar() — ordered by call sequence: + # session 1: document + dataset, session 2: dataset (clean), session 3: document (update), + # session 4: document (indexing) + session.scalar.side_effect = [ mock_document, mock_dataset, mock_dataset, - ] - # .filter_by() path: session 3 (update), session 4 (indexing) - session.query.return_value.filter_by.return_value.first.side_effect = [ mock_document, mock_document, ] From b5acc8e3925000ff3755474896a84a7082541e49 Mon Sep 17 00:00:00 2001 From: aliworksx08 <57456290+aliworksx08@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:44:49 -0500 Subject: [PATCH 36/53] refactor: migrate session.query to select API in core tools (#34814) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/tools/tool_file_manager.py | 33 +++---------------- api/core/tools/workflow_as_tool/provider.py | 13 ++++---- .../core/tools/test_tool_file_manager.py | 26 ++++----------- .../tools/workflow_as_tool/test_provider.py | 8 ++--- 4 files changed, 23 insertions(+), 57 deletions(-) diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index 7ac29cf069..a59d167a0a 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -11,6 +11,7 @@ from uuid import uuid4 import httpx from graphon.file import File, FileTransferMethod, get_file_type_by_mime_type +from sqlalchemy import select from configs import dify_config from core.db.session_factory import session_factory @@ -166,13 +167,7 @@ class ToolFileManager: :return: the binary of the file, mime type """ with session_factory.create_session() as session: - tool_file: ToolFile | None = ( - session.query(ToolFile) - .where( - ToolFile.id == id, - ) - .first() - ) + tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == id).limit(1)) if not tool_file: return None @@ -190,13 +185,7 @@ class ToolFileManager: :return: the binary of the file, mime type """ with session_factory.create_session() as session: - message_file: MessageFile | None = ( - session.query(MessageFile) - .where( - MessageFile.id == id, - ) - .first() - ) + message_file: MessageFile | None = session.scalar(select(MessageFile).where(MessageFile.id == id).limit(1)) # Check if message_file is not None if message_file is not None: @@ -210,13 +199,7 @@ class ToolFileManager: else: tool_file_id = None - tool_file: ToolFile | None = ( - session.query(ToolFile) - .where( - ToolFile.id == tool_file_id, - ) - .first() - ) + tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == tool_file_id).limit(1)) if not tool_file: return None @@ -234,13 +217,7 @@ class ToolFileManager: :return: the binary of the file, mime type """ with session_factory.create_session() as session: - tool_file: ToolFile | None = ( - session.query(ToolFile) - .where( - ToolFile.id == tool_file_id, - ) - .first() - ) + tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == tool_file_id).limit(1)) if not tool_file: return None, None diff --git a/api/core/tools/workflow_as_tool/provider.py b/api/core/tools/workflow_as_tool/provider.py index f48b24be30..a01004448a 100644 --- a/api/core/tools/workflow_as_tool/provider.py +++ b/api/core/tools/workflow_as_tool/provider.py @@ -4,6 +4,7 @@ from collections.abc import Mapping from graphon.variables.input_entities import VariableEntity, VariableEntityType from pydantic import Field +from sqlalchemy import select from sqlalchemy.orm import Session from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager @@ -96,10 +97,10 @@ class WorkflowToolProviderController(ToolProviderController): :param app: the app :return: the tool """ - workflow: Workflow | None = ( - session.query(Workflow) + workflow: Workflow | None = session.scalar( + select(Workflow) .where(Workflow.app_id == db_provider.app_id, Workflow.version == db_provider.version) - .first() + .limit(1) ) if not workflow: @@ -217,13 +218,13 @@ class WorkflowToolProviderController(ToolProviderController): return self.tools with Session(db.engine, expire_on_commit=False) as session, session.begin(): - db_provider: WorkflowToolProvider | None = ( - session.query(WorkflowToolProvider) + db_provider: WorkflowToolProvider | None = session.scalar( + select(WorkflowToolProvider) .where( WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == self.provider_id, ) - .first() + .limit(1) ) if not db_provider: diff --git a/api/tests/unit_tests/core/tools/test_tool_file_manager.py b/api/tests/unit_tests/core/tools/test_tool_file_manager.py index 7fcebde3c5..2889cb9db1 100644 --- a/api/tests/unit_tests/core/tools/test_tool_file_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_file_manager.py @@ -129,7 +129,7 @@ def test_get_file_binary_returns_none_when_not_found() -> None: # Arrange manager = ToolFileManager() session = Mock() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None # Act with _patch_session_factory(session): @@ -144,7 +144,7 @@ def test_get_file_binary_returns_bytes_when_found() -> None: manager = ToolFileManager() tool_file = SimpleNamespace(file_key="k1", mimetype="text/plain") session = Mock() - session.query.return_value.where.return_value.first.return_value = tool_file + session.scalar.return_value = tool_file # Act with patch("core.tools.tool_file_manager.storage") as storage: @@ -160,11 +160,7 @@ def test_get_file_binary_by_message_file_id_when_messagefile_missing() -> None: # Arrange manager = ToolFileManager() session = Mock() - first_query = Mock() - second_query = Mock() - first_query.where.return_value.first.return_value = None - second_query.where.return_value.first.return_value = None - session.query.side_effect = [first_query, second_query] + session.scalar.side_effect = [None, None] # Act with _patch_session_factory(session): @@ -179,11 +175,7 @@ def test_get_file_binary_by_message_file_id_when_url_is_none() -> None: manager = ToolFileManager() message_file = SimpleNamespace(url=None) session = Mock() - first_query = Mock() - second_query = Mock() - first_query.where.return_value.first.return_value = message_file - second_query.where.return_value.first.return_value = None - session.query.side_effect = [first_query, second_query] + session.scalar.side_effect = [message_file, None] # Act with _patch_session_factory(session): @@ -199,11 +191,7 @@ def test_get_file_binary_by_message_file_id_returns_bytes_when_found() -> None: message_file = SimpleNamespace(url="https://x/files/tools/tool123.png") tool_file = SimpleNamespace(file_key="k2", mimetype="image/png") session = Mock() - first_query = Mock() - second_query = Mock() - first_query.where.return_value.first.return_value = message_file - second_query.where.return_value.first.return_value = tool_file - session.query.side_effect = [first_query, second_query] + session.scalar.side_effect = [message_file, tool_file] # Act with patch("core.tools.tool_file_manager.storage") as storage: @@ -219,7 +207,7 @@ def test_get_file_generator_returns_none_when_toolfile_missing() -> None: # Arrange manager = ToolFileManager() session = Mock() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None # Act with _patch_session_factory(session): @@ -242,7 +230,7 @@ def test_get_file_generator_returns_stream_when_found() -> None: size=12, ) session = Mock() - session.query.return_value.where.return_value.first.return_value = tool_file + session.scalar.return_value = tool_file # Act with patch("core.tools.tool_file_manager.storage") as storage: diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py index 2607861b59..4767480a5a 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py @@ -43,7 +43,7 @@ def test_get_db_provider_tool_builds_entity(): controller = _controller() session = Mock() workflow = SimpleNamespace(graph_dict={"nodes": []}, features_dict={}) - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow app = SimpleNamespace(id="app-1") db_provider = SimpleNamespace( id="provider-1", @@ -136,7 +136,7 @@ def test_from_db_builds_controller(): parameter_configurations=[], ) session = _mock_session_with_begin() - session.query.return_value.where.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider session.get.side_effect = [app, user] fake_cm = MagicMock() fake_cm.__enter__.return_value = session @@ -163,7 +163,7 @@ def test_get_tools_returns_empty_when_provider_missing(): mock_db.engine = object() with patch("core.tools.workflow_as_tool.provider.Session") as session_cls: session = _mock_session_with_begin() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None session_cls.return_value.__enter__.return_value = session assert controller.get_tools("tenant-1") == [] @@ -189,7 +189,7 @@ def test_get_tools_raises_when_app_missing(): mock_db.engine = object() with patch("core.tools.workflow_as_tool.provider.Session") as session_cls: session = _mock_session_with_begin() - session.query.return_value.where.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider session.get.return_value = None session_cls.return_value.__enter__.return_value = session with pytest.raises(ValueError, match="app not found"): From e3cc4b83c878747c56d2ee95a2022abb5be2d431 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:46:36 -0500 Subject: [PATCH 37/53] refactor: migrate session.query to select API in clean dataset task (#34815) --- api/tasks/clean_dataset_task.py | 30 +++++++++++-------- .../tasks/test_clean_dataset_task.py | 27 ++++------------- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/api/tasks/clean_dataset_task.py b/api/tasks/clean_dataset_task.py index 0d51a743ad..377d0e5cc7 100644 --- a/api/tasks/clean_dataset_task.py +++ b/api/tasks/clean_dataset_task.py @@ -112,7 +112,9 @@ def clean_dataset_task( segment_ids = [segment.id for segment in segments] for segment in segments: image_upload_file_ids = get_image_upload_file_ids(segment.content) - image_files = session.query(UploadFile).where(UploadFile.id.in_(image_upload_file_ids)).all() + image_files = session.scalars( + select(UploadFile).where(UploadFile.id.in_(image_upload_file_ids)) + ).all() for image_file in image_files: if image_file is None: continue @@ -150,20 +152,22 @@ def clean_dataset_task( ) session.execute(binding_delete_stmt) - session.query(DatasetProcessRule).where(DatasetProcessRule.dataset_id == dataset_id).delete() - session.query(DatasetQuery).where(DatasetQuery.dataset_id == dataset_id).delete() - session.query(AppDatasetJoin).where(AppDatasetJoin.dataset_id == dataset_id).delete() + session.execute(delete(DatasetProcessRule).where(DatasetProcessRule.dataset_id == dataset_id)) + session.execute(delete(DatasetQuery).where(DatasetQuery.dataset_id == dataset_id)) + session.execute(delete(AppDatasetJoin).where(AppDatasetJoin.dataset_id == dataset_id)) # delete dataset metadata - session.query(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset_id).delete() - session.query(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset_id).delete() + session.execute(delete(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset_id)) + session.execute(delete(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset_id)) # delete pipeline and workflow if pipeline_id: - session.query(Pipeline).where(Pipeline.id == pipeline_id).delete() - session.query(Workflow).where( - Workflow.tenant_id == tenant_id, - Workflow.app_id == pipeline_id, - Workflow.type == WorkflowType.RAG_PIPELINE, - ).delete() + session.execute(delete(Pipeline).where(Pipeline.id == pipeline_id)) + session.execute( + delete(Workflow).where( + Workflow.tenant_id == tenant_id, + Workflow.app_id == pipeline_id, + Workflow.type == WorkflowType.RAG_PIPELINE, + ) + ) # delete files if documents: file_ids = [] @@ -174,7 +178,7 @@ def clean_dataset_task( if data_source_info and "upload_file_id" in data_source_info: file_id = data_source_info["upload_file_id"] file_ids.append(file_id) - files = session.query(UploadFile).where(UploadFile.id.in_(file_ids)).all() + files = session.scalars(select(UploadFile).where(UploadFile.id.in_(file_ids))).all() for file in files: storage.delete(file.key) diff --git a/api/tests/unit_tests/tasks/test_clean_dataset_task.py b/api/tests/unit_tests/tasks/test_clean_dataset_task.py index 936a10d6c5..b4332334ab 100644 --- a/api/tests/unit_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/unit_tests/tasks/test_clean_dataset_task.py @@ -60,12 +60,6 @@ def mock_db_session(): cm.__exit__.return_value = None mock_sf.create_session.return_value = cm - # Setup query chain - mock_query = MagicMock() - mock_session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.delete.return_value = 0 - # Setup scalars for select queries mock_session.scalars.return_value.all.return_value = [] @@ -220,11 +214,6 @@ class TestPipelineAndWorkflowDeletion: - Pipeline record is deleted - Related workflow record is deleted """ - # Arrange - mock_query = mock_db_session.session.query.return_value - mock_query.where.return_value = mock_query - mock_query.delete.return_value = 1 - # Act clean_dataset_task( dataset_id=dataset_id, @@ -236,9 +225,9 @@ class TestPipelineAndWorkflowDeletion: pipeline_id=pipeline_id, ) - # Assert - verify delete was called for pipeline-related queries - # The actual count depends on total queries, but pipeline deletion should add 2 more - assert mock_query.delete.call_count >= 7 # 5 base + 2 pipeline/workflow + # Assert - verify execute was called for delete operations + # 1 attachment JOIN query + 5 base deletes + 2 pipeline/workflow deletes = 8 + assert mock_db_session.session.execute.call_count >= 8 def test_clean_dataset_task_without_pipeline_id( self, @@ -256,11 +245,6 @@ class TestPipelineAndWorkflowDeletion: Expected behavior: - Pipeline and workflow deletion queries are not executed """ - # Arrange - mock_query = mock_db_session.session.query.return_value - mock_query.where.return_value = mock_query - mock_query.delete.return_value = 1 - # Act clean_dataset_task( dataset_id=dataset_id, @@ -272,8 +256,9 @@ class TestPipelineAndWorkflowDeletion: pipeline_id=None, ) - # Assert - verify delete was called only for base queries (5 times) - assert mock_query.delete.call_count == 5 + # Assert - verify execute was called for delete operations + # 1 attachment JOIN query + 5 base deletes = 6 + assert mock_db_session.session.execute.call_count == 6 # ============================================================================ From 5f53748d074f80802bd840dade44f2e8142eefdd Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:48:40 -0700 Subject: [PATCH 38/53] refactor: convert ToolProviderType if/elif to match/case (#30001) (#34794) --- api/core/tools/entities/api_entities.py | 35 +++++++++++-------- api/services/tools/tools_transform_service.py | 34 ++++++++++-------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index d5d3d1b1d9..410ec72baf 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -75,22 +75,27 @@ class ToolProviderApiEntity(BaseModel): parameter.pop("input_schema", None) # ------------- optional_fields = self.optional_field("server_url", self.server_url) - if self.type == ToolProviderType.MCP: - optional_fields.update(self.optional_field("updated_at", self.updated_at)) - optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) - optional_fields.update( - self.optional_field( - "configuration", self.configuration.model_dump() if self.configuration else MCPConfiguration() + match self.type: + case ToolProviderType.MCP: + optional_fields.update(self.optional_field("updated_at", self.updated_at)) + optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) + optional_fields.update( + self.optional_field( + "configuration", self.configuration.model_dump() if self.configuration else MCPConfiguration() + ) ) - ) - optional_fields.update( - self.optional_field("authentication", self.authentication.model_dump() if self.authentication else None) - ) - optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration)) - optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) - optional_fields.update(self.optional_field("original_headers", self.original_headers)) - elif self.type == ToolProviderType.WORKFLOW: - optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id)) + optional_fields.update( + self.optional_field( + "authentication", self.authentication.model_dump() if self.authentication else None + ) + ) + optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration)) + optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) + optional_fields.update(self.optional_field("original_headers", self.original_headers)) + case ToolProviderType.WORKFLOW: + optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id)) + case _: + pass return { "id": self.id, "author": self.author, diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index b24f001133..4fd2ea1628 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -48,21 +48,25 @@ class ToolTransformService: URL(dify_config.CONSOLE_API_URL or "/") / "console" / "api" / "workspaces" / "current" / "tool-provider" ) - if provider_type == ToolProviderType.BUILT_IN: - return str(url_prefix / "builtin" / provider_name / "icon") - elif provider_type in {ToolProviderType.API, ToolProviderType.WORKFLOW}: - try: - if isinstance(icon, str): - parsed = emoji_icon_adapter.validate_json(icon) - return {"background": parsed["background"], "content": parsed["content"]} - return {"background": icon["background"], "content": icon["content"]} - except (ValueError, ValidationError, KeyError): - return {"background": "#252525", "content": "\ud83d\ude01"} - elif provider_type == ToolProviderType.MCP: - if isinstance(icon, Mapping): - return {"background": icon.get("background", ""), "content": icon.get("content", "")} - return icon - return "" + match provider_type: + case ToolProviderType.BUILT_IN: + return str(url_prefix / "builtin" / provider_name / "icon") + case ToolProviderType.API | ToolProviderType.WORKFLOW: + try: + if isinstance(icon, str): + parsed = emoji_icon_adapter.validate_json(icon) + return {"background": parsed["background"], "content": parsed["content"]} + return {"background": icon["background"], "content": icon["content"]} + except (ValueError, ValidationError, KeyError): + return {"background": "#252525", "content": "\ud83d\ude01"} + case ToolProviderType.MCP: + if isinstance(icon, Mapping): + return {"background": icon.get("background", ""), "content": icon.get("content", "")} + return icon + case ToolProviderType.PLUGIN | ToolProviderType.APP | ToolProviderType.DATASET_RETRIEVAL: + return "" + case _: + return "" @staticmethod def repack_provider(tenant_id: str, provider: Union[dict, ToolProviderApiEntity, PluginDatasourceProviderEntity]): From d360929af1687439e0483e6f103d56a1585c86aa Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:49:03 -0500 Subject: [PATCH 39/53] refactor(api): use sessionmaker in pgvecto_rs VDB service (#34818) --- .../datasource/vdb/pgvecto_rs/pgvecto_rs.py | 20 ++++----- .../vdb/pgvecto_rs/test_pgvecto_rs.py | 41 +++++++++++++++---- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py b/api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py index 90d9173409..387e918c76 100644 --- a/api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py +++ b/api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, model_validator from sqlalchemy import Float, create_engine, insert, select, text from sqlalchemy import text as sql_text from sqlalchemy.dialects import postgresql -from sqlalchemy.orm import Mapped, Session, mapped_column +from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker from configs import dify_config from core.rag.datasource.vdb.pgvecto_rs.collection import CollectionORM @@ -55,9 +55,8 @@ class PGVectoRS(BaseVector): f"postgresql+psycopg2://{config.user}:{config.password}@{config.host}:{config.port}/{config.database}" ) self._client = create_engine(self._url) - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: session.execute(text("CREATE EXTENSION IF NOT EXISTS vectors")) - session.commit() self._fields: list[str] = [] class _Table(CollectionORM): @@ -88,7 +87,7 @@ class PGVectoRS(BaseVector): if redis_client.get(collection_exist_cache_key): return index_name = f"{self._collection_name}_embedding_index" - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: create_statement = sql_text(f""" CREATE TABLE IF NOT EXISTS {self._collection_name} ( id UUID PRIMARY KEY, @@ -111,12 +110,11 @@ class PGVectoRS(BaseVector): $$); """) session.execute(index_statement) - session.commit() redis_client.set(collection_exist_cache_key, 1, ex=3600) def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): pks = [] - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: for document, embedding in zip(documents, embeddings): pk = uuid4() session.execute( @@ -128,7 +126,6 @@ class PGVectoRS(BaseVector): ), ) pks.append(pk) - session.commit() return pks @@ -145,10 +142,9 @@ class PGVectoRS(BaseVector): def delete_by_metadata_field(self, key: str, value: str): ids = self.get_ids_by_metadata_field(key, value) if ids: - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: select_statement = sql_text(f"DELETE FROM {self._collection_name} WHERE id = ANY(:ids)") session.execute(select_statement, {"ids": ids}) - session.commit() def delete_by_ids(self, ids: list[str]): with Session(self._client) as session: @@ -159,15 +155,13 @@ class PGVectoRS(BaseVector): if result: ids = [item[0] for item in result] if ids: - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: select_statement = sql_text(f"DELETE FROM {self._collection_name} WHERE id = ANY(:ids)") session.execute(select_statement, {"ids": ids}) - session.commit() def delete(self): - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: session.execute(sql_text(f"DROP TABLE IF EXISTS {self._collection_name}")) - session.commit() def text_exists(self, id: str) -> bool: with Session(self._client) as session: diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py b/api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py index 1aec81b8ac..5b9ec8002a 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py @@ -53,6 +53,31 @@ def _session_factory(calls, execute_results=None): return _session +class _FakeBeginContext: + def __init__(self, session): + self._session = session + + def __enter__(self): + return self._session + + def __exit__(self, exc_type, exc, tb): + return None + + +def _sessionmaker_factory(calls, execute_results=None): + def _sessionmaker(*args, **kwargs): + session = _FakeSessionContext(calls=calls, execute_results=execute_results) + return MagicMock(begin=MagicMock(return_value=_FakeBeginContext(session))) + + return _sessionmaker + + +def _patch_both(monkeypatch, module, calls, execute_results=None): + """Patch both Session and sessionmaker on the module with the same call tracker.""" + monkeypatch.setattr(module, "Session", _session_factory(calls, execute_results)) + monkeypatch.setattr(module, "sessionmaker", _sessionmaker_factory(calls, execute_results)) + + @pytest.fixture def pgvecto_module(monkeypatch): for name, module in _build_fake_pgvecto_modules().items(): @@ -105,7 +130,7 @@ def test_init_get_type_and_create_delegate(pgvecto_module, monkeypatch): module, _ = pgvecto_module session_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(session_calls)) + _patch_both(monkeypatch, module, session_calls) vector = module.PGVectoRS("collection_1", _config(module), dim=3) vector.create_collection = MagicMock() @@ -124,7 +149,7 @@ def test_create_collection_cache_and_sql_execution(pgvecto_module, monkeypatch): module, _ = pgvecto_module session_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(session_calls)) + _patch_both(monkeypatch, module, session_calls) lock = MagicMock() lock.__enter__.return_value = None @@ -151,10 +176,10 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): execute_results = [SimpleNamespace(fetchall=lambda: [("id-1",), ("id-2",)]), SimpleNamespace(fetchall=lambda: [])] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(init_calls)) + _patch_both(monkeypatch, module, init_calls) vector = module.PGVectoRS("collection_1", _config(module), dim=3) - monkeypatch.setattr(module, "Session", _session_factory(runtime_calls, execute_results=list(execute_results))) + _patch_both(monkeypatch, module, runtime_calls, execute_results=list(execute_results)) class _InsertBuilder: def __init__(self, table): @@ -179,6 +204,7 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): "Session", _session_factory(runtime_calls, execute_results=[SimpleNamespace(fetchall=lambda: [("id-1",), ("id-2",)])]), ) + monkeypatch.setattr(module, "sessionmaker", _sessionmaker_factory(runtime_calls)) assert vector.get_ids_by_metadata_field("document_id", "doc-1") == ["id-1", "id-2"] monkeypatch.setattr( @@ -204,12 +230,13 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): ], ), ) + monkeypatch.setattr(module, "sessionmaker", _sessionmaker_factory(runtime_calls)) vector.delete_by_ids(["doc-1"]) assert any("meta->>'doc_id' = ANY (:doc_ids)" in str(args[0]) for args, _ in runtime_calls) assert any("DELETE FROM collection_1 WHERE id = ANY(:ids)" in str(args[0]) for args, _ in runtime_calls) runtime_calls.clear() - monkeypatch.setattr(module, "Session", _session_factory(runtime_calls, execute_results=[MagicMock()])) + _patch_both(monkeypatch, module, runtime_calls, execute_results=[MagicMock()]) vector.delete() assert any("DROP TABLE IF EXISTS collection_1" in str(args[0]) for args, _ in runtime_calls) @@ -218,7 +245,7 @@ def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch): module, _ = pgvecto_module init_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(init_calls)) + _patch_both(monkeypatch, module, init_calls) vector = module.PGVectoRS("collection_1", _config(module), dim=3) runtime_calls = [] @@ -277,7 +304,7 @@ def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch): (SimpleNamespace(meta={"doc_id": "1"}, text="text-1"), 0.1), (SimpleNamespace(meta={"doc_id": "2"}, text="text-2"), 0.8), ] - monkeypatch.setattr(module, "Session", _session_factory(runtime_calls, execute_results=[rows])) + _patch_both(monkeypatch, module, runtime_calls, execute_results=[rows]) docs = vector.search_by_vector([0.1, 0.2], top_k=2, score_threshold=0.5, document_ids_filter=["d-1"]) assert len(docs) == 1 From ee789db443642565cfa2e3c38f58680adef30bc3 Mon Sep 17 00:00:00 2001 From: aliworksx08 <57456290+aliworksx08@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:49:59 -0500 Subject: [PATCH 40/53] refactor: migrate session.query to select API in plugin services (#34817) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../plugin/plugin_auto_upgrade_service.py | 19 ++++++------ .../plugin/plugin_permission_service.py | 9 ++++-- .../test_plugin_auto_upgrade_service.py | 29 ++++++++++--------- .../plugin/test_plugin_permission_service.py | 10 +++---- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/api/services/plugin/plugin_auto_upgrade_service.py b/api/services/plugin/plugin_auto_upgrade_service.py index adbed87c3c..a58bede8db 100644 --- a/api/services/plugin/plugin_auto_upgrade_service.py +++ b/api/services/plugin/plugin_auto_upgrade_service.py @@ -1,3 +1,4 @@ +from sqlalchemy import select from sqlalchemy.orm import sessionmaker from extensions.ext_database import db @@ -8,10 +9,10 @@ class PluginAutoUpgradeService: @staticmethod def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None: with sessionmaker(bind=db.engine).begin() as session: - return ( - session.query(TenantPluginAutoUpgradeStrategy) + return session.scalar( + select(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .first() + .limit(1) ) @staticmethod @@ -24,10 +25,10 @@ class PluginAutoUpgradeService: include_plugins: list[str], ) -> bool: with sessionmaker(bind=db.engine).begin() as session: - exist_strategy = ( - session.query(TenantPluginAutoUpgradeStrategy) + exist_strategy = session.scalar( + select(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .first() + .limit(1) ) if not exist_strategy: strategy = TenantPluginAutoUpgradeStrategy( @@ -51,10 +52,10 @@ class PluginAutoUpgradeService: @staticmethod def exclude_plugin(tenant_id: str, plugin_id: str) -> bool: with sessionmaker(bind=db.engine).begin() as session: - exist_strategy = ( - session.query(TenantPluginAutoUpgradeStrategy) + exist_strategy = session.scalar( + select(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .first() + .limit(1) ) if not exist_strategy: # create for this tenant diff --git a/api/services/plugin/plugin_permission_service.py b/api/services/plugin/plugin_permission_service.py index 55276d6f99..0d2a70acbd 100644 --- a/api/services/plugin/plugin_permission_service.py +++ b/api/services/plugin/plugin_permission_service.py @@ -1,3 +1,4 @@ +from sqlalchemy import select from sqlalchemy.orm import sessionmaker from extensions.ext_database import db @@ -8,7 +9,9 @@ class PluginPermissionService: @staticmethod def get_permission(tenant_id: str) -> TenantPluginPermission | None: with sessionmaker(bind=db.engine).begin() as session: - return session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).first() + return session.scalar( + select(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).limit(1) + ) @staticmethod def change_permission( @@ -17,8 +20,8 @@ class PluginPermissionService: debug_permission: TenantPluginPermission.DebugPermission, ): with sessionmaker(bind=db.engine).begin() as session: - permission = ( - session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).first() + permission = session.scalar( + select(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).limit(1) ) if not permission: permission = TenantPluginPermission( diff --git a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py index 45156958b6..bc2f1c6ecc 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py @@ -20,7 +20,7 @@ class TestGetStrategy: def test_returns_strategy_when_found(self): p1, p2, session = _patched_session() strategy = MagicMock() - session.query.return_value.where.return_value.first.return_value = strategy + session.scalar.return_value = strategy with p1, p2: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService @@ -31,7 +31,7 @@ class TestGetStrategy: def test_returns_none_when_not_found(self): p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None with p1, p2: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService @@ -44,9 +44,9 @@ class TestGetStrategy: class TestChangeStrategy: def test_creates_new_strategy(self): p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.return_value = MagicMock() from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService @@ -65,7 +65,7 @@ class TestChangeStrategy: def test_updates_existing_strategy(self): p1, p2, session = _patched_session() existing = MagicMock() - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing with p1, p2: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService @@ -90,11 +90,12 @@ class TestChangeStrategy: class TestExcludePlugin: def test_creates_default_strategy_when_none_exists(self): p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None with ( p1, p2, + patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls, patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs, ): @@ -113,9 +114,9 @@ class TestExcludePlugin: existing = MagicMock() existing.upgrade_mode = "exclude" existing.exclude_plugins = ["p-existing"] - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" @@ -131,9 +132,9 @@ class TestExcludePlugin: existing = MagicMock() existing.upgrade_mode = "partial" existing.include_plugins = ["p1", "p2"] - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" @@ -148,9 +149,9 @@ class TestExcludePlugin: p1, p2, session = _patched_session() existing = MagicMock() existing.upgrade_mode = "all" - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" @@ -167,9 +168,9 @@ class TestExcludePlugin: existing = MagicMock() existing.upgrade_mode = "exclude" existing.exclude_plugins = ["p1"] - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" diff --git a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py index 40f4c6a8d2..20f132c015 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py @@ -20,7 +20,7 @@ class TestGetPermission: def test_returns_permission_when_found(self): p1, p2, session = _patched_session() permission = MagicMock() - session.query.return_value.where.return_value.first.return_value = permission + session.scalar.return_value = permission with p1, p2: from services.plugin.plugin_permission_service import PluginPermissionService @@ -31,7 +31,7 @@ class TestGetPermission: def test_returns_none_when_not_found(self): p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None with p1, p2: from services.plugin.plugin_permission_service import PluginPermissionService @@ -44,9 +44,9 @@ class TestGetPermission: class TestChangePermission: def test_creates_new_permission_when_not_exists(self): p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None - with p1, p2, patch(f"{MODULE}.TenantPluginPermission") as perm_cls: + with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginPermission") as perm_cls: perm_cls.return_value = MagicMock() from services.plugin.plugin_permission_service import PluginPermissionService @@ -59,7 +59,7 @@ class TestChangePermission: def test_updates_existing_permission(self): p1, p2, session = _patched_session() existing = MagicMock() - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing with p1, p2: from services.plugin.plugin_permission_service import PluginPermissionService From 9a51c2f56ab189e69d91c606a40792fd3f620955 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:50:59 -0500 Subject: [PATCH 41/53] refactor: migrate session.query to select API in deal dataset vector index task (#34819) --- api/tasks/deal_dataset_vector_index_task.py | 54 ++++++++++++--------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/api/tasks/deal_dataset_vector_index_task.py b/api/tasks/deal_dataset_vector_index_task.py index 0047e04a17..36605359dc 100644 --- a/api/tasks/deal_dataset_vector_index_task.py +++ b/api/tasks/deal_dataset_vector_index_task.py @@ -3,7 +3,7 @@ import time import click from celery import shared_task -from sqlalchemy import select +from sqlalchemy import select, update from core.db.session_factory import session_factory from core.rag.index_processor.constant.doc_type import DocType @@ -29,7 +29,7 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): with session_factory.create_session() as session: try: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Dataset not found") @@ -49,23 +49,24 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): if dataset_documents: dataset_documents_ids = [doc.id for doc in dataset_documents] - session.query(DatasetDocument).where(DatasetDocument.id.in_(dataset_documents_ids)).update( - {"indexing_status": "indexing"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id.in_(dataset_documents_ids)) + .values(indexing_status="indexing") ) session.commit() for dataset_document in dataset_documents: try: # add from vector index - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if segments: documents = [] for segment in segments: @@ -82,13 +83,17 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): documents.append(document) # save vector index index_processor.load(dataset, documents, with_keywords=False) - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "completed"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="completed") ) session.commit() except Exception as e: - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "error", "error": str(e)}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="error", error=str(e)) ) session.commit() elif action == "update": @@ -104,8 +109,10 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): if dataset_documents: # update document status dataset_documents_ids = [doc.id for doc in dataset_documents] - session.query(DatasetDocument).where(DatasetDocument.id.in_(dataset_documents_ids)).update( - {"indexing_status": "indexing"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id.in_(dataset_documents_ids)) + .values(indexing_status="indexing") ) session.commit() @@ -115,15 +122,14 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): for dataset_document in dataset_documents: # update from vector index try: - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if segments: documents = [] multimodal_documents = [] @@ -172,13 +178,17 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): index_processor.load( dataset, documents, multimodal_documents=multimodal_documents, with_keywords=False ) - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "completed"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="completed") ) session.commit() except Exception as e: - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "error", "error": str(e)}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="error", error=str(e)) ) session.commit() else: From 66e588c8caaa7e89fbac3baa197d754d9d628db9 Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:58:38 -0500 Subject: [PATCH 42/53] refactor(api): use sessionmaker in builtin tools manage service (#34812) --- .../tools/builtin_tools_manage_service.py | 22 +++----- .../test_builtin_tools_manage_service.py | 54 ++++++++++--------- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index d529d2f065..3daaf9a263 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any from sqlalchemy import exists, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -46,13 +46,12 @@ class BuiltinToolManageService: delete custom oauth client params """ tool_provider = ToolProviderID(provider) - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: session.query(ToolOAuthTenantClient).filter_by( tenant_id=tenant_id, provider=tool_provider.provider_name, plugin_id=tool_provider.plugin_id, ).delete() - session.commit() return {"result": "success"} @staticmethod @@ -150,7 +149,7 @@ class BuiltinToolManageService: """ update builtin tool provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # get if the provider exists db_provider = ( session.query(BuiltinToolProvider) @@ -203,9 +202,7 @@ class BuiltinToolManageService: db_provider.name = name - session.commit() except Exception as e: - session.rollback() raise ValueError(str(e)) return {"result": "success"} @@ -222,7 +219,7 @@ class BuiltinToolManageService: """ add builtin tool provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: try: lock = f"builtin_tool_provider_create_lock:{tenant_id}_{provider}" with redis_client.lock(lock, timeout=20): @@ -281,9 +278,7 @@ class BuiltinToolManageService: ) session.add(db_provider) - session.commit() except Exception as e: - session.rollback() raise ValueError(str(e)) return {"result": "success"} @@ -379,7 +374,7 @@ class BuiltinToolManageService: """ delete tool provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: db_provider = ( session.query(BuiltinToolProvider) .where( @@ -393,7 +388,6 @@ class BuiltinToolManageService: raise ValueError(f"you have not added provider {provider}") session.delete(db_provider) - session.commit() # delete cache provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) @@ -409,7 +403,7 @@ class BuiltinToolManageService: """ set default provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # get provider target_provider = session.query(BuiltinToolProvider).filter_by(id=id, tenant_id=tenant_id).first() if target_provider is None: @@ -422,7 +416,6 @@ class BuiltinToolManageService: # set new default provider target_provider.is_default = True - session.commit() return {"result": "success"} @@ -654,7 +647,7 @@ class BuiltinToolManageService: if not isinstance(provider_controller, (BuiltinToolProviderController, PluginToolProviderController)): raise ValueError(f"Provider {provider} is not a builtin or plugin provider") - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: custom_client_params = ( session.query(ToolOAuthTenantClient) .filter_by( @@ -690,7 +683,6 @@ class BuiltinToolManageService: if enable_oauth_custom_client is not None: custom_client_params.enabled = enable_oauth_custom_client - session.commit() return {"result": "success"} @staticmethod diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 175900071b..e80c306854 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -15,17 +15,24 @@ def _mock_session(mock_session_cls): return session +def _mock_sessionmaker(mock_sm_cls): + """Helper: set up a sessionmaker().begin() context manager mock and return the inner session.""" + session = MagicMock() + mock_sm_cls.return_value.begin.return_value.__enter__ = MagicMock(return_value=session) + mock_sm_cls.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + return session + + class TestDeleteCustomOauthClientParams: - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_deletes_and_returns_success(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) + def test_deletes_and_returns_success(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) result = BuiltinToolManageService.delete_custom_oauth_client_params("tenant-1", "google") assert result == {"result": "success"} session.query.return_value.filter_by.return_value.delete.assert_called_once() - session.commit.assert_called_once() class TestListBuiltinToolProviderTools: @@ -138,10 +145,10 @@ class TestIsOauthCustomClientEnabled: class TestDeleteBuiltinToolProvider: @patch(f"{MODULE}.BuiltinToolManageService.create_tool_encrypter") @patch(f"{MODULE}.ToolManager") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_raises_when_not_found(self, mock_db, mock_session_cls, mock_tm, mock_enc): - session = _mock_session(mock_session_cls) + def test_raises_when_not_found(self, mock_db, mock_sm_cls, mock_tm, mock_enc): + session = _mock_sessionmaker(mock_sm_cls) session.query.return_value.where.return_value.first.return_value = None with pytest.raises(ValueError, match="you have not added provider"): @@ -149,10 +156,10 @@ class TestDeleteBuiltinToolProvider: @patch(f"{MODULE}.BuiltinToolManageService.create_tool_encrypter") @patch(f"{MODULE}.ToolManager") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_deletes_provider_and_clears_cache(self, mock_db, mock_session_cls, mock_tm, mock_enc): - session = _mock_session(mock_session_cls) + def test_deletes_provider_and_clears_cache(self, mock_db, mock_sm_cls, mock_tm, mock_enc): + session = _mock_sessionmaker(mock_sm_cls) db_provider = MagicMock() session.query.return_value.where.return_value.first.return_value = db_provider mock_cache = MagicMock() @@ -162,24 +169,23 @@ class TestDeleteBuiltinToolProvider: assert result == {"result": "success"} session.delete.assert_called_once_with(db_provider) - session.commit.assert_called_once() mock_cache.delete.assert_called_once() class TestSetDefaultProvider: - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_raises_when_not_found(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) + def test_raises_when_not_found(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) session.query.return_value.filter_by.return_value.first.return_value = None with pytest.raises(ValueError, match="provider not found"): BuiltinToolManageService.set_default_provider("t", "u", "p", "id") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_sets_default_and_clears_old(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) + def test_sets_default_and_clears_old(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) target = MagicMock() session.query.return_value.filter_by.return_value.first.return_value = target @@ -187,14 +193,13 @@ class TestSetDefaultProvider: assert result == {"result": "success"} assert target.is_default is True - session.commit.assert_called_once() class TestUpdateBuiltinToolProvider: - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_raises_when_provider_not_exists(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) + def test_raises_when_provider_not_exists(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) session.query.return_value.where.return_value.first.return_value = None with pytest.raises(ValueError, match="you have not added provider"): @@ -203,10 +208,10 @@ class TestUpdateBuiltinToolProvider: @patch(f"{MODULE}.BuiltinToolManageService.create_tool_encrypter") @patch(f"{MODULE}.CredentialType") @patch(f"{MODULE}.ToolManager") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_updates_credentials_and_commits(self, mock_db, mock_session_cls, mock_tm, mock_cred_type, mock_enc): - session = _mock_session(mock_session_cls) + def test_updates_credentials_and_commits(self, mock_db, mock_sm_cls, mock_tm, mock_cred_type, mock_enc): + session = _mock_sessionmaker(mock_sm_cls) db_provider = MagicMock(credential_type="api_key", credentials="{}") session.query.return_value.where.return_value.first.return_value = db_provider @@ -227,7 +232,6 @@ class TestUpdateBuiltinToolProvider: result = BuiltinToolManageService.update_builtin_tool_provider("u", "t", "p", "c", credentials={"key": "val"}) assert result == {"result": "success"} - session.commit.assert_called_once() mock_cache.delete.assert_called_once() From 4c05316a7b5bda820e4d08abf157ef1e49cb8896 Mon Sep 17 00:00:00 2001 From: aliworksx08 <57456290+aliworksx08@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:04:18 -0500 Subject: [PATCH 43/53] refactor(api): deduplicate DSL shared entities into dsl_entities.py (#34762) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/app.py | 3 ++- api/controllers/console/app/app_import.py | 3 ++- .../rag_pipeline/rag_pipeline_import.py | 2 +- api/controllers/inner_api/app/dsl.py | 3 ++- api/services/app_dsl_service.py | 20 ++---------------- api/services/entities/dsl_entities.py | 21 +++++++++++++++++++ .../rag_pipeline/rag_pipeline_dsl_service.py | 8 ++----- 7 files changed, 32 insertions(+), 28 deletions(-) create mode 100644 api/services/entities/dsl_entities.py diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index c4b9bf6540..2018f60215 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -34,9 +34,10 @@ from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models import App, DatasetPermissionEnum, Workflow from models.model import IconType -from services.app_dsl_service import AppDslService, ImportMode +from services.app_dsl_service import AppDslService from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService +from services.entities.dsl_entities import ImportMode from services.entities.knowledge_entities.knowledge_entities import ( DataSource, InfoList, diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index 06192936f1..12d6951a48 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -17,8 +17,9 @@ from fields.app_fields import ( ) from libs.login import current_account_with_tenant, login_required from models.model import App -from services.app_dsl_service import AppDslService, ImportStatus +from services.app_dsl_service import AppDslService from services.enterprise.enterprise_service import EnterpriseService +from services.entities.dsl_entities import ImportStatus from services.feature_service import FeatureService from .. import console_ns diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py index 76a8c136e4..aa27458176 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py @@ -19,7 +19,7 @@ from fields.rag_pipeline_fields import ( ) from libs.login import current_account_with_tenant, login_required from models.dataset import Pipeline -from services.app_dsl_service import ImportStatus +from services.entities.dsl_entities import ImportStatus from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService diff --git a/api/controllers/inner_api/app/dsl.py b/api/controllers/inner_api/app/dsl.py index b1986b2557..6c15f9aa8b 100644 --- a/api/controllers/inner_api/app/dsl.py +++ b/api/controllers/inner_api/app/dsl.py @@ -18,7 +18,8 @@ from controllers.inner_api.wraps import enterprise_inner_api_only from extensions.ext_database import db from models import Account, App from models.account import AccountStatus -from services.app_dsl_service import AppDslService, ImportMode, ImportStatus +from services.app_dsl_service import AppDslService +from services.entities.dsl_entities import ImportMode, ImportStatus class InnerAppDSLImportPayload(BaseModel): diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index dd73e10374..c6c8a15109 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -3,7 +3,6 @@ import hashlib import logging import uuid from collections.abc import Mapping -from enum import StrEnum from typing import cast from urllib.parse import urlparse from uuid import uuid4 @@ -19,7 +18,7 @@ from graphon.nodes.question_classifier.entities import QuestionClassifierNodeDat from graphon.nodes.tool.entities import ToolNodeData from packaging import version from packaging.version import parse as parse_version -from pydantic import BaseModel, Field +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session @@ -40,6 +39,7 @@ from libs.datetime_utils import naive_utc_now from models import Account, App, AppMode from models.model import AppModelConfig, AppModelConfigDict, IconType from models.workflow import Workflow +from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.workflow_draft_variable_service import WorkflowDraftVariableService from services.workflow_service import WorkflowService @@ -53,18 +53,6 @@ DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB CURRENT_DSL_VERSION = "0.6.0" -class ImportMode(StrEnum): - YAML_CONTENT = "yaml-content" - YAML_URL = "yaml-url" - - -class ImportStatus(StrEnum): - COMPLETED = "completed" - COMPLETED_WITH_WARNINGS = "completed-with-warnings" - PENDING = "pending" - FAILED = "failed" - - class Import(BaseModel): id: str status: ImportStatus @@ -75,10 +63,6 @@ class Import(BaseModel): error: str = "" -class CheckDependenciesResult(BaseModel): - leaked_dependencies: list[PluginDependency] = Field(default_factory=list) - - def _check_version_compatibility(imported_version: str) -> ImportStatus: """Determine import status based on version comparison""" try: diff --git a/api/services/entities/dsl_entities.py b/api/services/entities/dsl_entities.py new file mode 100644 index 0000000000..05baa51fbd --- /dev/null +++ b/api/services/entities/dsl_entities.py @@ -0,0 +1,21 @@ +from enum import StrEnum + +from pydantic import BaseModel, Field + +from core.plugin.entities.plugin import PluginDependency + + +class ImportMode(StrEnum): + YAML_CONTENT = "yaml-content" + YAML_URL = "yaml-url" + + +class ImportStatus(StrEnum): + COMPLETED = "completed" + COMPLETED_WITH_WARNINGS = "completed-with-warnings" + PENDING = "pending" + FAILED = "failed" + + +class CheckDependenciesResult(BaseModel): + leaked_dependencies: list[PluginDependency] = Field(default_factory=list) diff --git a/api/services/rag_pipeline/rag_pipeline_dsl_service.py b/api/services/rag_pipeline/rag_pipeline_dsl_service.py index e42c020925..c24bf3d649 100644 --- a/api/services/rag_pipeline/rag_pipeline_dsl_service.py +++ b/api/services/rag_pipeline/rag_pipeline_dsl_service.py @@ -20,7 +20,7 @@ from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeDat from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData from graphon.nodes.tool.entities import ToolNodeData from packaging import version -from pydantic import BaseModel, Field +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session @@ -37,7 +37,7 @@ from models import Account from models.dataset import Dataset, DatasetCollectionBinding, Pipeline from models.enums import CollectionBindingType, DatasetRuntimeMode from models.workflow import Workflow, WorkflowType -from services.app_dsl_service import ImportMode, ImportStatus +from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus from services.entities.knowledge_entities.rag_pipeline_entities import ( IconInfo, KnowledgeConfiguration, @@ -64,10 +64,6 @@ class RagPipelineImportInfo(BaseModel): dataset_id: str | None = None -class CheckDependenciesResult(BaseModel): - leaked_dependencies: list[PluginDependency] = Field(default_factory=list) - - def _check_version_compatibility(imported_version: str) -> ImportStatus: """Determine import status based on version comparison""" try: From 8225f9856586e4abcfae46431670c91afa205fa9 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:09:27 +0800 Subject: [PATCH 44/53] fix(web): use nuqs for log conversation url state (#34820) --- .../app/log/__tests__/list.spec.tsx | 244 +++++++++--------- web/app/components/app/log/list.tsx | 20 +- 2 files changed, 123 insertions(+), 141 deletions(-) diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index a5d801f13f..25512ed689 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,14 +1,13 @@ /* eslint-disable ts/no-explicit-any */ import type { ReactNode } from 'react' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import ConversationList from '../list' const mockFetchChatMessages = vi.fn() const mockUpdateLogMessageFeedbacks = vi.fn() const mockUpdateLogMessageAnnotations = vi.fn() -const mockPush = vi.fn() -const mockReplace = vi.fn() const mockOnRefresh = vi.fn() const mockSetCurrentLogItem = vi.fn() const mockSetShowPromptLogModal = vi.fn() @@ -17,7 +16,6 @@ const mockSetShowMessageLogModal = vi.fn() const mockCompletionRefetch = vi.fn() const mockDelAnnotation = vi.fn() -let mockSearchParams = new URLSearchParams() let mockChatConversationDetail: Record | undefined let mockCompletionConversationDetail: Record | undefined let mockShowMessageLogModal = false @@ -53,18 +51,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - replace: mockReplace, - }), - usePathname: () => '/apps/app-1/logs', - useSearchParams: () => ({ - get: (key: string) => mockSearchParams.get(key), - toString: () => mockSearchParams.toString(), - }), -})) - vi.mock('@/service/use-log', () => ({ useChatConversationDetail: () => ({ data: mockChatConversationDetail, @@ -256,10 +242,28 @@ const createChatMessage = (id: string, overrides: Record = {}) ...overrides, }) +const renderConversationList = ({ + appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } as any, + logs = createLogs() as any, + searchParams = '?page=2', +}: { + appDetail?: any + logs?: any + searchParams?: string +} = {}) => { + return renderWithNuqs( + , + { searchParams }, + ) +} + describe('ConversationList', () => { beforeEach(() => { vi.clearAllMocks() - mockSearchParams = new URLSearchParams('page=2') mockChatConversationDetail = undefined mockCompletionConversationDetail = undefined mockShowMessageLogModal = false @@ -273,34 +277,29 @@ describe('ConversationList', () => { }) }) - it('should render chat rows and push the conversation id into the url when a row is clicked', () => { - render( - , - ) + it('should render chat rows and push the conversation id into the url when a row is clicked', async () => { + const { onUrlUpdate } = renderConversationList() expect(screen.getByText('hello world')).toBeInTheDocument() expect(screen.getAllByText('formatted-1710000000')).toHaveLength(2) fireEvent.click(screen.getByText('hello world')) - expect(mockPush).toHaveBeenCalledWith('/apps/app-1/logs?page=2&conversation_id=conversation-1', { scroll: false }) - expect(screen.getByTestId('drawer')).toBeInTheDocument() + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalled() + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('page')).toBe('2') + expect(update.searchParams.get('conversation_id')).toBe('conversation-1') + expect(update.options.history).toBe('push') }) - it('should close the drawer, refresh, and clear modal flags', () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') - - render( - , - ) + it('should close the drawer, refresh, and clear modal flags', async () => { + const { onUrlUpdate } = renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) fireEvent.click(screen.getByText('close-drawer')) @@ -308,11 +307,18 @@ describe('ConversationList', () => { expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) - expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false }) + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalled() + }) + + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('page')).toBe('2') + expect(update.searchParams.has('conversation_id')).toBe(false) + expect(update.options.history).toBe('replace') }) it('should render chat conversation details and submit feedback from the chat panel', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -355,13 +361,9 @@ describe('ConversationList', () => { mockShowMessageLogModal = true mockCurrentLogItem = { id: 'log-1' } - render( - , - ) + renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(mockFetchChatMessages).toHaveBeenCalledWith({ @@ -396,7 +398,6 @@ describe('ConversationList', () => { }) it('should render completion details and refetch after feedback updates', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockCompletionConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -423,13 +424,11 @@ describe('ConversationList', () => { mockShowPromptLogModal = true mockCurrentLogItem = { id: 'log-2' } - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, + logs: createCompletionLogs() as any, + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(screen.getByTestId('text-generation')).toBeInTheDocument() @@ -454,64 +453,61 @@ describe('ConversationList', () => { }) it('should render chatflow status cells and feedback counters for advanced chat logs', () => { - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } as any, + logs: { + data: [ + { + id: 'conversation-pending', + name: 'Pending row', + from_account_name: 'user-a', + read_at: 1710000001, + message_count: 3, + status_count: { paused: 1, success: 0, failed: 0, partial_success: 0 }, + user_feedback_stats: { like: 2, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-success', + name: 'Success row', + from_account_name: 'user-b', + read_at: 1710000001, + message_count: 4, + status_count: { paused: 0, success: 4, failed: 0, partial_success: 0 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-partial', + name: 'Partial row', + from_account_name: 'user-c', + read_at: 1710000001, + message_count: 5, + status_count: { paused: 0, success: 3, failed: 0, partial_success: 1 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-failure', + name: 'Failure row', + from_account_name: 'user-d', + read_at: 1710000001, + message_count: 1, + status_count: { paused: 0, success: 0, failed: 2, partial_success: 0 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + ], + } as any, + }) expect(screen.getByText('Pending')).toBeInTheDocument() expect(screen.getByText('Success')).toBeInTheDocument() @@ -522,7 +518,6 @@ describe('ConversationList', () => { }) it('should support annotation changes, modal closing, and paginated scroll loading in the detail drawer', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -568,13 +563,9 @@ describe('ConversationList', () => { has_more: false, }) - render( - , - ) + renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(screen.getByTestId('chat-panel')).toBeInTheDocument() @@ -609,7 +600,6 @@ describe('ConversationList', () => { }) it('should close the prompt log modal from completion detail drawers', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockCompletionConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -636,13 +626,11 @@ describe('ConversationList', () => { mockShowPromptLogModal = true mockCurrentLogItem = { id: 'log-2' } - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, + logs: createCompletionLogs() as any, + searchParams: '?page=2&conversation_id=conversation-1', + }) expect(await screen.findByTestId('prompt-log-modal')).toBeInTheDocument() diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 79323d34ab..01621e0d2a 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -13,6 +13,7 @@ import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { noop } from 'es-toolkit/function' +import { parseAsString, useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -33,7 +34,6 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' @@ -46,7 +46,6 @@ import { applyAnnotationEdited, applyAnnotationRemoved, buildChatThreadState, - buildConversationUrl, getCompletionMessageFiles, getConversationRowValues, getDetailVarList, @@ -674,10 +673,7 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string } const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined + const [conversationIdInUrl, setConversationIdInUrl] = useQueryState('conversation_id', parseAsString) const media = useBreakpoints() const isMobile = media === MediaType.mobile @@ -697,8 +693,6 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id - const buildUrlWithConversation = useCallback((conversationId?: string) => buildConversationUrl(pathname, searchParams.toString(), conversationId), [pathname, searchParams]) - const handleRowClick = useCallback((log: ConversationListItem) => { if (conversationIdInUrl === log.id) { if (!showDrawer) @@ -717,8 +711,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) if (currentConversation?.id !== log.id) setCurrentConversation(undefined) - router.push(buildUrlWithConversation(log.id), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) + void setConversationIdInUrl(log.id, { history: 'push' }) + }, [conversationIdInUrl, currentConversation, setConversationIdInUrl, showDrawer]) const currentConversationId = currentConversation?.id @@ -755,7 +749,7 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) pendingConversationCacheRef.current = undefined - }, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) + }, [conversationIdInUrl, currentConversation, currentConversationId, logs?.data, showDrawer]) const onCloseDrawer = useCallback(() => { onRefresh() @@ -769,8 +763,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) closingConversationIdRef.current = conversationIdInUrl ?? null if (conversationIdInUrl) - router.replace(buildUrlWithConversation(), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) + void setConversationIdInUrl(null, { history: 'replace' }) + }, [conversationIdInUrl, onRefresh, setConversationIdInUrl, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) // Annotated data needs to be highlighted const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { From d5ababfed0957909c312eb9fce2453039536ca7f Mon Sep 17 00:00:00 2001 From: Jonathan Chang <55106972+jonathanchang31@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:14:48 -0500 Subject: [PATCH 45/53] refactor(api): deduplicate json serialization in AppModelConfig.from_model_config_dict (#34795) --- api/models/model.py | 64 ++++++++++++++------------------------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/api/models/model.py b/api/models/model.py index 12865c4d22..ece3ff8b87 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -813,56 +813,32 @@ class AppModelConfig(TypeBase): "file_upload": self.file_upload_dict, } + @staticmethod + def _dump_optional(value: Any) -> str | None: + return json.dumps(value) if value else None + def from_model_config_dict(self, model_config: AppModelConfigDict): self.opening_statement = model_config.get("opening_statement") - self.suggested_questions = ( - json.dumps(model_config.get("suggested_questions")) if model_config.get("suggested_questions") else None - ) - self.suggested_questions_after_answer = ( - json.dumps(model_config.get("suggested_questions_after_answer")) - if model_config.get("suggested_questions_after_answer") - else None - ) - self.speech_to_text = ( - json.dumps(model_config.get("speech_to_text")) if model_config.get("speech_to_text") else None - ) - self.text_to_speech = ( - json.dumps(model_config.get("text_to_speech")) if model_config.get("text_to_speech") else None - ) - self.more_like_this = ( - json.dumps(model_config.get("more_like_this")) if model_config.get("more_like_this") else None - ) - self.sensitive_word_avoidance = ( - json.dumps(model_config.get("sensitive_word_avoidance")) - if model_config.get("sensitive_word_avoidance") - else None - ) - self.external_data_tools = ( - json.dumps(model_config.get("external_data_tools")) if model_config.get("external_data_tools") else None - ) - self.model = json.dumps(model_config.get("model")) if model_config.get("model") else None - self.user_input_form = ( - json.dumps(model_config.get("user_input_form")) if model_config.get("user_input_form") else None + self.suggested_questions = self._dump_optional(model_config.get("suggested_questions")) + self.suggested_questions_after_answer = self._dump_optional( + model_config.get("suggested_questions_after_answer") ) + self.speech_to_text = self._dump_optional(model_config.get("speech_to_text")) + self.text_to_speech = self._dump_optional(model_config.get("text_to_speech")) + self.more_like_this = self._dump_optional(model_config.get("more_like_this")) + self.sensitive_word_avoidance = self._dump_optional(model_config.get("sensitive_word_avoidance")) + self.external_data_tools = self._dump_optional(model_config.get("external_data_tools")) + self.model = self._dump_optional(model_config.get("model")) + self.user_input_form = self._dump_optional(model_config.get("user_input_form")) self.dataset_query_variable = model_config.get("dataset_query_variable") self.pre_prompt = model_config.get("pre_prompt") - self.agent_mode = json.dumps(model_config.get("agent_mode")) if model_config.get("agent_mode") else None - self.retriever_resource = ( - json.dumps(model_config.get("retriever_resource")) if model_config.get("retriever_resource") else None - ) + self.agent_mode = self._dump_optional(model_config.get("agent_mode")) + self.retriever_resource = self._dump_optional(model_config.get("retriever_resource")) self.prompt_type = PromptType(model_config.get("prompt_type", "simple")) - self.chat_prompt_config = ( - json.dumps(model_config.get("chat_prompt_config")) if model_config.get("chat_prompt_config") else None - ) - self.completion_prompt_config = ( - json.dumps(model_config.get("completion_prompt_config")) - if model_config.get("completion_prompt_config") - else None - ) - self.dataset_configs = ( - json.dumps(model_config.get("dataset_configs")) if model_config.get("dataset_configs") else None - ) - self.file_upload = json.dumps(model_config.get("file_upload")) if model_config.get("file_upload") else None + self.chat_prompt_config = self._dump_optional(model_config.get("chat_prompt_config")) + self.completion_prompt_config = self._dump_optional(model_config.get("completion_prompt_config")) + self.dataset_configs = self._dump_optional(model_config.get("dataset_configs")) + self.file_upload = self._dump_optional(model_config.get("file_upload")) return self From ec56f4e8399a6d9ef12309d46c9ffbce2fee9b0e Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 9 Apr 2026 14:44:28 +0800 Subject: [PATCH 46/53] fix(docker): restore S3_ADDRESS_STYLE env examples (#34826) --- api/.env.example | 1 + docker/.env.example | 1 + docker/docker-compose.yaml | 1 + 3 files changed, 3 insertions(+) diff --git a/api/.env.example b/api/.env.example index 2c1a755059..a04a18944a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -109,6 +109,7 @@ S3_BUCKET_NAME=your-bucket-name S3_ACCESS_KEY=your-access-key S3_SECRET_KEY=your-secret-key S3_REGION=your-region +S3_ADDRESS_STYLE=auto # Workflow run and Conversation archive storage (S3-compatible) ARCHIVE_STORAGE_ENABLED=false diff --git a/docker/.env.example b/docker/.env.example index f6da6c568d..4426a882f1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -469,6 +469,7 @@ S3_REGION=us-east-1 S3_BUCKET_NAME=difyai S3_ACCESS_KEY= S3_SECRET_KEY= +S3_ADDRESS_STYLE=auto # Whether to use AWS managed IAM roles for authenticating with the S3 service. # If set to false, the access key and secret key must be provided. S3_USE_AWS_MANAGED_IAM=false diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index dbadc58f89..1fc1cfdf9e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -131,6 +131,7 @@ x-shared-env: &shared-api-worker-env S3_BUCKET_NAME: ${S3_BUCKET_NAME:-difyai} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} S3_SECRET_KEY: ${S3_SECRET_KEY:-} + S3_ADDRESS_STYLE: ${S3_ADDRESS_STYLE:-auto} S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false} ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false} ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-} From 1ce6e279f0ee0f6de67528333651521fd9e08a6f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 9 Apr 2026 15:30:51 +0800 Subject: [PATCH 47/53] test: add unit tests for AppPublisher, Sidebar, Chat, FileUploader, Form Demo, Notion Page Selector, Prompt Editor, and Header Navigation components (#34802) Co-authored-by: CodingOnStar --- .../app-sidebar/dataset-info-flow.test.tsx | 224 +++++++++++++ .../app-sidebar/sidebar-shell-flow.test.tsx | 199 ++++++++++++ .../app/app-access-control-flow.test.tsx | 139 +++++++++ web/__tests__/app/app-publisher-flow.test.tsx | 243 +++++++++++++++ web/__tests__/base/chat-flow.test.tsx | 154 +++++++++ .../base/file-uploader-flow.test.tsx | 106 +++++++ web/__tests__/base/form-demo-flow.test.tsx | 65 ++++ .../base/notion-page-selector-flow.test.tsx | 151 +++++++++ .../base/prompt-editor-flow.test.tsx | 191 ++++++++++++ .../custom/custom-page-flow.test.tsx | 107 +++++++ .../header/account-dropdown-flow.test.tsx | 182 +++++++++++ web/__tests__/header/nav-flow.test.tsx | 237 ++++++++++++++ .../plugins/plugin-page-shell-flow.test.tsx | 163 ++++++++++ .../share/text-generation-mode-flow.test.tsx | 155 +++++++++ .../tools/provider-list-shell-flow.test.tsx | 205 ++++++++++++ .../__tests__/dropdown-callbacks.spec.tsx | 50 +++ .../dataset-info/__tests__/index.spec.tsx | 68 +++- .../apps/__tests__/app-card-skeleton.spec.tsx | 24 ++ .../chat/__tests__/chat-log-modals.spec.tsx | 144 +++++++++ .../chat/__tests__/use-chat-layout.spec.tsx | 293 +++++++++++++++++ .../base/chat/chat/chat-log-modals.tsx | 56 ++++ web/app/components/base/chat/chat/index.tsx | 176 ++--------- .../base/chat/chat/use-chat-layout.ts | 144 +++++++++ .../page-selector/__tests__/page-row.spec.tsx | 113 +++++++ .../__tests__/use-page-selector-model.spec.ts | 127 ++++++++ .../page-selector/__tests__/utils.spec.ts | 118 +++++++ .../__tests__/virtual-page-list.spec.tsx | 144 +++++++++ .../__tests__/prompt-editor-content.spec.tsx | 295 ++++++++++++++++++ .../components/base/prompt-editor/index.tsx | 182 +---------- .../prompt-editor/prompt-editor-content.tsx | 257 +++++++++++++++ .../preview/__tests__/loading.spec.tsx | 30 ++ .../detail/__tests__/context.spec.tsx | 30 ++ .../__tests__/segment-list-context.spec.tsx | 55 ++++ .../hooks/__tests__/use-doc-toc.spec.tsx | 125 ++++++++ .../actions/__tests__/node-actions.spec.ts | 69 ++++ .../actions/commands/__tests__/slash.spec.tsx | 124 ++++++++ .../__tests__/menu-item-content.spec.tsx | 28 ++ .../model-auth/__tests__/index.spec.ts | 23 ++ .../__tests__/credits-fallback-alert.spec.tsx | 18 ++ .../__tests__/downloading-icon.spec.tsx | 16 + .../text-generation/__tests__/index.spec.tsx | 219 +++++++++++++ .../hooks/__tests__/use-is-chat-mode.spec.ts | 41 +++ web/eslint-suppressions.json | 6 - web/service/fetch.ts | 12 +- 44 files changed, 5177 insertions(+), 331 deletions(-) create mode 100644 web/__tests__/app-sidebar/dataset-info-flow.test.tsx create mode 100644 web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx create mode 100644 web/__tests__/app/app-access-control-flow.test.tsx create mode 100644 web/__tests__/app/app-publisher-flow.test.tsx create mode 100644 web/__tests__/base/chat-flow.test.tsx create mode 100644 web/__tests__/base/file-uploader-flow.test.tsx create mode 100644 web/__tests__/base/form-demo-flow.test.tsx create mode 100644 web/__tests__/base/notion-page-selector-flow.test.tsx create mode 100644 web/__tests__/base/prompt-editor-flow.test.tsx create mode 100644 web/__tests__/custom/custom-page-flow.test.tsx create mode 100644 web/__tests__/header/account-dropdown-flow.test.tsx create mode 100644 web/__tests__/header/nav-flow.test.tsx create mode 100644 web/__tests__/plugins/plugin-page-shell-flow.test.tsx create mode 100644 web/__tests__/share/text-generation-mode-flow.test.tsx create mode 100644 web/__tests__/tools/provider-list-shell-flow.test.tsx create mode 100644 web/app/components/apps/__tests__/app-card-skeleton.spec.tsx create mode 100644 web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx create mode 100644 web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx create mode 100644 web/app/components/base/chat/chat/chat-log-modals.tsx create mode 100644 web/app/components/base/chat/chat/use-chat-layout.ts create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts create mode 100644 web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx create mode 100644 web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx create mode 100644 web/app/components/base/prompt-editor/prompt-editor-content.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/__tests__/context.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx create mode 100644 web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx create mode 100644 web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx create mode 100644 web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx create mode 100644 web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx create mode 100644 web/app/components/share/text-generation/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx new file mode 100644 index 0000000000..d1ca233d96 --- /dev/null +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -0,0 +1,224 @@ +import type { DataSet } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DatasetInfo from '@/app/components/app-sidebar/dataset-info' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +const mockReplace = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockInvalidDatasetDetail = vi.fn() +const mockExportPipeline = vi.fn() +const mockCheckIsUsedInApp = vi.fn() +const mockDeleteDataset = vi.fn() +const mockDownloadBlob = vi.fn() + +let mockDataset: DataSet + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ + dataset: mockDataset, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => + selector({ isCurrentWorkspaceDatasetOperator: false }), +})) + +vi.mock('@/hooks/use-knowledge', () => ({ + useKnowledge: () => ({ + formatIndexingTechniqueAndMethod: () => 'indexing-technique', + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset', 'detail'], + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipeline, + }), +})) + +vi.mock('@/service/datasets', () => ({ + checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), + deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/datasets/rename-modal', () => ({ + default: ({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + }) => show + ? ( +
+ + +
+ ) + : null, +})) + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: { + icon: '📙', + icon_background: '#FFF4ED', + icon_type: 'emoji', + icon_url: '', + }, + description: 'Dataset description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + created_by: 'user-1', + updated_by: 'user-1', + updated_at: 1690000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 1, + total_document_count: 1, + word_count: 1000, + provider: 'internal', + embedding_model: 'text-embedding-3', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'rag_pipeline', + pipeline_id: 'pipeline-1', + enable_api: false, + is_multimodal: false, + is_published: true, + ...overrides, +}) + +const openDropdown = () => { + fireEvent.click(screen.getByRole('button')) +} + +describe('App Sidebar Dataset Info Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataset = createDataset() + mockExportPipeline.mockResolvedValue({ data: 'pipeline: demo' }) + mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) + mockDeleteDataset.mockResolvedValue({}) + }) + + it('exports the published pipeline from the dropdown menu', async () => { + render() + + expect(screen.getByText('Dataset Name')).toBeInTheDocument() + + openDropdown() + fireEvent.click(await screen.findByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockExportPipeline).toHaveBeenCalledWith({ + pipelineId: 'pipeline-1', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'Dataset Name.pipeline', + })) + }) + }) + + it('opens the rename modal and refreshes dataset caches after a successful rename', async () => { + render() + + openDropdown() + fireEvent.click(await screen.findByText('common.operation.edit')) + + expect(screen.getByTestId('rename-dataset-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'rename-success' })) + + expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1) + }) + + it('checks app usage before deleting and redirects back to the dataset list after confirmation', async () => { + render() + + openDropdown() + fireEvent.click(await screen.findByText('common.operation.delete')) + + await waitFor(() => { + expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1') + expect(screen.getByText('dataset.deleteDatasetConfirmTitle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') + expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) +}) diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx new file mode 100644 index 0000000000..3e3edba5dd --- /dev/null +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -0,0 +1,199 @@ +import type { SVGProps } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppDetailNav from '@/app/components/app-sidebar' + +const mockSetAppSidebarExpand = vi.fn() + +let mockAppSidebarExpand = 'expand' +let mockPathname = '/app/app-1/logs' +let mockSelectedSegment = 'logs' +let mockIsHovering = true +let keyPressHandler: ((event: { preventDefault: () => void }) => void) | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: { + id: 'app-1', + name: 'Demo App', + mode: 'chat', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: null, + }, + appSidebarExpand: mockAppSidebarExpand, + setAppSidebarExpand: mockSetAppSidebarExpand, + }), +})) + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, + useSelectedLayoutSegment: () => mockSelectedSegment, +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + className, + title, + }: { + href: string + children?: React.ReactNode + className?: string + title?: string + }) => ( + + {children} + + ), +})) + +vi.mock('ahooks', () => ({ + useHover: () => mockIsHovering, + useKeyPress: (_key: string, handler: (event: { preventDefault: () => void }) => void) => { + keyPressHandler = handler + }, +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => 'desktop', + MediaType: { + mobile: 'mobile', + desktop: 'desktop', + }, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: vi.fn(), + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await vi.importActual('react') + const OpenContext = React.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( + + ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/app-sidebar/app-info', () => ({ + default: ({ + expand, + onlyShowDetail, + openState, + }: { + expand: boolean + onlyShowDetail?: boolean + openState?: boolean + }) => ( +
+ ), +})) + +const MockIcon = (props: SVGProps) => + +const navigation = [ + { name: 'Overview', href: '/app/app-1/overview', icon: MockIcon, selectedIcon: MockIcon }, + { name: 'Logs', href: '/app/app-1/logs', icon: MockIcon, selectedIcon: MockIcon }, +] + +describe('App Sidebar Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockAppSidebarExpand = 'expand' + mockPathname = '/app/app-1/logs' + mockSelectedSegment = 'logs' + mockIsHovering = true + keyPressHandler = null + }) + + it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => { + render() + + expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true') + + const logsLink = screen.getByRole('link', { name: /Logs/i }) + expect(logsLink.className).toContain('bg-components-menu-item-bg-active') + + fireEvent.click(screen.getByRole('button')) + expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') + + const preventDefault = vi.fn() + keyPressHandler?.({ preventDefault }) + + expect(preventDefault).toHaveBeenCalled() + expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') + }) + + it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', () => { + mockPathname = '/app/app-1/workflow' + mockSelectedSegment = 'workflow' + localStorage.setItem('workflow-canvas-maximize', 'true') + + render() + + expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + expect(screen.getByText('Demo App')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx new file mode 100644 index 0000000000..49443eb4ec --- /dev/null +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -0,0 +1,139 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppPublisher from '@/app/components/app/app-publisher' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +const mockFetchAppDetailDirect = vi.fn() +const mockSetAppDetail = vi.fn() +const mockRefetch = vi.fn() + +let mockAppDetail: { + id: string + name: string + mode: AppModeEnum + access_mode: AccessMode + description: string + icon: string + icon_type: string + icon_background: string + site: { + app_base_url: string + access_token: string + } +} | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `ago:${value}`, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: ({ + onConfirm, + onClose, + }: { + onConfirm: () => Promise + onClose: () => void + }) => ( +
+ + +
+ ), +})) + +describe('App Access Control Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + description: 'Demo app description', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchAppDetailDirect.mockResolvedValue({ + ...mockAppDetail, + access_mode: AccessMode.PUBLIC, + }) + }) + + it('refreshes app detail after confirming access control updates', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' })) + fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific')) + + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' })) + + await waitFor(() => { + expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + })) + }) + + await waitFor(() => { + expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx new file mode 100644 index 0000000000..5c330cf71e --- /dev/null +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -0,0 +1,243 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppPublisher from '@/app/components/app/app-publisher' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +const mockTrackEvent = vi.fn() +const mockRefetch = vi.fn() +const mockFetchInstalledAppList = vi.fn() +const mockFetchAppDetailDirect = vi.fn() +const mockToastError = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockSetAppDetail = vi.fn() + +let mockAppDetail: { + id: string + name: string + mode: AppModeEnum + access_mode: AccessMode + description: string + icon: string + icon_type: string + icon_background: string + site: { + app_base_url: string + access_token: string + } +} | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('ahooks', () => ({ + useKeyPress: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `ago:${value}`, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( + isShow + ? ( +
+ +
+ ) + : null + ), +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await vi.importActual('react') + const OpenContext = React.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +describe('App Publisher Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + description: 'Demo app description', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [{ id: 'installed-1' }], + }) + mockFetchAppDetailDirect.mockResolvedValue({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + }) + mockOpenAsyncWindow.mockImplementation(async ( + resolver: () => Promise, + options?: { onError?: (error: Error) => void }, + ) => { + try { + return await resolver() + } + catch (error) { + options?.onError?.(error as Error) + } + }) + }) + + it('publishes from the summary panel and tracks the publish event', async () => { + const onPublish = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + + expect(screen.getByText('common.latestPublished')).toBeInTheDocument() + expect(screen.getByText('common.publishUpdate')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publishUpdate')) + + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({ + action_mode: 'app', + app_id: 'app-1', + app_name: 'Demo App', + })) + }) + + expect(mockRefetch).toHaveBeenCalled() + }) + + it('opens embedded modal and resolves the installed explore target', async () => { + render() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.embedIntoSite')) + + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.openInExplore')) + + await waitFor(() => { + expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') + expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1) + }) + }) + + it('shows a toast error when no installed explore app is available', async () => { + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [], + }) + + render() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.openInExplore')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('No app found in Explore') + }) + }) +}) diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx new file mode 100644 index 0000000000..2a02c063fd --- /dev/null +++ b/web/__tests__/base/chat-flow.test.tsx @@ -0,0 +1,154 @@ +import type { RefObject } from 'react' +import type { ChatConfig } from '@/app/components/base/chat/types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChatWithHistory from '@/app/components/base/chat/chat-with-history' +import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks' +import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import useDocumentTitle from '@/hooks/use-document-title' + +vi.mock('@/app/components/base/chat/chat-with-history/hooks', () => ({ + useChatWithHistory: vi.fn(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(), + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/'), + useSearchParams: vi.fn(() => new URLSearchParams()), + useParams: vi.fn(() => ({})), +})) + +type HookReturn = ReturnType + +const mockAppData = { + site: { title: 'Test Chat', chat_color_theme: 'blue', chat_color_theme_inverted: false }, +} as unknown as AppData + +const defaultHookReturn: HookReturn = { + isInstalledApp: false, + appId: 'test-app-id', + currentConversationId: '', + currentConversationItem: undefined, + handleConversationIdInfoChange: vi.fn(), + appData: mockAppData, + appParams: {} as ChatConfig, + appMeta: {} as AppMeta, + appPinnedConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appConversationDataLoading: false, + appChatListData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appChatListDataLoading: false, + appPrevChatTree: [], + pinnedConversationList: [], + conversationList: [], + setShowNewConversationItemInList: vi.fn(), + newConversationInputs: {}, + newConversationInputsRef: { current: {} } as unknown as RefObject>, + handleNewConversationInputsChange: vi.fn(), + inputsForms: [], + handleNewConversation: vi.fn(), + handleStartChat: vi.fn(), + handleChangeConversation: vi.fn(), + handlePinConversation: vi.fn(), + handleUnpinConversation: vi.fn(), + conversationDeleting: false, + handleDeleteConversation: vi.fn(), + conversationRenaming: false, + handleRenameConversation: vi.fn(), + handleNewConversationCompleted: vi.fn(), + newConversationId: '', + chatShouldReloadKey: 'test-reload-key', + handleFeedback: vi.fn(), + currentChatInstanceRef: { current: { handleStop: vi.fn() } }, + sidebarCollapseState: false, + handleSidebarCollapse: vi.fn(), + clearChatList: false, + setClearChatList: vi.fn(), + isResponding: false, + setIsResponding: vi.fn(), + currentConversationInputs: {}, + setCurrentConversationInputs: vi.fn(), + allInputsHidden: false, + initUserVariables: {}, +} + +describe('Base Chat Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc) + vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn) + renderHook(() => useThemeContext()).result.current.buildTheme() + }) + + // Chat-with-history shell integration across layout, responsive shell, and theme setup. + describe('Chat With History Shell', () => { + it('builds theme, updates the document title, and expands the collapsed desktop sidebar on hover', async () => { + const themeBuilder = renderHook(() => useThemeContext()).result.current + const { container } = render() + + const titles = screen.getAllByText('Test Chat') + expect(titles.length).toBeGreaterThan(0) + expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat') + + await waitFor(() => { + expect(themeBuilder.theme.primaryColor).toBe('blue') + expect(themeBuilder.theme.chatColorThemeInverted).toBe(false) + }) + + vi.mocked(useChatWithHistory).mockReturnValue({ + ...defaultHookReturn, + sidebarCollapseState: true, + }) + + const { container: collapsedContainer } = render() + const hoverArea = collapsedContainer.querySelector('.absolute.top-0.z-20') + + expect(container.querySelector('.chat-history-shell')).toBeInTheDocument() + expect(hoverArea).toBeInTheDocument() + + if (hoverArea) { + fireEvent.mouseEnter(hoverArea) + expect(hoverArea).toHaveClass('left-0') + + fireEvent.mouseLeave(hoverArea) + expect(hoverArea).toHaveClass('left-[-248px]') + } + }) + + it('falls back to the mobile loading shell when site metadata is unavailable', () => { + vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile) + vi.mocked(useChatWithHistory).mockReturnValue({ + ...defaultHookReturn, + appData: null, + appChatListDataLoading: true, + }) + + const { container } = render() + + expect(useDocumentTitle).toHaveBeenCalledWith('Chat') + expect(screen.getByRole('status')).toBeInTheDocument() + expect(container.querySelector('.mobile-chat-shell')).toBeInTheDocument() + expect(container.querySelector('.rounded-t-2xl')).toBeInTheDocument() + expect(container.querySelector('.rounded-2xl')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/base/file-uploader-flow.test.tsx b/web/__tests__/base/file-uploader-flow.test.tsx new file mode 100644 index 0000000000..81dccedfe5 --- /dev/null +++ b/web/__tests__/base/file-uploader-flow.test.tsx @@ -0,0 +1,106 @@ +import type { FileUpload } from '@/app/components/base/features/types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FileUploaderInAttachmentWrapper from '@/app/components/base/file-uploader/file-uploader-in-attachment' +import FileUploaderInChatInput from '@/app/components/base/file-uploader/file-uploader-in-chat-input' +import { FileContextProvider } from '@/app/components/base/file-uploader/store' +import { TransferMethod } from '@/types/app' + +const mockUploadRemoteFileInfo = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({}), +})) + +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['document'], + allowed_file_extensions: [], + allowed_file_upload_methods: [TransferMethod.remote_url], + number_limits: 5, + preview_config: { + enabled: false, + mode: 'current_page', + file_type_list: [], + }, + ...overrides, +} as FileUpload) + +const renderChatInput = (fileConfig: FileUpload, readonly = false) => { + return render( + + + , + ) +} + +describe('Base File Uploader Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-file-1', + mime_type: 'application/pdf', + size: 2048, + name: 'guide.pdf', + url: 'https://cdn.example.com/guide.pdf', + }) + }) + + it('uploads a remote file from the attachment wrapper and pushes the updated file list to consumers', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /fileUploader\.pasteFileLink/i })) + await user.type(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), 'https://example.com/guide.pdf') + await user.click(screen.getByRole('button', { name: /operation\.ok/i })) + + await waitFor(() => { + expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/guide.pdf', false) + }) + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ + name: 'https://example.com/guide.pdf', + uploadedId: 'remote-file-1', + url: 'https://cdn.example.com/guide.pdf', + progress: 100, + }), + ]) + }) + + expect(screen.getByText('https://example.com/guide.pdf')).toBeInTheDocument() + }) + + it('opens the link picker from chat input and keeps the trigger disabled in readonly mode', async () => { + const user = userEvent.setup() + const fileConfig = createFileConfig() + + const { unmount } = renderChatInput(fileConfig) + + const activeTrigger = screen.getByRole('button') + expect(activeTrigger).toBeEnabled() + + await user.click(activeTrigger) + expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i)).toBeInTheDocument() + expect(screen.queryByText(/fileUploader\.uploadFromComputer/i)).not.toBeInTheDocument() + + unmount() + renderChatInput(fileConfig, true) + + expect(screen.getByRole('button')).toBeDisabled() + }) +}) diff --git a/web/__tests__/base/form-demo-flow.test.tsx b/web/__tests__/base/form-demo-flow.test.tsx new file mode 100644 index 0000000000..afb36528c0 --- /dev/null +++ b/web/__tests__/base/form-demo-flow.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DemoForm from '@/app/components/base/form/form-scenarios/demo' + +describe('Base Form Demo Flow', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reveals contact fields and submits the composed form values through the shared form actions', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /^name$/i }), 'Alice') + await user.type(screen.getByRole('textbox', { name: /^surname$/i }), 'Smith') + await user.click(screen.getByText(/i accept the terms and conditions/i)) + + expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /^email$/i }), 'alice@example.com') + + const preferredMethodLabel = screen.getByText('Preferred Contact Method') + const preferredMethodField = preferredMethodLabel.parentElement?.parentElement + expect(preferredMethodField).toBeTruthy() + + await user.click(within(preferredMethodField as HTMLElement).getByText('Email')) + await user.click(screen.getByText('Whatsapp')) + + const submitButton = screen.getByRole('button', { name: /operation\.submit/i }) + expect(submitButton).toBeEnabled() + await user.click(submitButton) + + await waitFor(() => { + expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({ + name: 'Alice', + surname: 'Smith', + isAcceptingTerms: true, + contact: expect.objectContaining({ + email: 'alice@example.com', + preferredContactMethod: 'whatsapp', + }), + })) + }) + }) + + it('removes the nested contact section again when the name field is cleared', async () => { + const user = userEvent.setup() + render() + + const nameInput = screen.getByRole('textbox', { name: /^name$/i }) + await user.type(nameInput, 'Alice') + expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument() + + await user.clear(nameInput) + + await waitFor(() => { + expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx new file mode 100644 index 0000000000..6295d2dc00 --- /dev/null +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -0,0 +1,151 @@ +import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { DataSourceNotionWorkspace } from '@/models/common' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NotionPageSelector from '@/app/components/base/notion-page-selector/base' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' + +const mockInvalidPreImportNotionPages = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockUsePreImportNotionPages = vi.fn() + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ + index, + size: 28, + start: index * 28, + })), + getTotalSize: () => count * 28 + 16, + }), +})) + +vi.mock('@/service/knowledge/use-import', () => ({ + usePreImportNotionPages: (params: { datasetId: string, credentialId: string }) => mockUsePreImportNotionPages(params), + useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => + selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), +})) + +const buildCredential = (id: string, name: string, workspaceName: string): DataSourceCredential => ({ + id, + name, + type: CredentialTypeEnum.OAUTH2, + is_default: false, + avatar_url: '', + credential: { + workspace_icon: '', + workspace_name: workspaceName, + }, +}) + +const credentials: DataSourceCredential[] = [ + buildCredential('c1', 'Cred 1', 'Workspace 1'), + buildCredential('c2', 'Cred 2', 'Workspace 2'), +] + +const workspacePagesByCredential: Record = { + c1: [ + { + workspace_id: 'w1', + workspace_icon: '', + workspace_name: 'Workspace 1', + pages: [ + { page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + { page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false }, + { page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true }, + ], + }, + ], + c2: [ + { + workspace_id: 'w2', + workspace_icon: '', + workspace_name: 'Workspace 2', + pages: [ + { page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + ], + }, + ], +} + +describe('Base Notion Page Selector Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUsePreImportNotionPages.mockImplementation(({ credentialId }: { credentialId: string }) => ({ + data: { + notion_info: workspacePagesByCredential[credentialId] ?? workspacePagesByCredential.c1, + }, + isFetching: false, + isError: false, + })) + }) + + it('selects a page tree, filters through search, clears search, and previews the current page', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onPreview = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('checkbox-notion-page-checkbox-root-1')) + + expect(onSelect).toHaveBeenLastCalledWith(expect.arrayContaining([ + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), + ])) + + await user.type(screen.getByTestId('notion-search-input'), 'missing-page') + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + + await user.click(screen.getByTestId('notion-search-input-clear')) + expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() + + await user.click(screen.getByTestId('notion-page-preview-root-1')) + expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' })) + }) + + it('switches workspace credentials and opens the configuration entry point', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onSelectCredential = vi.fn() + + render( + , + ) + + expect(onSelectCredential).toHaveBeenCalledWith('c1') + + await user.click(screen.getByTestId('notion-credential-selector-btn')) + await user.click(screen.getByTestId('notion-credential-item-c2')) + + expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) + expect(onSelect).toHaveBeenCalledWith([]) + + await waitFor(() => { + expect(onSelectCredential).toHaveBeenLastCalledWith('c2') + expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' })) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + }) +}) diff --git a/web/__tests__/base/prompt-editor-flow.test.tsx b/web/__tests__/base/prompt-editor-flow.test.tsx new file mode 100644 index 0000000000..5fa96e6ee2 --- /dev/null +++ b/web/__tests__/base/prompt-editor-flow.test.tsx @@ -0,0 +1,191 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { ComponentProps } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { getNearestEditorFromDOMNode } from 'lexical' +import { useEffect } from 'react' +import PromptEditor from '@/app/components/base/prompt-editor' +import { + UPDATE_DATASETS_EVENT_EMITTER, + UPDATE_HISTORY_EVENT_EMITTER, +} from '@/app/components/base/prompt-editor/constants' +import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' + +type Captures = { + eventEmitter: EventEmitter | null + events: EventEmitterValue[] +} + +const EventProbe = ({ captures }: { captures: Captures }) => { + const { eventEmitter } = useEventEmitterContextContext() + + useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + eventEmitter?.useSubscription((value) => { + captures.events.push(value) + }) + + return +} + +const PromptEditorHarness = ({ + captures, + ...props +}: ComponentProps & { captures: Captures }) => ( + + + + +) + +describe('Base Prompt Editor Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Real prompt editor integration should emit block updates and transform editor updates into text output. + describe('Editor Shell', () => { + it('should render with the real editor, emit dataset/history events, and convert update events into text changes', async () => { + const captures: Captures = { eventEmitter: null, events: [] } + const onChange = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const user = userEvent.setup() + + const { rerender, container } = render( + , + ) + + expect(screen.getByText('Type prompt')).toBeInTheDocument() + + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + expect(editable).toBeInTheDocument() + + await user.click(editable) + await waitFor(() => { + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'outside' })) + await waitFor(() => { + expect(onBlur).toHaveBeenCalledTimes(1) + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'editor-1', + payload: 'first line\nsecond line', + }) + }) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('first line\nsecond line') + }) + + expect(captures.events).toContainEqual({ + type: UPDATE_DATASETS_EVENT_EMITTER, + payload: [{ id: 'ds-1', name: 'Dataset One', type: 'dataset' }], + }) + expect(captures.events).toContainEqual({ + type: UPDATE_HISTORY_EVENT_EMITTER, + payload: { user: 'user-role', assistant: 'assistant-role' }, + }) + + rerender( + , + ) + + await waitFor(() => { + expect(captures.events).toContainEqual({ + type: UPDATE_DATASETS_EVENT_EMITTER, + payload: [{ id: 'ds-2', name: 'Dataset Two', type: 'dataset' }], + }) + }) + expect(captures.events).toContainEqual({ + type: UPDATE_HISTORY_EVENT_EMITTER, + payload: { user: 'user-next', assistant: 'assistant-next' }, + }) + }) + + it('should tolerate updates without onChange and rethrow lexical runtime errors through the configured handler', async () => { + const captures: Captures = { eventEmitter: null, events: [] } + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { container } = render( + , + ) + + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'editor-2', + payload: 'silent update', + }) + }) + + const editable = container.querySelector('[contenteditable="false"]') as HTMLElement + const editor = getNearestEditorFromDOMNode(editable) + + expect(editable).toBeInTheDocument() + expect(editor).not.toBeNull() + expect(screen.getByRole('textbox')).toHaveTextContent('silent update') + + expect(() => { + act(() => { + editor?.update(() => { + throw new Error('prompt-editor boom') + }) + }) + }).toThrow('prompt-editor boom') + + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx new file mode 100644 index 0000000000..6eb5ccadb9 --- /dev/null +++ b/web/__tests__/custom/custom-page-flow.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' +import CustomPage from '@/app/components/custom/custom-page' +import useWebAppBrand from '@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand' + +const mockSetShowPricingModal = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), +})) + +vi.mock('@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand', () => ({ + __esModule: true, + default: vi.fn(), +})) + +const { useProviderContext } = await import('@/context/provider-context') + +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseWebAppBrand = vi.mocked(useWebAppBrand) + +const createBrandState = (overrides: Partial> = {}): ReturnType => ({ + fileId: '', + imgKey: 1, + uploadProgress: 0, + uploading: false, + webappLogo: 'https://example.com/logo.png', + webappBrandRemoved: false, + uploadDisabled: false, + workspaceLogo: 'https://example.com/workspace-logo.png', + isCurrentWorkspaceManager: true, + isSandbox: false, + handleApply: vi.fn(), + handleCancel: vi.fn(), + handleChange: vi.fn(), + handleRestore: vi.fn(), + handleSwitch: vi.fn(), + ...overrides, +}) + +const setProviderPlan = (planType: Plan, enableBilling = true) => { + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ + enableBilling, + plan: { + ...defaultPlan, + type: planType, + }, + })) +} + +describe('Custom Page Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + setProviderPlan(Plan.professional) + mockUseWebAppBrand.mockReturnValue(createBrandState()) + }) + + it('shows the billing upgrade banner for sandbox workspaces and opens pricing modal', () => { + setProviderPlan(Plan.sandbox) + + render() + + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort')) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('renders the branding controls and the sales contact footer for paid workspaces', () => { + const hookState = createBrandState({ + fileId: 'pending-logo', + }) + mockUseWebAppBrand.mockReturnValue(hookState) + + render() + + const contactLink = screen.getByText('custom.customize.contactUs').closest('a') + expect(contactLink).toHaveAttribute('href', contactSalesUrl) + + fireEvent.click(screen.getByRole('switch')) + fireEvent.click(screen.getByRole('button', { name: 'custom.restore' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'custom.apply' })) + + expect(hookState.handleSwitch).toHaveBeenCalledWith(true) + expect(hookState.handleRestore).toHaveBeenCalledTimes(1) + expect(hookState.handleCancel).toHaveBeenCalledTimes(1) + expect(hookState.handleApply).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx new file mode 100644 index 0000000000..6a645c7a43 --- /dev/null +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -0,0 +1,182 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Plan } from '@/app/components/billing/type' +import AccountDropdown from '@/app/components/header/account-dropdown' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' + +const { + mockPush, + mockLogout, + mockResetUser, + mockSetShowAccountSettingModal, +} = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockLogout: vi.fn(), + mockResetUser: vi.fn(), + mockSetShowAccountSettingModal: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, version?: string }) => { + if (options?.version) + return `${options.ns}.${key}:${options.version}` + return options?.ns ? `${options.ns}.${key}` : key + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + name: 'Ada Lovelace', + email: 'ada@example.com', + avatar_url: '', + }, + langGeniusVersionInfo: { + current_version: '1.0.0', + latest_version: '1.1.0', + release_notes: 'https://example.com/releases/1.1.0', + }, + isCurrentWorkspaceOwner: false, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + isEducationAccount: false, + plan: { + type: Plan.professional, + }, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record) => unknown) => { + const state = { + systemFeatures: { + branding: { + enabled: false, + workspace_logo: null, + }, + }, + } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/service/use-common', () => ({ + useLogout: () => ({ + mutateAsync: mockLogout, + }), +})) + +vi.mock('@/app/components/base/amplitude/utils', () => ({ + resetUser: mockResetUser, +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string + children?: React.ReactNode + } & Record) => ( + + {children} + + ), +})) + +const renderAccountDropdown = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('Header Account Dropdown Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + repo: { stars: 123456 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + localStorage.clear() + }) + + it('opens account actions, fetches github stars, and opens the settings and about flows', async () => { + renderAccountDropdown() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('ada@example.com')).toBeInTheDocument() + expect(await screen.findByText('123,456')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.userProfile.settings')) + + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.MEMBERS, + }) + + fireEvent.click(screen.getByText('common.userProfile.about')) + + await waitFor(() => { + expect(screen.getByText(/Version/)).toBeInTheDocument() + expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument() + }) + }) + + it('logs out, resets cached user markers, and redirects to signin', async () => { + localStorage.setItem('setup_status', 'done') + localStorage.setItem('education-reverify-prev-expire-at', '1') + localStorage.setItem('education-reverify-has-noticed', '1') + localStorage.setItem('education-expired-has-noticed', '1') + + renderAccountDropdown() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + fireEvent.click(screen.getByText('common.userProfile.logout')) + + await waitFor(() => { + expect(mockLogout).toHaveBeenCalledTimes(1) + expect(mockResetUser).toHaveBeenCalledTimes(1) + expect(mockPush).toHaveBeenCalledWith('/signin') + }) + + expect(localStorage.getItem('setup_status')).toBeNull() + expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull() + expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull() + expect(localStorage.getItem('education-expired-has-noticed')).toBeNull() + }) +}) diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx new file mode 100644 index 0000000000..05955a6c83 --- /dev/null +++ b/web/__tests__/header/nav-flow.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Nav from '@/app/components/header/nav' +import { AppModeEnum } from '@/types/app' + +const mockPush = vi.fn() +const mockSetAppDetail = vi.fn() +const mockOnCreate = vi.fn() +const mockOnLoadMore = vi.fn() + +let mockSelectedSegment = 'app' +let mockIsCurrentWorkspaceEditor = true + +vi.mock('@headlessui/react', () => { + type MenuContextValue = { + open: boolean + setOpen: React.Dispatch> + } + const MenuContext = React.createContext(null) + + const Menu = ({ + children, + }: { + children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) + }) => { + const [open, setOpen] = React.useState(false) + const value = React.useMemo(() => ({ open, setOpen }), [open]) + + return ( + + {typeof children === 'function' ? children({ open }) : children} + + ) + } + + const MenuButton = ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const context = React.useContext(MenuContext) + + return ( + + ) + } + + const MenuItems = ({ + as: Component = 'div', + children, + ...props + }: { + as?: React.ElementType + children: React.ReactNode + } & Record) => { + const context = React.useContext(MenuContext) + if (!context?.open) + return null + + return {children} + } + + const MenuItem = ({ + as: Component = 'div', + children, + ...props + }: { + as?: React.ElementType + children: React.ReactNode + } & Record) => {children} + + return { + Menu, + MenuButton, + MenuItems, + MenuItem, + Transition: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useSelectedLayoutSegment: () => mockSelectedSegment, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + }: { + href: string + children?: React.ReactNode + }) => {children}, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: () => mockSetAppDetail, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + }), +})) + +const navigationItems = [ + { + id: 'app-1', + name: 'Alpha', + link: '/app/app-1/configuration', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + mode: AppModeEnum.CHAT, + }, + { + id: 'app-2', + name: 'Bravo', + link: '/app/app-2/workflow', + icon_type: 'emoji' as const, + icon: '⚙️', + icon_background: '#E0F2FE', + icon_url: null, + mode: AppModeEnum.WORKFLOW, + }, +] + +const curNav = { + id: 'app-1', + name: 'Alpha', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + mode: AppModeEnum.CHAT, +} + +const renderNav = (nav = curNav) => { + return render( +
} + marketplace={
marketplace view
} + />, + { searchParams }, + ) +} + +describe('Plugin Page Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchManifestFromMarketPlace.mockResolvedValue({ + data: { + plugin: { + org: 'langgenius', + name: 'plugin-demo', + }, + version: { + version: '1.0.0', + }, + }, + }) + }) + + it('switches from installed plugins to marketplace and syncs the active tab into the URL', async () => { + const { onUrlUpdate } = renderPluginPage() + + expect(screen.getByTestId('plugins-view')).toBeInTheDocument() + expect(screen.queryByTestId('marketplace-view')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('tab-item-discover')) + + await waitFor(() => { + expect(screen.getByTestId('marketplace-view')).toBeInTheDocument() + }) + + const tabUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(tabUpdate.searchParams.get('tab')).toBe('discover') + }) + + it('hydrates marketplace installation from query params and clears the install state when closed', async () => { + const { onUrlUpdate } = renderPluginPage('?package-ids=%5B%22langgenius%2Fplugin-demo%22%5D') + + await waitFor(() => { + expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('langgenius%2Fplugin-demo') + expect(screen.getByTestId('install-from-marketplace-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'close-install-modal' })) + + await waitFor(() => { + const clearUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(clearUpdate.searchParams.has('package-ids')).toBe(false) + }) + }) +}) diff --git a/web/__tests__/share/text-generation-mode-flow.test.tsx b/web/__tests__/share/text-generation-mode-flow.test.tsx new file mode 100644 index 0000000000..0d4307bca0 --- /dev/null +++ b/web/__tests__/share/text-generation-mode-flow.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TextGeneration from '@/app/components/share/text-generation' + +const useSearchParamsMock = vi.fn(() => new URLSearchParams()) +const mockUseTextGenerationAppState = vi.fn() +const mockUseTextGenerationBatch = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => useSearchParamsMock(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-app-state', () => ({ + useTextGenerationAppState: (...args: unknown[]) => mockUseTextGenerationAppState(...args), +})) + +vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-batch', () => ({ + useTextGenerationBatch: (...args: unknown[]) => mockUseTextGenerationBatch(...args), +})) + +vi.mock('@/app/components/share/text-generation/text-generation-sidebar', () => ({ + default: ({ + currentTab, + onTabChange, + }: { + currentTab: string + onTabChange: (tab: string) => void + }) => ( +
+ {currentTab} + + +
+ ), +})) + +vi.mock('@/app/components/share/text-generation/text-generation-result-panel', () => ({ + default: ({ + isCallBatchAPI, + resultExisted, + }: { + isCallBatchAPI: boolean + resultExisted: boolean + }) => ( +
+ ), +})) + +const createReadyAppState = () => ({ + accessMode: 'public', + appId: 'app-123', + appSourceType: 'published', + customConfig: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + handleRemoveSavedMessage: vi.fn(), + handleSaveMessage: vi.fn(), + moreLikeThisConfig: { + enabled: true, + }, + promptConfig: { + user_input_form: [], + }, + savedMessages: [], + siteInfo: { + title: 'Text Generation', + }, + systemFeatures: { + branding: { + enabled: false, + workspace_logo: null, + }, + }, + textToSpeechConfig: { + enabled: true, + }, + visionConfig: null, +}) + +const createBatchState = () => ({ + allFailedTaskList: [], + allSuccessTaskList: [], + allTaskList: [], + allTasksRun: false, + controlRetry: 0, + exportRes: vi.fn(), + handleCompleted: vi.fn(), + handleRetryAllFailedTask: vi.fn(), + handleRunBatch: vi.fn(), + isCallBatchAPI: false, + noPendingTask: true, + resetBatchExecution: vi.fn(), + setIsCallBatchAPI: vi.fn(), + showTaskList: false, +}) + +describe('Text Generation Mode Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + useSearchParamsMock.mockReturnValue(new URLSearchParams()) + mockUseTextGenerationAppState.mockReturnValue(createReadyAppState()) + mockUseTextGenerationBatch.mockReturnValue(createBatchState()) + }) + + it('shows the loading state before app metadata is ready', () => { + mockUseTextGenerationAppState.mockReturnValue({ + ...createReadyAppState(), + appId: '', + promptConfig: null, + siteInfo: null, + }) + + render() + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + }) + + it('hydrates the initial tab from the mode query parameter and lets the sidebar switch it', () => { + useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=batch')) + + render() + + expect(screen.getByTestId('current-tab')).toHaveTextContent('batch') + + fireEvent.click(screen.getByRole('button', { name: 'switch-to-create' })) + + expect(screen.getByTestId('current-tab')).toHaveTextContent('create') + }) + + it('falls back to create mode for unsupported query values', () => { + useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=unsupported')) + + render() + + expect(screen.getByTestId('current-tab')).toHaveTextContent('create') + expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute('data-batch', 'false') + }) +}) diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx new file mode 100644 index 0000000000..5b6ba8a64b --- /dev/null +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderList from '@/app/components/tools/provider-list' +import { CollectionType } from '@/app/components/tools/types' +import { renderWithNuqs } from '@/test/nuqs-testing' + +const mockInvalidateInstalledPluginList = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + enable_marketplace: true, + }, + }), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + getTagLabel: (name: string) => name, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: [ + { + id: 'builtin-plugin', + name: 'plugin-tool', + author: 'Dify', + description: { en_US: 'Plugin Tool' }, + icon: 'icon-plugin', + label: { en_US: 'Plugin Tool' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['search'], + plugin_id: 'langgenius/plugin-tool', + }, + { + id: 'builtin-basic', + name: 'basic-tool', + author: 'Dify', + description: { en_US: 'Basic Tool' }, + icon: 'icon-basic', + label: { en_US: 'Basic Tool' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['utility'], + }, + ], + refetch: vi.fn(), + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({ + data: enabled + ? { + plugins: [{ + plugin_id: 'langgenius/plugin-tool', + declaration: { + category: 'tool', + }, + }], + } + : null, + }), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('@/app/components/tools/labels/filter', () => ({ + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, className }: { payload: { name: string }, className?: string }) => ( +
+ {payload.name} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ tags }: { tags: string[] }) =>
{tags.join(',')}
, +})) + +vi.mock('@/app/components/tools/provider/detail', () => ({ + default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => ( +
+ {collection.name} + +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ + detail, + onHide, + onUpdate, + }: { + detail?: { plugin_id: string } + onHide: () => void + onUpdate: () => void + }) => detail + ? ( +
+ {detail.plugin_id} + + +
+ ) + : null, +})) + +vi.mock('@/app/components/tools/provider/empty', () => ({ + default: () =>
workflow empty
, +})) + +vi.mock('@/app/components/plugins/marketplace/empty', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/tools/marketplace', () => ({ + default: ({ + isMarketplaceArrowVisible, + showMarketplacePanel, + }: { + isMarketplaceArrowVisible: boolean + showMarketplacePanel: () => void + }) => ( + + ), +})) + +vi.mock('@/app/components/tools/marketplace/hooks', () => ({ + useMarketplace: () => ({ + handleScroll: vi.fn(), + }), +})) + +vi.mock('@/app/components/tools/mcp', () => ({ + default: ({ searchText }: { searchText: string }) =>
{searchText}
, +})) + +const renderProviderList = (searchParams = '') => { + return renderWithNuqs(, { searchParams }) +} + +describe('Tool Provider List Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + Element.prototype.scrollTo = vi.fn() + }) + + it('opens a plugin-backed provider detail panel and invalidates installed plugins on update', async () => { + renderProviderList('?category=builtin') + + fireEvent.click(screen.getByTestId('tool-card-plugin-tool')) + + await waitFor(() => { + expect(screen.getByTestId('tool-plugin-detail-panel')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'update-plugin-detail' })) + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'close-plugin-detail' })) + + await waitFor(() => { + expect(screen.queryByTestId('tool-plugin-detail-panel')).not.toBeInTheDocument() + }) + }) + + it('scrolls to the marketplace section and syncs workflow tab selection into the URL', async () => { + const { onUrlUpdate } = renderProviderList('?category=builtin') + + fireEvent.click(screen.getByTestId('marketplace-arrow')) + expect(Element.prototype.scrollTo).toHaveBeenCalled() + + fireEvent.click(screen.getByTestId('tab-item-workflow')) + + await waitFor(() => { + expect(screen.getByTestId('workflow-empty')).toBeInTheDocument() + }) + + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.get('category')).toBe('workflow') + }) +}) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 1df6fa79b7..dcc9f9c98e 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -18,6 +18,7 @@ const mockInvalidDatasetDetail = vi.fn() const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() +const mockToast = vi.fn() const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -111,6 +112,10 @@ vi.mock('@/service/datasets', () => ({ deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + vi.mock('@/app/components/datasets/rename-modal', () => ({ default: ({ show, @@ -225,4 +230,49 @@ describe('Dropdown callback coverage', () => { expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() }) }) + + it('should show the used-by-app confirmation copy when the dataset is referenced by apps', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockResolvedValueOnce({ is_using: true }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(screen.getByText('dataset.datasetUsedByApp')).toBeInTheDocument() + }) + }) + + it('should surface an export failure toast when pipeline export fails', async () => { + const user = userEvent.setup() + mockExportPipeline.mockRejectedValueOnce(new Error('export failed')) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) + }) + }) + + it('should surface the backend message when checking app usage fails', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockRejectedValueOnce({ + json: vi.fn().mockResolvedValue({ message: 'check failed' }), + }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' }) + }) + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index a1e275d731..bb85e00c14 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { DataSet } from '@/models/datasets' import { RiEditLine } from '@remixicon/react' -import { render, screen, waitFor } from '@testing-library/react' +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { @@ -218,6 +218,31 @@ describe('MenuItem', () => { // Assert expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should stop propagation before invoking the handler', () => { + const parentClick = vi.fn() + const handleClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('Edit')) + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should not crash when no click handler is provided', () => { + render() + + const event = createEvent.click(screen.getByText('Edit')) + fireEvent(screen.getByText('Edit'), event) + + expect(event.defaultPrevented).toBe(true) + }) }) }) @@ -265,6 +290,47 @@ describe('Menu', () => { expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) }) + + describe('Interactions', () => { + it('should invoke the rename callback when edit is clicked', async () => { + const user = userEvent.setup() + const openRenameModal = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('common.operation.edit')) + + expect(openRenameModal).toHaveBeenCalledTimes(1) + }) + + it('should invoke export and delete callbacks from their menu items', async () => { + const user = userEvent.setup() + const handleExportPipeline = vi.fn() + const detectIsUsedByApp = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + await user.click(screen.getByText('common.operation.delete')) + + expect(handleExportPipeline).toHaveBeenCalledTimes(1) + expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) + }) + }) }) describe('Dropdown', () => { diff --git a/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx new file mode 100644 index 0000000000..f43db2f5f9 --- /dev/null +++ b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react' +import { AppCardSkeleton } from '../app-card-skeleton' + +describe('AppCardSkeleton', () => { + it('should render six skeleton cards by default', () => { + const { container } = render() + + expect(container.childElementCount).toBe(6) + expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton') + }) + + it('should respect the custom skeleton count and card classes', () => { + const { container } = render() + + expect(container.childElementCount).toBe(2) + expect(container.firstElementChild).toHaveClass( + 'h-[160px]', + 'rounded-xl', + 'border-[0.5px]', + 'bg-components-card-bg', + 'p-4', + ) + }) +}) diff --git a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx new file mode 100644 index 0000000000..36e7cec67d --- /dev/null +++ b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx @@ -0,0 +1,144 @@ +import type { IChatItem } from '../type' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { fetchAgentLogDetail } from '@/service/log' +import ChatLogModals from '../chat-log-modals' + +vi.mock('@/service/log', () => ({ + fetchAgentLogDetail: vi.fn(), +})) + +describe('ChatLogModals', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ appDetail: { id: 'app-1' } as ReturnType['appDetail'] }) + }) + + // Modal visibility should follow the two booleans unless log modals are globally hidden. + describe('Rendering', () => { + it('should render real prompt and agent log modals when enabled', async () => { + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + , + ) + + expect(screen.getByText('PROMPT LOG')).toBeInTheDocument() + expect(screen.getByText('Prompt body')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + }) + }) + + it('should render nothing when hideLogModal is true', () => { + render( + , + ) + + expect(screen.queryByText('PROMPT LOG')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).not.toBeInTheDocument() + }) + }) + + // Cancel actions should clear the current item and close only the targeted modal. + describe('User Interactions', () => { + it('should close the prompt log modal through the real close action', async () => { + const user = userEvent.setup() + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('close-btn-container')) + + expect(setCurrentLogItem).toHaveBeenCalled() + expect(setShowPromptLogModal).toHaveBeenCalledWith(false) + expect(setShowAgentLogModal).not.toHaveBeenCalled() + }) + + it('should close the agent log modal through the real close action', async () => { + const user = userEvent.setup() + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + }) + await user.click(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling as HTMLElement) + + expect(setCurrentLogItem).toHaveBeenCalled() + expect(setShowAgentLogModal).toHaveBeenCalledWith(false) + expect(setShowPromptLogModal).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx new file mode 100644 index 0000000000..15da63e4d0 --- /dev/null +++ b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx @@ -0,0 +1,293 @@ +import type { ChatItem } from '../../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { useChatLayout } from '../use-chat-layout' + +type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void + +let capturedResizeCallbacks: ResizeCallback[] = [] +let disconnectSpy: ReturnType +let rafCallbacks: FrameRequestCallback[] = [] + +const makeChatItem = (overrides: Partial = {}): ChatItem => ({ + id: `item-${Math.random().toString(36).slice(2)}`, + content: 'Test content', + isAnswer: false, + ...overrides, +}) + +const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({ + borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + contentRect: new DOMRect(0, 0, inlineSize, blockSize), + devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + target: document.createElement('div'), +}) + +const assignMetric = (node: HTMLElement, key: 'clientWidth' | 'clientHeight' | 'scrollHeight', value: number) => { + Object.defineProperty(node, key, { + configurable: true, + value, + }) +} + +const LayoutHarness = ({ + chatList, + sidebarCollapseState, + attachRefs = true, +}: { + chatList: ChatItem[] + sidebarCollapseState?: boolean + attachRefs?: boolean +}) => { + const { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } = useChatLayout({ chatList, sidebarCollapseState }) + + return ( + <> +
{ + chatContainerRef.current = attachRefs ? node : null + if (node && attachRefs) { + assignMetric(node, 'clientWidth', 400) + assignMetric(node, 'clientHeight', 240) + assignMetric(node, 'scrollHeight', 640) + if (!node.dataset.metricsReady) { + node.scrollTop = 0 + node.dataset.metricsReady = 'true' + } + } + }} + > +
{ + chatContainerInnerRef.current = attachRefs ? node : null + if (node && attachRefs) + assignMetric(node, 'clientWidth', 360) + }} + /> +
+
{ + chatFooterRef.current = attachRefs ? node : null + }} + > +
{ + chatFooterInnerRef.current = attachRefs ? node : null + }} + /> +
+ {width} + + ) +} + +const flushAnimationFrames = () => { + const queuedCallbacks = [...rafCallbacks] + rafCallbacks = [] + queuedCallbacks.forEach(callback => callback(0)) +} + +describe('useChatLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + capturedResizeCallbacks = [] + disconnectSpy = vi.fn() + rafCallbacks = [] + + Object.defineProperty(document.body, 'clientWidth', { + configurable: true, + value: 1024, + }) + + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + vi.stubGlobal('ResizeObserver', class { + constructor(cb: ResizeCallback) { + capturedResizeCallbacks.push(cb) + } + + observe() { } + unobserve() { } + disconnect = disconnectSpy + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + // The hook should compute shell dimensions and auto-scroll when enough chat items exist. + describe('Layout Calculation', () => { + it('should auto-scroll and compute the chat shell widths on mount', () => { + const addSpy = vi.spyOn(window, 'addEventListener') + + render( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(screen.getByTestId('layout-width')).toHaveTextContent('600') + expect(screen.getByTestId('chat-footer').style.width).toBe('400px') + expect(screen.getByTestId('chat-footer-inner').style.width).toBe('360px') + expect((screen.getByTestId('chat-container') as HTMLDivElement).scrollTop).toBe(640) + expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + }) + }) + + // Resize observers should keep padding and widths in sync, then fully clean up on unmount. + describe('Resize Observers', () => { + it('should react to observer updates and disconnect both observers on unmount', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener') + const { unmount } = render( + , + ) + + act(() => { + capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver) + }) + expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px') + + act(() => { + capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver) + }) + expect(screen.getByTestId('chat-footer').style.width).toBe('560px') + + unmount() + + expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + expect(disconnectSpy).toHaveBeenCalledTimes(2) + }) + + it('should respect manual scrolling until a new first message arrives and safely ignore missing refs', () => { + const { rerender } = render( + , + ) + + const container = screen.getByTestId('chat-container') as HTMLDivElement + + act(() => { + fireEvent.scroll(container) + flushAnimationFrames() + }) + + act(() => { + container.scrollTop = 10 + fireEvent.scroll(container) + }) + + rerender( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + act(() => { + container.scrollTop = 420 + fireEvent.scroll(container) + }) + + rerender( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(container.scrollTop).toBe(640) + + rerender( + , + ) + + act(() => { + fireEvent.scroll(container) + flushAnimationFrames() + }) + }) + + it('should keep the hook stable when the DOM refs are not attached', () => { + render( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(screen.getByTestId('layout-width')).toHaveTextContent('0') + expect(capturedResizeCallbacks).toHaveLength(0) + expect(screen.getByTestId('chat-footer').style.width).toBe('') + expect(screen.getByTestId('chat-footer-inner').style.width).toBe('') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/chat-log-modals.tsx b/web/app/components/base/chat/chat/chat-log-modals.tsx new file mode 100644 index 0000000000..d1bf43b81c --- /dev/null +++ b/web/app/components/base/chat/chat/chat-log-modals.tsx @@ -0,0 +1,56 @@ +import type { FC } from 'react' +import type { IChatItem } from './type' +import AgentLogModal from '@/app/components/base/agent-log-modal' +import PromptLogModal from '@/app/components/base/prompt-log-modal' + +type ChatLogModalsProps = { + width: number + currentLogItem?: IChatItem + showPromptLogModal: boolean + showAgentLogModal: boolean + hideLogModal?: boolean + setCurrentLogItem: (item?: IChatItem) => void + setShowPromptLogModal: (showPromptLogModal: boolean) => void + setShowAgentLogModal: (showAgentLogModal: boolean) => void +} + +const ChatLogModals: FC = ({ + width, + currentLogItem, + showPromptLogModal, + showAgentLogModal, + hideLogModal, + setCurrentLogItem, + setShowPromptLogModal, + setShowAgentLogModal, +}) => { + if (hideLogModal) + return null + + return ( + <> + {showPromptLogModal && ( + { + setCurrentLogItem() + setShowPromptLogModal(false) + }} + /> + )} + {showAgentLogModal && ( + { + setCurrentLogItem() + setShowAgentLogModal(false) + }} + /> + )} + + ) +} + +export default ChatLogModals diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index ed44c8719d..f04169327f 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -13,26 +13,19 @@ import type { import type { InputForm } from './type' import type { Emoji } from '@/app/components/tools/types' import type { AppData } from '@/models/share' -import { debounce } from 'es-toolkit/compat' -import { - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' -import AgentLogModal from '@/app/components/base/agent-log-modal' import Button from '@/app/components/base/button' -import PromptLogModal from '@/app/components/base/prompt-log-modal' import { cn } from '@/utils/classnames' import Answer from './answer' import ChatInputArea from './chat-input-area' +import ChatLogModals from './chat-log-modals' import { ChatContextProvider } from './context-provider' import Question from './question' import TryToAsk from './try-to-ask' +import { useChatLayout } from './use-chat-layout' export type ChatProps = { isTryApp?: boolean @@ -133,128 +126,17 @@ const Chat: FC = ({ showAgentLogModal: state.showAgentLogModal, setShowAgentLogModal: state.setShowAgentLogModal, }))) - const [width, setWidth] = useState(0) - const chatContainerRef = useRef(null) - const chatContainerInnerRef = useRef(null) - const chatFooterRef = useRef(null) - const chatFooterInnerRef = useRef(null) - const userScrolledRef = useRef(false) - const isAutoScrollingRef = useRef(false) - - const handleScrollToBottom = useCallback(() => { - if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { - isAutoScrollingRef.current = true - chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight - - requestAnimationFrame(() => { - isAutoScrollingRef.current = false - }) - } - }, [chatList.length]) - - const handleWindowResize = useCallback(() => { - if (chatContainerRef.current) - setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8) - - if (chatContainerRef.current && chatFooterRef.current) - chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` - - if (chatContainerInnerRef.current && chatFooterInnerRef.current) - chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` - }, []) - - useEffect(() => { - handleScrollToBottom() - handleWindowResize() - }, [handleScrollToBottom, handleWindowResize]) - - useEffect(() => { - /* v8 ignore next - @preserve */ - if (chatContainerRef.current) { - requestAnimationFrame(() => { - handleScrollToBottom() - handleWindowResize() - }) - } + const { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } = useChatLayout({ + chatList, + sidebarCollapseState, }) - useEffect(() => { - const debouncedHandler = debounce(handleWindowResize, 200) - window.addEventListener('resize', debouncedHandler) - - return () => { - window.removeEventListener('resize', debouncedHandler) - debouncedHandler.cancel() - } - }, [handleWindowResize]) - - useEffect(() => { - /* v8 ignore next - @preserve */ - if (chatFooterRef.current && chatContainerRef.current) { - const resizeContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { blockSize } = entry.borderBoxSize[0] - chatContainerRef.current!.style.paddingBottom = `${blockSize}px` - handleScrollToBottom() - } - }) - resizeContainerObserver.observe(chatFooterRef.current) - - const resizeFooterObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { inlineSize } = entry.borderBoxSize[0] - chatFooterRef.current!.style.width = `${inlineSize}px` - } - }) - resizeFooterObserver.observe(chatContainerRef.current) - - return () => { - resizeContainerObserver.disconnect() - resizeFooterObserver.disconnect() - } - } - }, [handleScrollToBottom]) - - useEffect(() => { - const setUserScrolled = () => { - const container = chatContainerRef.current - /* v8 ignore next 2 - @preserve */ - if (!container) - return - /* v8 ignore next 2 - @preserve */ - if (isAutoScrollingRef.current) - return - - const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop - const SCROLL_UP_THRESHOLD = 100 - - userScrolledRef.current = distanceToBottom > SCROLL_UP_THRESHOLD - } - - const container = chatContainerRef.current - /* v8 ignore next 2 - @preserve */ - if (!container) - return - - container.addEventListener('scroll', setUserScrolled) - return () => container.removeEventListener('scroll', setUserScrolled) - }, []) - - const prevFirstMessageIdRef = useRef(undefined) - useEffect(() => { - const firstMessageId = chatList[0]?.id - if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) - userScrolledRef.current = false - prevFirstMessageIdRef.current = firstMessageId - }, [chatList]) - - useEffect(() => { - if (!sidebarCollapseState) { - const timer = setTimeout(handleWindowResize, 200) - return () => clearTimeout(timer) - } - }, [handleWindowResize, sidebarCollapseState]) - const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend return ( @@ -279,7 +161,7 @@ const Chat: FC = ({
{chatNode}
= ({ !noStopResponding && isResponding && (
@@ -375,26 +257,16 @@ const Chat: FC = ({ }
- {showPromptLogModal && !hideLogModal && ( - { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} - {showAgentLogModal && !hideLogModal && ( - { - setCurrentLogItem() - setShowAgentLogModal(false) - }} - /> - )} +
) diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts new file mode 100644 index 0000000000..41f622c523 --- /dev/null +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -0,0 +1,144 @@ +import type { ChatItem } from '../types' +import { debounce } from 'es-toolkit/compat' +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +type UseChatLayoutOptions = { + chatList: ChatItem[] + sidebarCollapseState?: boolean +} + +export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { + const [width, setWidth] = useState(0) + const chatContainerRef = useRef(null) + const chatContainerInnerRef = useRef(null) + const chatFooterRef = useRef(null) + const chatFooterInnerRef = useRef(null) + const userScrolledRef = useRef(false) + const isAutoScrollingRef = useRef(false) + const prevFirstMessageIdRef = useRef(undefined) + + const handleScrollToBottom = useCallback(() => { + if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { + isAutoScrollingRef.current = true + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight + + requestAnimationFrame(() => { + isAutoScrollingRef.current = false + }) + } + }, [chatList.length]) + + const handleWindowResize = useCallback(() => { + if (chatContainerRef.current) + setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8) + + if (chatContainerRef.current && chatFooterRef.current) + chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` + + if (chatContainerInnerRef.current && chatFooterInnerRef.current) + chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` + }, []) + + useEffect(() => { + handleScrollToBottom() + const animationFrame = requestAnimationFrame(handleWindowResize) + + return () => { + cancelAnimationFrame(animationFrame) + } + }, [handleScrollToBottom, handleWindowResize]) + + useEffect(() => { + if (chatContainerRef.current) { + requestAnimationFrame(() => { + handleScrollToBottom() + handleWindowResize() + }) + } + }) + + useEffect(() => { + const debouncedHandler = debounce(handleWindowResize, 200) + window.addEventListener('resize', debouncedHandler) + + return () => { + window.removeEventListener('resize', debouncedHandler) + debouncedHandler.cancel() + } + }, [handleWindowResize]) + + useEffect(() => { + if (chatFooterRef.current && chatContainerRef.current) { + const resizeContainerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { blockSize } = entry.borderBoxSize[0] + chatContainerRef.current!.style.paddingBottom = `${blockSize}px` + handleScrollToBottom() + } + }) + resizeContainerObserver.observe(chatFooterRef.current) + + const resizeFooterObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { inlineSize } = entry.borderBoxSize[0] + chatFooterRef.current!.style.width = `${inlineSize}px` + } + }) + resizeFooterObserver.observe(chatContainerRef.current) + + return () => { + resizeContainerObserver.disconnect() + resizeFooterObserver.disconnect() + } + } + }, [handleScrollToBottom]) + + useEffect(() => { + const setUserScrolled = () => { + const container = chatContainerRef.current + if (!container) + return + if (isAutoScrollingRef.current) + return + + const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop + const scrollUpThreshold = 100 + + userScrolledRef.current = distanceToBottom > scrollUpThreshold + } + + const container = chatContainerRef.current + if (!container) + return + + container.addEventListener('scroll', setUserScrolled) + return () => container.removeEventListener('scroll', setUserScrolled) + }, []) + + useEffect(() => { + const firstMessageId = chatList[0]?.id + if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) + userScrolledRef.current = false + prevFirstMessageIdRef.current = firstMessageId + }, [chatList]) + + useEffect(() => { + if (!sidebarCollapseState) { + const timer = setTimeout(handleWindowResize, 200) + return () => clearTimeout(timer) + } + }, [handleWindowResize, sidebarCollapseState]) + + return { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } +} diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx new file mode 100644 index 0000000000..dba53d7642 --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/page-row.spec.tsx @@ -0,0 +1,113 @@ +import type { ComponentProps } from 'react' +import type { NotionPageRow } from '../types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PageRow from '../page-row' + +const buildRow = (overrides: Partial = {}): NotionPageRow => ({ + page: { + page_id: 'page-1', + page_name: 'Page 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + parentExists: false, + depth: 0, + expand: false, + hasChild: false, + ancestors: [], + ...overrides, +}) + +const renderPageRow = (overrides: Partial> = {}) => { + const props: ComponentProps = { + checked: false, + disabled: false, + isPreviewed: false, + onPreview: vi.fn(), + onSelect: vi.fn(), + onToggle: vi.fn(), + row: buildRow(), + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + style: { height: 28 }, + ...overrides, + } + + return { + ...render(), + props, + } +} + +describe('PageRow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call onSelect with the page id when the checkbox is clicked', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + renderPageRow({ onSelect }) + + await user.click(screen.getByTestId('checkbox-notion-page-checkbox-page-1')) + + expect(onSelect).toHaveBeenCalledWith('page-1') + }) + + it('should call onToggle when the row has children and the toggle is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + + renderPageRow({ + onToggle, + row: buildRow({ + hasChild: true, + expand: true, + }), + }) + + await user.click(screen.getByTestId('notion-page-toggle-page-1')) + + expect(onToggle).toHaveBeenCalledWith('page-1') + }) + + it('should render breadcrumbs and hide the toggle while searching', () => { + renderPageRow({ + searchValue: 'Page', + row: buildRow({ + parentExists: true, + ancestors: ['Workspace', 'Section'], + }), + }) + + expect(screen.queryByTestId('notion-page-toggle-page-1')).not.toBeInTheDocument() + expect(screen.getByText('Workspace / Section / Page 1')).toBeInTheDocument() + }) + + it('should render preview state and call onPreview when the preview button is clicked', async () => { + const user = userEvent.setup() + const onPreview = vi.fn() + + renderPageRow({ + isPreviewed: true, + onPreview, + }) + + expect(screen.getByTestId('notion-page-row-page-1')).toHaveClass('bg-state-base-hover') + + await user.click(screen.getByTestId('notion-page-preview-page-1')) + + expect(onPreview).toHaveBeenCalledWith('page-1') + }) + + it('should hide the preview button when showPreview is false', () => { + renderPageRow({ showPreview: false }) + + expect(screen.queryByTestId('notion-page-preview-page-1')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts new file mode 100644 index 0000000000..d90c50308d --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts @@ -0,0 +1,127 @@ +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { act, renderHook, waitFor } from '@testing-library/react' +import { usePageSelectorModel } from '../use-page-selector-model' + +const buildPage = (overrides: Partial): DataSourceNotionPage => ({ + page_id: 'page-id', + page_name: 'Page name', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + ...overrides, +}) + +const list: DataSourceNotionPage[] = [ + buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }), + buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }), + buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }), + buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }), +] + +const pagesMap: DataSourceNotionPageMap = { + 'root-1': { ...list[0], workspace_id: 'workspace-1' }, + 'child-1': { ...list[1], workspace_id: 'workspace-1' }, + 'grandchild-1': { ...list[2], workspace_id: 'workspace-1' }, + 'child-2': { ...list[3], workspace_id: 'workspace-1' }, +} + +const createProps = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + checkedIds: new Set(), + searchValue: '', + pagesMap, + list, + onSelect: vi.fn(), + previewPageId: undefined, + onPreview: vi.fn(), + selectionMode: 'multiple', + ...overrides, +}) + +describe('usePageSelectorModel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build visible rows from the expanded tree state', async () => { + const { result } = renderHook(() => usePageSelectorModel(createProps())) + + expect(result.current.rows.map(row => row.page.page_id)).toEqual(['root-1']) + + act(() => { + result.current.handleToggle('root-1') + }) + + await waitFor(() => { + expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'child-2', + ]) + }) + + act(() => { + result.current.handleToggle('child-1') + }) + + await waitFor(() => { + expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ]) + }) + }) + + it('should select descendants when selecting a parent in multiple mode', () => { + const onSelect = vi.fn() + const { result } = renderHook(() => usePageSelectorModel(createProps({ onSelect }))) + + act(() => { + result.current.handleSelect('root-1') + }) + + expect(onSelect).toHaveBeenCalledWith(new Set([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ])) + }) + + it('should update local preview and respect the controlled previewPageId when provided', () => { + const onPreview = vi.fn() + const { result, rerender } = renderHook( + props => usePageSelectorModel(props), + { initialProps: createProps({ onPreview }) }, + ) + + act(() => { + result.current.handlePreview('child-1') + }) + + expect(onPreview).toHaveBeenCalledWith('child-1') + expect(result.current.currentPreviewPageId).toBe('child-1') + + rerender(createProps({ onPreview, previewPageId: 'grandchild-1' })) + + expect(result.current.currentPreviewPageId).toBe('grandchild-1') + }) + + it('should expose filtered rows when the deferred search value changes', async () => { + const { result, rerender } = renderHook( + props => usePageSelectorModel(props), + { initialProps: createProps() }, + ) + + rerender(createProps({ searchValue: 'Grandchild' })) + + await waitFor(() => { + expect(result.current.effectiveSearchValue).toBe('Grandchild') + expect(result.current.rows.map(row => row.page.page_id)).toEqual(['grandchild-1']) + }) + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts new file mode 100644 index 0000000000..2e6005c573 --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts @@ -0,0 +1,118 @@ +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { + buildNotionPageTree, + getNextSelectedPageIds, + getRootPageIds, + getVisiblePageRows, +} from '../utils' + +const buildPage = (overrides: Partial): DataSourceNotionPage => ({ + page_id: 'page-id', + page_name: 'Page name', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + ...overrides, +}) + +const list: DataSourceNotionPage[] = [ + buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }), + buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }), + buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }), + buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }), + buildPage({ page_id: 'orphan-1', page_name: 'Orphan 1', parent_id: 'missing-parent' }), +] + +const pagesMap: DataSourceNotionPageMap = { + 'root-1': { ...list[0], workspace_id: 'workspace-1' }, + 'child-1': { ...list[1], workspace_id: 'workspace-1' }, + 'grandchild-1': { ...list[2], workspace_id: 'workspace-1' }, + 'child-2': { ...list[3], workspace_id: 'workspace-1' }, + 'orphan-1': { ...list[4], workspace_id: 'workspace-1' }, +} + +describe('page-selector utils', () => { + it('should build a tree with descendants, depth, and ancestors', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + expect(treeMap['root-1'].children).toEqual(new Set(['child-1', 'child-2'])) + expect(treeMap['root-1'].descendants).toEqual(new Set(['child-1', 'grandchild-1', 'child-2'])) + expect(treeMap['grandchild-1'].depth).toBe(2) + expect(treeMap['grandchild-1'].ancestors).toEqual(['Root 1', 'Child 1']) + }) + + it('should return root page ids for true roots and pages with missing parents', () => { + expect(getRootPageIds(list, pagesMap)).toEqual(['root-1', 'orphan-1']) + }) + + it('should return expanded tree rows in depth-first order when not searching', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + const rows = getVisiblePageRows({ + list, + pagesMap, + searchValue: '', + treeMap, + rootPageIds: ['root-1'], + expandedIds: new Set(['root-1', 'child-1']), + }) + + expect(rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ]) + }) + + it('should return filtered search rows with ancestry metadata when searching', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + const rows = getVisiblePageRows({ + list, + pagesMap, + searchValue: 'Grandchild', + treeMap, + rootPageIds: ['root-1'], + expandedIds: new Set(), + }) + + expect(rows).toEqual([ + expect.objectContaining({ + page: expect.objectContaining({ page_id: 'grandchild-1' }), + ancestors: ['Root 1', 'Child 1'], + hasChild: false, + parentExists: true, + }), + ]) + }) + + it('should toggle selected ids correctly in single and multiple mode', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(['root-1']), + pageId: 'child-1', + searchValue: '', + selectionMode: 'single', + treeMap, + })).toEqual(new Set(['child-1'])) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(), + pageId: 'root-1', + searchValue: '', + selectionMode: 'multiple', + treeMap, + })).toEqual(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(['child-1']), + pageId: 'child-1', + searchValue: 'Child', + selectionMode: 'multiple', + treeMap, + })).toEqual(new Set()) + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx new file mode 100644 index 0000000000..7ad4f29d3e --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx @@ -0,0 +1,144 @@ +import type { ComponentProps } from 'react' +import type { NotionPageRow } from '../types' +import { render, screen } from '@testing-library/react' +import VirtualPageList from '../virtual-page-list' + +vi.mock('@tanstack/react-virtual') + +const pageRowPropsSpy = vi.fn() +type MockPageRowProps = ComponentProps + +vi.mock('../page-row', () => ({ + default: ({ + checked, + disabled, + isPreviewed, + onPreview, + onSelect, + onToggle, + row, + searchValue, + selectionMode, + showPreview, + style, + }: MockPageRowProps) => { + pageRowPropsSpy({ + checked, + disabled, + isPreviewed, + onPreview, + onSelect, + onToggle, + row, + searchValue, + selectionMode, + showPreview, + style, + }) + return
+ }, +})) + +const buildRow = (overrides: Partial = {}): NotionPageRow => ({ + page: { + page_id: 'page-1', + page_name: 'Page 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + parentExists: false, + depth: 0, + expand: false, + hasChild: false, + ancestors: [], + ...overrides, +}) + +describe('VirtualPageList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render virtual rows and pass row state to PageRow', () => { + const rows = [ + buildRow(), + buildRow({ + page: { + page_id: 'page-2', + page_name: 'Page 2', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + }), + ] + + render( + , + ) + + expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + expect(screen.getByTestId('page-row-page-1')).toBeInTheDocument() + expect(screen.getByTestId('page-row-page-2')).toBeInTheDocument() + expect(pageRowPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ + checked: true, + disabled: false, + isPreviewed: false, + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + row: rows[0], + style: expect.objectContaining({ + height: '28px', + width: 'calc(100% - 16px)', + }), + })) + expect(pageRowPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ + checked: false, + disabled: true, + isPreviewed: true, + row: rows[1], + })) + }) + + it('should size the virtual container using the row estimate', () => { + const rows = [buildRow(), buildRow()] + + render( + ()} + disabledValue={new Set()} + onPreview={vi.fn()} + onSelect={vi.fn()} + onToggle={vi.fn()} + previewPageId="" + rows={rows} + searchValue="" + selectionMode="multiple" + showPreview={false} + />, + ) + + const list = screen.getByTestId('virtual-list') + const innerContainer = list.firstElementChild as HTMLElement + + expect(innerContainer).toHaveStyle({ + height: '56px', + position: 'relative', + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx new file mode 100644 index 0000000000..02de482073 --- /dev/null +++ b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx @@ -0,0 +1,295 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { LexicalEditor } from 'lexical' +import type { ComponentProps } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { CodeNode } from '@lexical/code' +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + BLUR_COMMAND, + COMMAND_PRIORITY_EDITOR, + createCommand, + FOCUS_COMMAND, + TextNode, +} from 'lexical' +import { useEffect } from 'react' +import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import { ContextBlockNode } from '../plugins/context-block' +import { CurrentBlockNode } from '../plugins/current-block' +import { CustomTextNode } from '../plugins/custom-text/node' +import { ErrorMessageBlockNode } from '../plugins/error-message-block' +import { HistoryBlockNode } from '../plugins/history-block' +import { HITLInputNode } from '../plugins/hitl-input-block' +import { LastRunBlockNode } from '../plugins/last-run-block' +import { QueryBlockNode } from '../plugins/query-block' +import { RequestURLBlockNode } from '../plugins/request-url-block' +import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '../plugins/update-block' +import { VariableValueBlockNode } from '../plugins/variable-value-block/node' +import { WorkflowVariableBlockNode } from '../plugins/workflow-variable-block' +import PromptEditorContent from '../prompt-editor-content' +import { textToEditorState } from '../utils' + +type Captures = { + editor: LexicalEditor | null + eventEmitter: EventEmitter | null +} + +const mockDOMRect = { + x: 100, + y: 100, + width: 100, + height: 20, + top: 100, + right: 200, + bottom: 120, + left: 100, + toJSON: () => ({}), +} + +const originalRangeGetClientRects = Range.prototype.getClientRects +const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect + +const setSelectionOnEditable = (editable: HTMLElement) => { + const lexicalTextNode = editable.querySelector('[data-lexical-text="true"]')?.firstChild + const range = document.createRange() + + if (lexicalTextNode) { + range.setStart(lexicalTextNode, 0) + range.setEnd(lexicalTextNode, 1) + } + else { + range.selectNodeContents(editable) + } + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +const CaptureEditorAndEmitter = ({ captures }: { captures: Captures }) => { + const { eventEmitter } = useEventEmitterContextContext() + const [editor] = useLexicalComposerContext() + + useEffect(() => { + captures.editor = editor + }, [captures, editor]) + + useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + return null +} + +const PromptEditorContentHarness = ({ + captures, + initialText = '', + ...props +}: ComponentProps & { captures: Captures, initialText?: string }) => ( + + new CustomTextNode(node.__text), + withKlass: CustomTextNode, + }, + ContextBlockNode, + HistoryBlockNode, + QueryBlockNode, + RequestURLBlockNode, + WorkflowVariableBlockNode, + VariableValueBlockNode, + HITLInputNode, + CurrentBlockNode, + ErrorMessageBlockNode, + LastRunBlockNode, + ], + editorState: textToEditorState(initialText), + onError: (error: Error) => { + throw error + }, + }} + > + + + + +) + +describe('PromptEditorContent', () => { + beforeAll(() => { + Range.prototype.getClientRects = vi.fn(() => { + const rectList = [mockDOMRect] as unknown as DOMRectList + Object.defineProperty(rectList, 'length', { value: 1 }) + Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null }) + return rectList + }) + Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterAll(() => { + Range.prototype.getClientRects = originalRangeGetClientRects + Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect + }) + + // The extracted content shell should run with the real Lexical stack and forward editor commands through its composed plugins. + describe('Rendering', () => { + it('should render with real dependencies and forward update/focus/blur events', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onEditorChange = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const anchorElem = document.createElement('div') + + const { container } = render( + , + ) + + expect(screen.getByText('Type prompt')).toBeInTheDocument() + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + expect(editable.className).toContain('text-[13px]') + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + expect(captures.eventEmitter).not.toBeNull() + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'content-editor', + payload: 'updated prompt', + }) + }) + + await waitFor(() => { + expect(onEditorChange).toHaveBeenCalled() + }) + + act(() => { + captures.editor?.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus')) + captures.editor?.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: null })) + }) + + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenCalledTimes(1) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render optional blocks and open shortcut popups with the real editor runtime', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onEditorChange = vi.fn() + const insertCommand = createCommand('prompt-editor-shortcut-insert') + const insertSpy = vi.fn() + const Popup = ({ onClose, onInsert }: { onClose: () => void, onInsert: (command: typeof insertCommand, params: string[]) => void }) => ( + <> + + + + ) + + const { container } = render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + + const unregister = captures.editor?.registerCommand( + insertCommand, + (payload) => { + insertSpy(payload) + return true + }, + COMMAND_PRIORITY_EDITOR, + ) + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + editable.focus() + setSelectionOnEditable(editable) + + fireEvent.keyDown(document, { key: '/', ctrlKey: true }) + + const insertButton = await screen.findByRole('button', { name: 'Insert shortcut' }) + fireEvent.click(insertButton) + + expect(insertSpy).toHaveBeenCalledWith(['from-shortcut']) + expect(onEditorChange).toHaveBeenCalled() + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Insert shortcut' })).not.toBeInTheDocument() + }) + + unregister?.() + }) + + it('should keep the shell stable without optional anchor or placeholder overrides', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + + expect(screen.queryByTestId('draggable-target-line')).not.toBeInTheDocument() + expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 772d15e4cf..6f6da6901b 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -22,11 +22,6 @@ import type { } from './types' import { CodeNode } from '@lexical/code' import { LexicalComposer } from '@lexical/react/LexicalComposer' -import { ContentEditable } from '@lexical/react/LexicalContentEditable' -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import { $getRoot, TextNode, @@ -39,63 +34,37 @@ import { UPDATE_DATASETS_EVENT_EMITTER, UPDATE_HISTORY_EVENT_EMITTER, } from './constants' -import ComponentPickerBlock from './plugins/component-picker-block' import { - ContextBlock, ContextBlockNode, - ContextBlockReplacementBlock, } from './plugins/context-block' import { - CurrentBlock, CurrentBlockNode, - CurrentBlockReplacementBlock, } from './plugins/current-block' import { CustomTextNode } from './plugins/custom-text/node' -import DraggableBlockPlugin from './plugins/draggable-plugin' import { - ErrorMessageBlock, ErrorMessageBlockNode, - ErrorMessageBlockReplacementBlock, } from './plugins/error-message-block' import { - HistoryBlock, HistoryBlockNode, - HistoryBlockReplacementBlock, } from './plugins/history-block' import { - HITLInputBlock, - HITLInputBlockReplacementBlock, HITLInputNode, } from './plugins/hitl-input-block' import { - LastRunBlock, LastRunBlockNode, - LastRunReplacementBlock, } from './plugins/last-run-block' -import OnBlurBlock from './plugins/on-blur-or-focus-block' -// import TreeView from './plugins/tree-view' -import Placeholder from './plugins/placeholder' import { - QueryBlock, QueryBlockNode, - QueryBlockReplacementBlock, } from './plugins/query-block' import { - RequestURLBlock, RequestURLBlockNode, - RequestURLBlockReplacementBlock, } from './plugins/request-url-block' -import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' -import UpdateBlock from './plugins/update-block' -import VariableBlock from './plugins/variable-block' -import VariableValueBlock from './plugins/variable-value-block' import { VariableValueBlockNode } from './plugins/variable-value-block/node' import { - WorkflowVariableBlock, WorkflowVariableBlockNode, - WorkflowVariableBlockReplacementBlock, } from './plugins/workflow-variable-block' +import PromptEditorContent from './prompt-editor-content' import { textToEditorState } from './utils' export type PromptEditorProps = { @@ -214,152 +183,31 @@ const PromptEditor: FC = ({ return (
- - )} - placeholder={( - - )} - ErrorBoundary={LexicalErrorBoundary} - /> - {shortcutPopups?.map(({ hotkey, Popup }, idx) => ( - - {(closePortal, onInsert) => } - - ))} - - - { - contextBlock?.show && ( - <> - - - - ) - } - { - queryBlock?.show && ( - <> - - - - ) - } - { - historyBlock?.show && ( - <> - - - - ) - } - { - (variableBlock?.show || externalToolBlock?.show) && ( - <> - - - - ) - } - { - workflowVariableBlock?.show && ( - <> - - - - ) - } - { - hitlInputBlock?.show && ( - <> - - - - ) - } - { - currentBlock?.show && ( - <> - - - - ) - } - { - requestURLBlock?.show && ( - <> - - - - ) - } - { - errorMessageBlock?.show && ( - <> - - - - ) - } - { - lastRunBlock?.show && ( - <> - - - - ) - } - { - isSupportFileVar && ( - - ) - } - - - - - {floatingAnchorElem && ( - - )} - {/* */}
) diff --git a/web/app/components/base/prompt-editor/prompt-editor-content.tsx b/web/app/components/base/prompt-editor/prompt-editor-content.tsx new file mode 100644 index 0000000000..07db69cfc8 --- /dev/null +++ b/web/app/components/base/prompt-editor/prompt-editor-content.tsx @@ -0,0 +1,257 @@ +import type { + EditorState, + LexicalCommand, +} from 'lexical' +import type { FC } from 'react' +import type { Hotkey } from './plugins/shortcuts-popup-plugin' +import type { + ContextBlockType, + CurrentBlockType, + ErrorMessageBlockType, + ExternalToolBlockType, + HistoryBlockType, + HITLInputBlockType, + LastRunBlockType, + QueryBlockType, + RequestURLBlockType, + VariableBlockType, + WorkflowVariableBlockType, +} from './types' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import * as React from 'react' +import { cn } from '@/utils/classnames' +import ComponentPickerBlock from './plugins/component-picker-block' +import { + ContextBlock, + ContextBlockReplacementBlock, +} from './plugins/context-block' +import { + CurrentBlock, + CurrentBlockReplacementBlock, +} from './plugins/current-block' +import DraggableBlockPlugin from './plugins/draggable-plugin' +import { + ErrorMessageBlock, + ErrorMessageBlockReplacementBlock, +} from './plugins/error-message-block' +import { + HistoryBlock, + HistoryBlockReplacementBlock, +} from './plugins/history-block' +import { + HITLInputBlock, + HITLInputBlockReplacementBlock, +} from './plugins/hitl-input-block' +import { + LastRunBlock, + LastRunReplacementBlock, +} from './plugins/last-run-block' +import OnBlurBlock from './plugins/on-blur-or-focus-block' +import Placeholder from './plugins/placeholder' +import { + QueryBlock, + QueryBlockReplacementBlock, +} from './plugins/query-block' +import { + RequestURLBlock, + RequestURLBlockReplacementBlock, +} from './plugins/request-url-block' +import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' +import UpdateBlock from './plugins/update-block' +import VariableBlock from './plugins/variable-block' +import VariableValueBlock from './plugins/variable-value-block' +import { + WorkflowVariableBlock, + WorkflowVariableBlockReplacementBlock, +} from './plugins/workflow-variable-block' + +type ShortcutPopup = { + hotkey: Hotkey + Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand, params: unknown[]) => void }> +} + +type PromptEditorContentProps = { + compact?: boolean + className?: string + placeholder?: string | React.ReactNode + placeholderClassName?: string + style?: React.CSSProperties + shortcutPopups: ShortcutPopup[] + contextBlock?: ContextBlockType + queryBlock?: QueryBlockType + requestURLBlock?: RequestURLBlockType + historyBlock?: HistoryBlockType + variableBlock?: VariableBlockType + externalToolBlock?: ExternalToolBlockType + workflowVariableBlock?: WorkflowVariableBlockType + hitlInputBlock?: HITLInputBlockType + currentBlock?: CurrentBlockType + errorMessageBlock?: ErrorMessageBlockType + lastRunBlock?: LastRunBlockType + isSupportFileVar?: boolean + onBlur?: () => void + onFocus?: () => void + instanceId?: string + floatingAnchorElem: HTMLDivElement | null + onEditorChange: (editorState: EditorState) => void +} + +const PromptEditorContent: FC = ({ + compact, + className, + placeholder, + placeholderClassName, + style, + shortcutPopups, + contextBlock, + queryBlock, + requestURLBlock, + historyBlock, + variableBlock, + externalToolBlock, + workflowVariableBlock, + hitlInputBlock, + currentBlock, + errorMessageBlock, + lastRunBlock, + isSupportFileVar, + onBlur, + onFocus, + instanceId, + floatingAnchorElem, + onEditorChange, +}) => { + return ( + <> + + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> + {shortcutPopups.map(({ hotkey, Popup }, idx) => ( + + {(closePortal, onInsert) => } + + ))} + + + {contextBlock?.show && ( + <> + + + + )} + {queryBlock?.show && ( + <> + + + + )} + {historyBlock?.show && ( + <> + + + + )} + {(variableBlock?.show || externalToolBlock?.show) && ( + <> + + + + )} + {workflowVariableBlock?.show && ( + <> + + + + )} + {hitlInputBlock?.show && ( + <> + + + + )} + {currentBlock?.show && ( + <> + + + + )} + {requestURLBlock?.show && ( + <> + + + + )} + {errorMessageBlock?.show && ( + <> + + + + )} + {lastRunBlock?.show && ( + <> + + + + )} + {isSupportFileVar && ( + + )} + + + + + {floatingAnchorElem && ( + + )} + + ) +} + +export default PromptEditorContent diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx new file mode 100644 index 0000000000..ada7aa7b7f --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import Loading from '../loading' + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children, className }: { children?: ReactNode, className?: string }) => ( +
{children}
+ ), + SkeletonRectangle: ({ className }: { className?: string }) => ( +
+ ), +})) + +describe('CreateFromPipelinePreviewLoading', () => { + it('should render the preview loading shell and all skeleton blocks', () => { + const { container } = render() + + expect(container.firstElementChild).toHaveClass( + 'flex', + 'h-full', + 'w-full', + 'flex-col', + 'overflow-hidden', + 'px-6', + 'py-5', + ) + expect(screen.getAllByTestId('skeleton-container')).toHaveLength(6) + expect(screen.getAllByTestId('skeleton-rectangle')).toHaveLength(29) + }) +}) diff --git a/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx new file mode 100644 index 0000000000..9524998290 --- /dev/null +++ b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { DocumentContext, useDocumentContext } from '../context' + +describe('DocumentContext', () => { + it('should return the default empty context value when no provider is present', () => { + const { result } = renderHook(() => useDocumentContext(value => value)) + + expect(result.current).toEqual({}) + }) + + it('should select values from the nearest provider', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook( + () => useDocumentContext(value => `${value.datasetId}:${value.documentId}`), + { wrapper }, + ) + + expect(result.current).toBe('dataset-1:document-1') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx new file mode 100644 index 0000000000..f3fa0d0929 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { SegmentListContext, useSegmentListContext } from '../segment-list-context' + +describe('SegmentListContext', () => { + it('should expose the default collapsed state', () => { + const { result } = renderHook(() => useSegmentListContext(value => value)) + + expect(result.current).toEqual({ + isCollapsed: true, + fullScreen: false, + toggleFullScreen: expect.any(Function), + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }) + }) + + it('should select provider values from the current segment list context', () => { + const toggleFullScreen = vi.fn() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook( + () => useSegmentListContext(value => ({ + fullScreen: value.fullScreen, + segmentOpen: value.currSegment.showModal, + childOpen: value.currChildChunk.showModal, + })), + { wrapper }, + ) + + expect(result.current).toEqual({ + fullScreen: true, + segmentOpen: true, + childOpen: true, + }) + }) +}) diff --git a/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx new file mode 100644 index 0000000000..b153f24179 --- /dev/null +++ b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx @@ -0,0 +1,125 @@ +import type { TocItem } from '../use-doc-toc' +import { act, renderHook } from '@testing-library/react' +import { useDocToc } from '../use-doc-toc' + +const mockMatchMedia = (matches: boolean) => { + vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))) +} + +const setupDocument = () => { + document.body.innerHTML = ` +
+ + ` + + const scrollContainer = document.querySelector('.overflow-auto') as HTMLDivElement + scrollContainer.scrollTo = vi.fn() + + const intro = document.getElementById('intro') as HTMLElement + const details = document.getElementById('details') as HTMLElement + + Object.defineProperty(intro, 'offsetTop', { configurable: true, value: 140 }) + Object.defineProperty(details, 'offsetTop', { configurable: true, value: 320 }) + + return { + scrollContainer, + intro, + details, + } +} + +describe('useDocToc', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + document.body.innerHTML = '' + mockMatchMedia(false) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('should extract headings and expand the TOC on wide screens', async () => { + setupDocument() + mockMatchMedia(true) + + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + expect(result.current.toc).toEqual([ + { href: '#intro', text: 'Intro' }, + { href: '#details', text: 'Details' }, + ]) + expect(result.current.activeSection).toBe('intro') + expect(result.current.isTocExpanded).toBe(true) + }) + + it('should update the active section when the scroll container scrolls', async () => { + const { scrollContainer, intro, details } = setupDocument() + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + + intro.getBoundingClientRect = vi.fn(() => ({ top: 500 } as DOMRect)) + details.getBoundingClientRect = vi.fn(() => ({ top: 300 } as DOMRect)) + + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + act(() => { + scrollContainer.dispatchEvent(new Event('scroll')) + }) + + expect(result.current.activeSection).toBe('details') + }) + + it('should scroll the container to the clicked heading offset', async () => { + const { scrollContainer } = setupDocument() + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + const preventDefault = vi.fn() + act(() => { + result.current.handleTocClick( + { preventDefault } as unknown as React.MouseEvent, + { href: '#details', text: 'Details' }, + ) + }) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(scrollContainer.scrollTo).toHaveBeenCalledWith({ + top: 240, + behavior: 'smooth', + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts new file mode 100644 index 0000000000..1594662691 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts @@ -0,0 +1,69 @@ +import type { SearchResult } from '../types' +import { ragPipelineNodesAction } from '../rag-pipeline-nodes' +import { workflowNodesAction } from '../workflow-nodes' + +describe('workflowNodesAction', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowNodesAction.searchFn = undefined + }) + + it('should return an empty result when no workflow search function is registered', async () => { + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([]) + }) + + it('should delegate to the injected workflow search function', async () => { + const results: SearchResult[] = [ + { id: 'workflow-node-1', title: 'LLM', type: 'workflow-node', data: {} as never }, + ] + workflowNodesAction.searchFn = vi.fn().mockReturnValue(results) + + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual(results) + expect(workflowNodesAction.searchFn).toHaveBeenCalledWith('llm') + }) + + it('should warn and return an empty list when workflow node search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + workflowNodesAction.searchFn = vi.fn(() => { + throw new Error('failed') + }) + + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('Workflow nodes search failed:', expect.any(Error)) + + warnSpy.mockRestore() + }) +}) + +describe('ragPipelineNodesAction', () => { + beforeEach(() => { + vi.clearAllMocks() + ragPipelineNodesAction.searchFn = undefined + }) + + it('should return an empty result when no rag pipeline search function is registered', async () => { + await expect(ragPipelineNodesAction.search('@node embed', 'embed', 'en')).resolves.toEqual([]) + }) + + it('should delegate to the injected rag pipeline search function', async () => { + const results: SearchResult[] = [ + { id: 'rag-node-1', title: 'Retriever', type: 'workflow-node', data: {} as never }, + ] + ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results) + + await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual(results) + expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve') + }) + + it('should warn and return an empty list when rag pipeline node search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ragPipelineNodesAction.searchFn = vi.fn(() => { + throw new Error('failed') + }) + + await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error)) + + warnSpy.mockRestore() + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx new file mode 100644 index 0000000000..46d1faba2e --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx @@ -0,0 +1,124 @@ +import type { SearchResult } from '../../types' +import { render } from '@testing-library/react' +import { slashAction, SlashCommandProvider } from '../slash' + +const { + mockSetTheme, + mockSetLocale, + mockExecuteCommand, + mockRegister, + mockSearch, + mockUnregister, +} = vi.hoisted(() => ({ + mockSetTheme: vi.fn(), + mockSetLocale: vi.fn(), + mockExecuteCommand: vi.fn(), + mockRegister: vi.fn(), + mockSearch: vi.fn(), + mockUnregister: vi.fn(), +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ + setTheme: mockSetTheme, + }), +})) + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + language: 'ja', + t: (key: string) => key, + }), +})) + +vi.mock('@/i18n-config', () => ({ + setLocaleOnClient: mockSetLocale, +})) + +vi.mock('../command-bus', () => ({ + executeCommand: (...args: unknown[]) => mockExecuteCommand(...args), +})) + +vi.mock('../registry', () => ({ + slashCommandRegistry: { + register: (...args: unknown[]) => mockRegister(...args), + search: (...args: unknown[]) => mockSearch(...args), + unregister: (...args: unknown[]) => mockUnregister(...args), + }, +})) + +describe('slashAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should expose translated title and description', () => { + expect(slashAction.title).toBe('gotoAnything.actions.slashTitle') + expect(slashAction.description).toBe('gotoAnything.actions.slashDesc') + }) + + it('should execute command results and ignore non-command results', () => { + slashAction.action?.({ + id: 'cmd-1', + title: 'Command', + type: 'command', + data: { + command: 'navigation.docs', + args: { path: '/docs' }, + }, + } as SearchResult) + + slashAction.action?.({ + id: 'app-1', + title: 'App', + type: 'app', + data: {} as never, + } as SearchResult) + + expect(mockExecuteCommand).toHaveBeenCalledTimes(1) + expect(mockExecuteCommand).toHaveBeenCalledWith('navigation.docs', { path: '/docs' }) + }) + + it('should delegate search to the slash command registry with the active language', async () => { + mockSearch.mockResolvedValue([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + + const results = await slashAction.search('/theme dark', 'dark') + + expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja') + expect(results).toEqual([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + }) +}) + +describe('SlashCommandProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should register commands on mount and unregister them on unmount', () => { + const { unmount } = render() + + expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([ + 'theme', + 'language', + 'forum', + 'docs', + 'community', + 'account', + 'zen', + ]) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme }) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale }) + + unmount() + + expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([ + 'theme', + 'language', + 'forum', + 'docs', + 'community', + 'account', + 'zen', + ]) + }) +}) diff --git a/web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx b/web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx new file mode 100644 index 0000000000..f7f53b6ab4 --- /dev/null +++ b/web/app/components/header/account-dropdown/__tests__/menu-item-content.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import { ExternalLinkIndicator, MenuItemContent } from '../menu-item-content' + +describe('MenuItemContent', () => { + it('should render the icon, label, and trailing content', () => { + const { container } = render( + Soon} + />, + ) + + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByTestId('menu-trailing')).toHaveTextContent('Soon') + expect(container.querySelector('.i-ri-settings-4-line')).toBeInTheDocument() + }) +}) + +describe('ExternalLinkIndicator', () => { + it('should render the external-link icon with aria-hidden semantics', () => { + const { container } = render() + + const indicator = container.querySelector('.i-ri-arrow-right-up-line') + expect(indicator).toBeInTheDocument() + expect(indicator).toHaveAttribute('aria-hidden') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts new file mode 100644 index 0000000000..7387234c67 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts @@ -0,0 +1,23 @@ +import * as ModelAuth from '../index' + +vi.mock('../add-credential-in-load-balancing', () => ({ default: 'AddCredentialInLoadBalancing' })) +vi.mock('../add-custom-model', () => ({ default: 'AddCustomModel' })) +vi.mock('../authorized', () => ({ default: 'Authorized' })) +vi.mock('../config-model', () => ({ default: 'ConfigModel' })) +vi.mock('../credential-selector', () => ({ default: 'CredentialSelector' })) +vi.mock('../manage-custom-model-credentials', () => ({ default: 'ManageCustomModelCredentials' })) +vi.mock('../switch-credential-in-load-balancing', () => ({ default: 'SwitchCredentialInLoadBalancing' })) + +describe('model-auth index exports', () => { + it('should re-export the model auth entry points', () => { + expect(ModelAuth).toMatchObject({ + AddCredentialInLoadBalancing: 'AddCredentialInLoadBalancing', + AddCustomModel: 'AddCustomModel', + Authorized: 'Authorized', + ConfigModel: 'ConfigModel', + CredentialSelector: 'CredentialSelector', + ManageCustomModelCredentials: 'ManageCustomModelCredentials', + SwitchCredentialInLoadBalancing: 'SwitchCredentialInLoadBalancing', + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx new file mode 100644 index 0000000000..2d634d8673 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import CreditsFallbackAlert from '../credits-fallback-alert' + +describe('CreditsFallbackAlert', () => { + it('should render the credential fallback copy and description when credentials exist', () => { + render() + + expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallback')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).toBeInTheDocument() + }) + + it('should render the no-credentials fallback copy without the description', () => { + render() + + expect(screen.getByText('common.modelProvider.card.noApiKeysFallback')).toBeInTheDocument() + expect(screen.queryByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx b/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx new file mode 100644 index 0000000000..2481a8c0b3 --- /dev/null +++ b/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import DownloadingIcon from '../downloading-icon' + +describe('DownloadingIcon', () => { + it('should render the animated install icon wrapper and svg markup', () => { + const { container } = render() + + const wrapper = container.firstElementChild as HTMLElement + const svg = container.querySelector('svg.install-icon') + + expect(wrapper).toHaveClass('inline-flex', 'text-components-button-secondary-text') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('viewBox', '0 0 24 24') + expect(svg?.querySelectorAll('path')).toHaveLength(3) + }) +}) diff --git a/web/app/components/share/text-generation/__tests__/index.spec.tsx b/web/app/components/share/text-generation/__tests__/index.spec.tsx new file mode 100644 index 0000000000..e3746b1da1 --- /dev/null +++ b/web/app/components/share/text-generation/__tests__/index.spec.tsx @@ -0,0 +1,219 @@ +import type { TextGenerationRunControl } from '../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { AccessMode } from '@/models/access-control' +import TextGeneration from '../index' + +const { + mockMode, + mockMedia, + mockAppStateRef, + mockBatchStateRef, + sidebarPropsSpy, + resultPanelPropsSpy, + mockSetIsCallBatchAPI, + mockResetBatchExecution, + mockHandleRunBatch, +} = vi.hoisted(() => ({ + mockMode: { value: 'create' }, + mockMedia: { value: 'pc' }, + mockAppStateRef: { value: null as unknown }, + mockBatchStateRef: { value: null as unknown }, + sidebarPropsSpy: vi.fn(), + resultPanelPropsSpy: vi.fn(), + mockSetIsCallBatchAPI: vi.fn(), + mockResetBatchExecution: vi.fn(), + mockHandleRunBatch: vi.fn(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + MediaType: { + mobile: 'mobile', + pc: 'pc', + tablet: 'tablet', + }, + default: () => mockMedia.value, +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => key === 'mode' ? mockMode.value : null, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ type }: { type: string }) =>
{type}
, +})) + +vi.mock('../hooks/use-text-generation-app-state', () => ({ + useTextGenerationAppState: () => mockAppStateRef.value, +})) + +vi.mock('../hooks/use-text-generation-batch', () => ({ + useTextGenerationBatch: () => mockBatchStateRef.value, +})) + +vi.mock('../text-generation-sidebar', () => ({ + default: (props: { + currentTab: string + onRunOnceSend: () => void + onBatchSend: (data: string[][]) => void + }) => { + sidebarPropsSpy(props) + return ( +
+ {props.currentTab} + + +
+ ) + }, +})) + +vi.mock('../text-generation-result-panel', () => ({ + default: (props: { + allTaskList: unknown[] + controlSend: number + controlStopResponding: number + isShowResultPanel: boolean + onRunControlChange: (value: TextGenerationRunControl | null) => void + onRunStart: () => void + }) => { + resultPanelPropsSpy(props) + return ( +
+ {props.isShowResultPanel ? 'shown' : 'hidden'} + {String(props.controlSend)} + {String(props.controlStopResponding)} + {String(props.allTaskList.length)} + + +
+ ) + }, +})) + +const createAppState = (overrides: Record = {}) => ({ + accessMode: AccessMode.PUBLIC, + appId: 'app-1', + appSourceType: 'webApp', + customConfig: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + handleRemoveSavedMessage: vi.fn(), + handleSaveMessage: vi.fn(), + moreLikeThisConfig: { enabled: true }, + promptConfig: { + prompt_template: '', + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], + }, + savedMessages: [], + siteInfo: { + title: 'Generator', + description: 'Description', + }, + systemFeatures: {}, + textToSpeechConfig: { enabled: true }, + visionConfig: { enabled: false }, + ...overrides, +}) + +const createBatchState = (overrides: Record = {}) => ({ + allFailedTaskList: [], + allSuccessTaskList: [], + allTaskList: [], + allTasksRun: true, + controlRetry: 0, + exportRes: [], + handleCompleted: vi.fn(), + handleRetryAllFailedTask: vi.fn(), + handleRunBatch: (data: string[][], options: { onStart: () => void }) => { + mockHandleRunBatch(data, options) + options.onStart() + return true + }, + isCallBatchAPI: false, + noPendingTask: true, + resetBatchExecution: () => mockResetBatchExecution(), + setIsCallBatchAPI: (value: boolean) => mockSetIsCallBatchAPI(value), + showTaskList: [], + ...overrides, +}) + +describe('TextGeneration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockMode.value = 'create' + mockMedia.value = 'pc' + mockAppStateRef.value = createAppState() + mockBatchStateRef.value = createBatchState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should render the loading state until app state is ready', () => { + mockAppStateRef.value = createAppState({ appId: '', siteInfo: null, promptConfig: null }) + + render() + + expect(screen.getByTestId('loading-app')).toHaveTextContent('app') + }) + + it('should fall back to create mode for unsupported query params and keep installed-app layout classes', () => { + mockMode.value = 'unsupported' + + const { container } = render() + + expect(screen.getByTestId('sidebar-current-tab')).toHaveTextContent('create') + expect(sidebarPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ + currentTab: 'create', + isInstalledApp: true, + isPC: true, + })) + + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('flex', 'h-full', 'rounded-2xl', 'shadow-md') + }) + + it('should orchestrate a run-once request and reveal the result panel', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'run-once' })) + + act(() => { + vi.runAllTimers() + }) + + expect(mockSetIsCallBatchAPI).toHaveBeenCalledWith(false) + expect(mockResetBatchExecution).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('show-result')).toHaveTextContent('shown') + expect(Number(screen.getByTestId('control-send').textContent)).toBeGreaterThan(0) + }) + + it('should orchestrate batch runs through the batch hook and expose the result panel', async () => { + mockMode.value = 'batch' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'run-batch' })) + + act(() => { + vi.runAllTimers() + }) + + expect(mockHandleRunBatch).toHaveBeenCalledWith( + [['name'], ['Alice']], + expect.objectContaining({ onStart: expect.any(Function) }), + ) + expect(screen.getByTestId('show-result')).toHaveTextContent('shown') + expect(Number(screen.getByTestId('control-stop').textContent)).toBeGreaterThan(0) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts new file mode 100644 index 0000000000..76c3e6b2ce --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react' +import { AppModeEnum } from '@/types/app' +import { useIsChatMode } from '../use-is-chat-mode' + +const { mockStoreState } = vi.hoisted(() => ({ + mockStoreState: { + appDetail: undefined as { mode?: AppModeEnum } | undefined, + }, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), +})) + +describe('useIsChatMode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStoreState.appDetail = undefined + }) + + it('should return true when the app mode is ADVANCED_CHAT', () => { + mockStoreState.appDetail = { mode: AppModeEnum.ADVANCED_CHAT } + + const { result } = renderHook(() => useIsChatMode()) + + expect(result.current).toBe(true) + }) + + it('should return false when the app mode is not chat or app detail is missing', () => { + mockStoreState.appDetail = { mode: AppModeEnum.WORKFLOW } + + const { result, rerender } = renderHook(() => useIsChatMode()) + + expect(result.current).toBe(false) + + mockStoreState.appDetail = undefined + rerender() + + expect(result.current).toBe(false) + }) +}) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 722eb8a7e4..846e9f11ec 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1880,12 +1880,6 @@ } }, "app/components/base/chat/chat/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - }, "ts/no-explicit-any": { "count": 3 } diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 74ee7a9614..82870a8d2e 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -61,13 +61,15 @@ const createResponseFromHTTPError = (error: HTTPError): Response => { const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => { return async ({ response }) => { if (!/^([23])\d{2}$/.test(String(response.status))) { - const errorData = await response.clone() - .json() - .then(data => data as ResponseError) - .catch(() => null) + let errorData: ResponseError | null = null + try { + const data: unknown = await response.clone().json() + errorData = data as ResponseError + } + catch {} const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent - if (shouldNotifyError) + if (shouldNotifyError && errorData) toast.error(errorData.message) if (response.status === 403 && errorData?.code === 'already_setup') From 7d793e12c8ed40e4801acd28ca5030639605302c Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Thu, 9 Apr 2026 15:31:57 +0800 Subject: [PATCH 48/53] chore: update deps (#34833) --- pnpm-lock.yaml | 1249 ++++++++++++++++++++++--------------------- pnpm-workspace.yaml | 28 +- 2 files changed, 648 insertions(+), 629 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3794d88d..b61ca1b0ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ catalogs: specifier: 1.27.6 version: 1.27.6 '@antfu/eslint-config': - specifier: 8.0.0 - version: 8.0.0 + specifier: 8.1.1 + version: 8.1.1 '@base-ui/react': specifier: 1.3.0 version: 1.3.0 @@ -88,11 +88,11 @@ catalogs: specifier: 4.7.0 version: 4.7.0 '@next/eslint-plugin-next': - specifier: 16.2.2 - version: 16.2.2 + specifier: 16.2.3 + version: 16.2.3 '@next/mdx': - specifier: 16.2.2 - version: 16.2.2 + specifier: 16.2.3 + version: 16.2.3 '@orpc/client': specifier: 1.13.13 version: 1.13.13 @@ -226,14 +226,14 @@ catalogs: specifier: 8.58.1 version: 8.58.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260407.1 - version: 7.0.0-dev.20260407.1 + specifier: 7.0.0-dev.20260408.1 + version: 7.0.0-dev.20260408.1 '@vitejs/plugin-react': specifier: 6.0.1 version: 6.0.1 '@vitejs/plugin-rsc': - specifier: 0.5.22 - version: 0.5.22 + specifier: 0.5.23 + version: 0.5.23 '@vitest/coverage-v8': specifier: 4.1.3 version: 4.1.3 @@ -301,8 +301,8 @@ catalogs: specifier: 10.2.0 version: 10.2.0 eslint-markdown: - specifier: 0.6.0 - version: 0.6.0 + specifier: 0.6.1 + version: 0.6.1 eslint-plugin-better-tailwindcss: specifier: 4.3.2 version: 4.3.2 @@ -343,8 +343,8 @@ catalogs: specifier: 1.11.13 version: 1.11.13 i18next: - specifier: 26.0.3 - version: 26.0.3 + specifier: 26.0.4 + version: 26.0.4 i18next-resources-to-backend: specifier: 1.2.1 version: 1.2.1 @@ -373,8 +373,8 @@ catalogs: specifier: 0.16.45 version: 0.16.45 knip: - specifier: 6.3.0 - version: 6.3.0 + specifier: 6.3.1 + version: 6.3.1 ky: specifier: 2.0.0 version: 2.0.0 @@ -397,8 +397,8 @@ catalogs: specifier: 1.0.0 version: 1.0.0 next: - specifier: 16.2.2 - version: 16.2.2 + specifier: 16.2.3 + version: 16.2.3 next-themes: specifier: 0.4.6 version: 0.4.6 @@ -415,17 +415,17 @@ catalogs: specifier: 4.2.0 version: 4.2.0 qs: - specifier: 6.15.0 - version: 6.15.0 + specifier: 6.15.1 + version: 6.15.1 react: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-18-input-autosize: specifier: 3.0.0 version: 3.0.0 react-dom: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-easy-crop: specifier: 5.5.7 version: 5.5.7 @@ -445,8 +445,8 @@ catalogs: specifier: 8.0.0-rc.0 version: 8.0.0-rc.0 react-server-dom-webpack: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-sortablejs: specifier: 6.1.4 version: 6.1.4 @@ -514,8 +514,8 @@ catalogs: specifier: 13.0.0 version: 13.0.0 vinext: - specifier: 0.0.40 - version: 0.0.40 + specifier: https://pkg.pr.new/vinext@adbf24d + version: 0.0.5 vite-plugin-inspect: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 @@ -648,22 +648,22 @@ importers: version: 1.27.6(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0) '@base-ui/react': specifier: 'catalog:' - version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@emoji-mart/data': specifier: 'catalog:' version: 1.2.1 '@floating-ui/react': specifier: 'catalog:' - version: 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@formatjs/intl-localematcher': specifier: 'catalog:' version: 0.8.2 '@headlessui/react': specifier: 'catalog:' - version: 2.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.2.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@heroicons/react': specifier: 'catalog:' - version: 2.2.0(react@19.2.4) + version: 2.2.0(react@19.2.5) '@lexical/code': specifier: npm:lexical-code-no-prism@0.41.0 version: lexical-code-no-prism@0.41.0(@lexical/utils@0.42.0)(lexical@0.42.0) @@ -675,7 +675,7 @@ importers: version: 0.42.0 '@lexical/react': specifier: 'catalog:' - version: 0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.30) + version: 0.42.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yjs@13.6.30) '@lexical/selection': specifier: 'catalog:' version: 0.42.0 @@ -687,7 +687,7 @@ importers: version: 0.42.0 '@monaco-editor/react': specifier: 'catalog:' - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@orpc/client': specifier: 'catalog:' version: 1.13.13 @@ -702,13 +702,13 @@ importers: version: 1.13.13(@orpc/client@1.13.13)(@tanstack/query-core@5.96.2) '@remixicon/react': specifier: 'catalog:' - version: 4.9.0(react@19.2.4) + version: 4.9.0(react@19.2.5) '@sentry/react': specifier: 'catalog:' - version: 10.47.0(react@19.2.4) + version: 10.47.0(react@19.2.5) '@streamdown/math': specifier: 'catalog:' - version: 1.0.2(react@19.2.4) + version: 1.0.2(react@19.2.5) '@svgdotjs/svg.js': specifier: 'catalog:' version: 3.2.5 @@ -720,19 +720,19 @@ importers: version: 0.5.19(tailwindcss@4.2.2) '@tanstack/react-form': specifier: 'catalog:' - version: 1.28.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.28.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-query': specifier: 'catalog:' - version: 5.96.2(react@19.2.4) + version: 5.96.2(react@19.2.5) '@tanstack/react-virtual': specifier: 'catalog:' - version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) abcjs: specifier: 'catalog:' version: 6.6.2 ahooks: specifier: 'catalog:' - version: 3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 3.9.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -744,7 +744,7 @@ importers: version: 2.1.1 cmdk: specifier: 'catalog:' - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) copy-to-clipboard: specifier: 'catalog:' version: 3.3.3 @@ -765,7 +765,7 @@ importers: version: 6.0.0 echarts-for-react: specifier: 'catalog:' - version: 3.0.6(echarts@6.0.0)(react@19.2.4) + version: 3.0.6(echarts@6.0.0)(react@19.2.5) elkjs: specifier: 'catalog:' version: 0.11.1 @@ -774,7 +774,7 @@ importers: version: 8.6.0(embla-carousel@8.6.0) embla-carousel-react: specifier: 'catalog:' - version: 8.6.0(react@19.2.4) + version: 8.6.0(react@19.2.5) emoji-mart: specifier: 'catalog:' version: 5.6.0 @@ -795,7 +795,7 @@ importers: version: 1.11.13 i18next: specifier: 'catalog:' - version: 26.0.3(typescript@6.0.2) + version: 26.0.4(typescript@6.0.2) i18next-resources-to-backend: specifier: 'catalog:' version: 1.2.1 @@ -804,7 +804,7 @@ importers: version: 11.1.4 jotai: specifier: 'catalog:' - version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) js-audio-recorder: specifier: 'catalog:' version: 1.0.7 @@ -843,58 +843,58 @@ importers: version: 1.0.0 next: specifier: 'catalog:' - version: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) next-themes: specifier: 'catalog:' - version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) nuqs: specifier: 'catalog:' - version: 2.8.9(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4) + version: 2.8.9(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react@19.2.5) pinyin-pro: specifier: 'catalog:' version: 3.28.0 qrcode.react: specifier: 'catalog:' - version: 4.2.0(react@19.2.4) + version: 4.2.0(react@19.2.5) qs: specifier: 'catalog:' - version: 6.15.0 + version: 6.15.1 react: specifier: 'catalog:' - version: 19.2.4 + version: 19.2.5 react-18-input-autosize: specifier: 'catalog:' - version: 3.0.0(react@19.2.4) + version: 3.0.0(react@19.2.5) react-dom: specifier: 'catalog:' - version: 19.2.4(react@19.2.4) + version: 19.2.5(react@19.2.5) react-easy-crop: specifier: 'catalog:' - version: 5.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.5.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-hotkeys-hook: specifier: 'catalog:' - version: 5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-i18next: specifier: 'catalog:' - version: 17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) + version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2) react-multi-email: specifier: 'catalog:' - version: 1.0.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.0.25(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-papaparse: specifier: 'catalog:' version: 4.4.0 react-pdf-highlighter: specifier: 'catalog:' - version: 8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 8.0.0-rc.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-sortablejs: specifier: 'catalog:' - version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7) + version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sortablejs@1.15.7) react-textarea-autosize: specifier: 'catalog:' - version: 8.5.9(@types/react@19.2.14)(react@19.2.4) + version: 8.5.9(@types/react@19.2.14)(react@19.2.5) reactflow: specifier: 'catalog:' - version: 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) remark-breaks: specifier: 'catalog:' version: 4.0.0 @@ -918,7 +918,7 @@ importers: version: 1.0.8 streamdown: specifier: 'catalog:' - version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) string-ts: specifier: 'catalog:' version: 2.3.1 @@ -933,7 +933,7 @@ importers: version: 5.1.0 use-context-selector: specifier: 'catalog:' - version: 2.0.0(react@19.2.4)(scheduler@0.27.0) + version: 2.0.0(react@19.2.5)(scheduler@0.27.0) uuid: specifier: 'catalog:' version: 13.0.0 @@ -942,17 +942,17 @@ importers: version: 4.3.6 zundo: specifier: 'catalog:' - version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))) + version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))) zustand: specifier: 'catalog:' - version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 8.0.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.2)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.2))(@typescript-eslint/utils@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2) + version: 8.1.1(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.2))(@typescript-eslint/utils@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.1.1(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 5.1.1(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@dify/iconify-collections': specifier: workspace:* version: link:../packages/iconify-collections @@ -976,37 +976,37 @@ importers: version: 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) '@mdx-js/react': specifier: 'catalog:' - version: 3.1.1(@types/react@19.2.14)(react@19.2.4) + version: 3.1.1(@types/react@19.2.14)(react@19.2.5) '@mdx-js/rollup': specifier: 'catalog:' version: 3.1.1(rollup@4.59.0) '@next/eslint-plugin-next': specifier: 'catalog:' - version: 16.2.2 + version: 16.2.3 '@next/mdx': specifier: 'catalog:' - version: 16.2.2(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)) + version: 16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)) '@rgrove/parse-xml': specifier: 'catalog:' version: 4.2.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3)) + version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) '@storybook/addon-links': specifier: 'catalog:' - version: 10.3.5(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/addon-onboarding': specifier: 'catalog:' - version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) + version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) '@storybook/react': specifier: 'catalog:' - version: 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + version: 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) '@tailwindcss/postcss': specifier: 'catalog:' version: 4.2.2 @@ -1018,13 +1018,13 @@ importers: version: 5.96.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@tanstack/react-devtools': specifier: 'catalog:' - version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) + version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.11) '@tanstack/react-form-devtools': specifier: 'catalog:' - version: 0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11) + version: 0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) '@tanstack/react-query-devtools': specifier: 'catalog:' - version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.4))(react@19.2.4) + version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.5))(react@19.2.5) '@testing-library/dom': specifier: 'catalog:' version: 10.4.1 @@ -1033,7 +1033,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: 'catalog:' - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@testing-library/user-event': specifier: 'catalog:' version: 14.6.1(@testing-library/dom@10.4.1) @@ -1075,19 +1075,19 @@ importers: version: 8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260407.1 + version: 7.0.0-dev.20260408.1 '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4) + version: 0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) agentation: specifier: 'catalog:' - version: 3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 3.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) code-inspector-plugin: specifier: 'catalog:' version: 1.5.1 @@ -1096,7 +1096,7 @@ importers: version: 10.2.0(jiti@2.6.1) eslint-markdown: specifier: 'catalog:' - version: 0.6.0(eslint@10.2.0(jiti@2.6.1)) + version: 0.6.1(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' version: 4.3.2(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2) @@ -1117,7 +1117,7 @@ importers: version: 4.0.2(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + version: 10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) happy-dom: specifier: 'catalog:' version: 20.8.9 @@ -1126,16 +1126,16 @@ importers: version: 4.12.12 knip: specifier: 'catalog:' - version: 6.3.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + version: 6.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) postcss: specifier: 'catalog:' version: 8.5.9 react-server-dom-webpack: specifier: 'catalog:' - version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)) + version: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) storybook: specifier: 'catalog:' - version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: specifier: 'catalog:' version: 4.2.2 @@ -1150,7 +1150,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.0.40(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2) + version: https://pkg.pr.new/vinext@adbf24d(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' @@ -1253,8 +1253,8 @@ packages: '@amplitude/targeting@0.2.0': resolution: {integrity: sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==} - '@antfu/eslint-config@8.0.0': - resolution: {integrity: sha512-IKiCfsa1vRgj8srB2azqiN3nOAcVyP/TZ5Ibiz0TDW9NoQPizTvkmRTSi1vo4ax0SL9TH/8uJLK6uCfd6bQzLA==} + '@antfu/eslint-config@8.1.1': + resolution: {integrity: sha512-y5/eAKlJUbQpeES2Pnb0i/VgbmqQ+srHJJNqbTKEBsxdLy3h1BqdS00zDpE+YeP71EWmlYJSTUhcJg4n4yMeAQ==} hasBin: true peerDependencies: '@angular-eslint/eslint-plugin': ^21.1.0 @@ -1564,6 +1564,10 @@ packages: resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/resolve.exports@1.2.0': resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} @@ -2260,14 +2264,14 @@ packages: '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} - '@next/env@16.2.2': - resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==} + '@next/env@16.2.3': + resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} - '@next/eslint-plugin-next@16.2.2': - resolution: {integrity: sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==} + '@next/eslint-plugin-next@16.2.3': + resolution: {integrity: sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==} - '@next/mdx@16.2.2': - resolution: {integrity: sha512-2CbRTXE6sJ7zDAaKXknb5FrrPs46iJeMPzuoBXsAOV/XVnxABGD4mSDusn0VuCoII/KjUZ+zsuo2VFbchYQXng==} + '@next/mdx@16.2.3': + resolution: {integrity: sha512-mm7XNfPagSIcN8jFtozB9toeh5ESES0KCLRoo0gu6xydijvnIrV7dRIK3akNL3Tecc8AHX1FNzYZOZTeFU6RCw==} peerDependencies: '@mdx-js/loader': '>=0.15.0' '@mdx-js/react': '>=0.15.0' @@ -2277,54 +2281,54 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@16.2.2': - resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==} + '@next/swc-darwin-arm64@16.2.3': + resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.2': - resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==} + '@next/swc-darwin-x64@16.2.3': + resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.2': - resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==} + '@next/swc-linux-arm64-gnu@16.2.3': + resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.2': - resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==} + '@next/swc-linux-arm64-musl@16.2.3': + resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.2': - resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==} + '@next/swc-linux-x64-gnu@16.2.3': + resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.2': - resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==} + '@next/swc-linux-x64-musl@16.2.3': + resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.2': - resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==} + '@next/swc-win32-arm64-msvc@16.2.3': + resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.2': - resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==} + '@next/swc-win32-x64-msvc@16.2.3': + resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -4327,43 +4331,43 @@ packages: resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-akoBfxvDbULMWLqHPDBI5sRkhjQ0blX5+iG7GBoSstqJZW4P0nzd516COGs7xWHsu3apBhaBgSTMCFO78kG80w==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-YcPczNLfPDB13eUBYHkTOkL7HyWqqqEhho4eSxhAvigZuxvtHQ1uyILIvLVAwipEVzhJ8QciKmLdLucpfi4XyA==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-j/V5BS+tgcRFGQC+y95vZB78fI45UgobAEY1+NlFZ3Yih9ICKWRfJPcalpiP5vjiO2NgqVzcFfO9XbpJyq5TTA==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-cHqkDg53xxxz21MThLBf4vx1kyIpRPEYNdEiQlvu9O35Tth49+aub6F+/YEMd9MG4TYZmxh1bEjkjErTUIElpA==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-QG0E0lmcZQZimvNltxyi5Q3Oz1pd0BdztS7K5T9HTs30E3TSeYHq7Csw3SbDfAVwcqs2HTe/AVqLy6ar+1zm3Q==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-iHG0FEXq/QFsn+qlTPllxdcbvfQ9aRYggy4lc1z0+f11Nyk4YDNCSiR8WW7pbnOTx/VreGbbXhlpuJXTidqL8g==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-ZDr+zQFSTPmLIGyXDWixYFeFtktWUDGAD6s65rTI5EJgyt4X5/kEMnNd04mf4PbN0ChSiTRzJYLzaM+JGo+jww==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-w26Gv9yq9LIYIhxjkQC+i0wBPDdQdX+H06ZhyVRL5grKWTIsk9Xwjp9mDRB/dGlXBKcvnM25JH16OyAA0rFH3A==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-a82yGx039yqZBS0dwKG8+kgeF2xVA7Pg6lL2SrswbaxWz3bXpI0ASX3HgUw+JMSIr4fbZ5ulKcaorPqbhc48/A==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-hMcUlUIzYbvbdq6j/B4RPL+kZR917NGnE9AgPZ7dJ92yamH/7LGT1Mnlc6McUx31yqTFBFHdTc7Cfx+ynua7Iw==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-e38ow5yqBrdiz4GunQCRk1E7cTtowpbXeAvVJf1wXrWbFqEc0D8BE7YPmTy9W2fOI0KFHUrsFg5h4Ad/TKVjug==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-avJWIEKSx4rdBLZD1FOOTuxTU51dQfYb3jZvZMaXD4thJjq+6eSwfzu2elwL36AZDlnaxggGjB5nBxp0t54iOA==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-1Jiij5NQOvlM72/DdfXzAVia1pdffgHiVgWZVmDwXECpzwQB0WwWfhI/0IddXP92Y9gVQFCGo9lypSAnamfGPA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-gpvEHkF/WoxkA3711c4uWNCZO9WAuwrq49COdNwxgOTzYHnMc1yCj8CpkCUJwU0f/Ydwp2s6/efn6gTMvtckPg==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260407.1': - resolution: {integrity: sha512-gf1W3UbzVTDkZJuwhNtOcfQ6l3hpDcxuWh90ANlp/cKupmAqaXNGpT23YjTYqXsaI7RDQR7JUELCKeWbW9PJIg==} + '@typescript/native-preview@7.0.0-dev.20260408.1': + resolution: {integrity: sha512-N0MZLEUnAoP/aRVk7MY119LDsESkbtEwIw+YeXi/jjx2XCqf7ni3GxIVsUYtf/troyuSedq3V/OUrkoCh5A9gA==} hasBin: true '@ungap/structured-clone@1.3.0': @@ -4420,8 +4424,8 @@ packages: babel-plugin-react-compiler: optional: true - '@vitejs/plugin-rsc@0.5.22': - resolution: {integrity: sha512-OC4wKNVHpF+LOgtasdMOAw1V0yWHj1Nx/XfkNW/9weFXd/9wXPWDyeJGcUJ03DxqJ8mYi4j9/kvo6HKYCoP9Ow==} + '@vitejs/plugin-rsc@0.5.23': + resolution: {integrity: sha512-CV6kWPE4E241qDStwK3ErYjuZqW1i1xun3/P1wsm94RJoActLTrQsGzGsf75ioeVxEK0roPqLGhcV2WlSlPePQ==} peerDependencies: react: '*' react-dom: '*' @@ -5561,8 +5565,8 @@ packages: '@eslint/json': optional: true - eslint-markdown@0.6.0: - resolution: {integrity: sha512-NrgfiNto5IJrW1F/Akf2hJYoJTCbXoClOUvtUMDgoqmQNH0VRihNvFh+MFay4E0HV2eozfgxsLSGxnndtRJA8w==} + eslint-markdown@0.6.1: + resolution: {integrity: sha512-eiHSRFnzcPWN/0YDrtELW/+GnGylAoyXVBDh0iVAttyC5rWAaZfgSrzlFUTlS7Jz4XEL36PFLsoEcXlbvl5qPQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: eslint: ^9.31.0 || ^10.0.0-rc.0 @@ -5623,8 +5627,8 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 - eslint-plugin-jsdoc@62.8.1: - resolution: {integrity: sha512-e9358PdHgvcMF98foNd3L7hVCw70Lt+YcSL7JzlJebB8eT5oRJtW6bHMQKoAwJtw6q0q0w/fRIr2kwnHdFDI6A==} + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -5655,8 +5659,8 @@ packages: resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} engines: {node: '>=5.0.0'} - eslint-plugin-perfectionist@5.7.0: - resolution: {integrity: sha512-WRHj7OZS/INutQ/gKN5C1ZGnMhkQ3oKZQAA2I7rl5yM8keBtSd9oj/qlJaHuwh5873FhMPqYlttcadF0YsTN7g==} + eslint-plugin-perfectionist@5.8.0: + resolution: {integrity: sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==} engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 @@ -6162,8 +6166,8 @@ packages: i18next-resources-to-backend@1.2.1: resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} - i18next@26.0.3: - resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==} + i18next@26.0.4: + resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -6382,6 +6386,10 @@ packages: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6431,8 +6439,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.3.0: - resolution: {integrity: sha512-g6dVPoTw6iNm3cubC5IWxVkVsd0r5hXhTBTbAGIEQN53GdA2ZM/slMTPJ7n5l8pBebNQPHpxjmKxuR4xVQ2/hQ==} + knip@6.3.1: + resolution: {integrity: sha512-22kLJloVcOVOAudCxlFOC0ICAMme7dKsS7pVTEnrmyKGpswb8ieznvAiSKUeFVDJhb01ect6dkDc1Ha1g1sPpg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -6951,8 +6959,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.2: - resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==} + next@16.2.3: + resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7299,8 +7307,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -7337,10 +7345,10 @@ packages: resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} engines: {node: ^20.9.0 || >=22} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: - react: ^19.2.4 + react: ^19.2.5 react-draggable@4.5.0: resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} @@ -7432,12 +7440,12 @@ packages: react: '>=16.3.0' react-dom: '>=16.3.0' - react-server-dom-webpack@19.2.4: - resolution: {integrity: sha512-zEhkWv6RhXDctC2N7yEUHg3751nvFg81ydHj8LTTZuukF/IF1gcOKqqAL6Ds+kS5HtDVACYPik0IvzkgYXPhlQ==} + react-server-dom-webpack@19.2.5: + resolution: {integrity: sha512-bYhdd2cZJhXHqyJBoloYaJrn8MrL9Egf3ZZVn0OrIODCCORm2goFD7C+xszf6xgfsSJi0rtgB/ichcuHfkJ4yQ==} engines: {node: '>=0.10.0'} peerDependencies: - react: ^19.2.4 - react-dom: ^19.2.4 + react: ^19.2.5 + react-dom: ^19.2.5 webpack: ^5.59.0 react-sortablejs@6.1.4: @@ -7464,8 +7472,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} reactflow@11.11.4: @@ -8295,17 +8303,18 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vinext@0.0.40: - resolution: {integrity: sha512-rs0z6G2el6kS/667ERKQjSMF3R8ZD2H9xDrnRntVOa6OBnyYcOMM/AVpOy/W1lxOkq6EYTO1OUD9DbNSWxRRJw==} + vinext@https://pkg.pr.new/vinext@adbf24d: + resolution: {tarball: https://pkg.pr.new/vinext@adbf24d} + version: 0.0.5 engines: {node: '>=22'} hasBin: true peerDependencies: '@mdx-js/rollup': ^3.0.0 '@vitejs/plugin-react': ^5.1.4 || ^6.0.0 - '@vitejs/plugin-rsc': ^0.5.21 - react: '>=19.2.0' - react-dom: '>=19.2.0' - react-server-dom-webpack: ^19.2.4 + '@vitejs/plugin-rsc': ^0.5.23 + react: ^19.2.5 + react-dom: ^19.2.5 + react-server-dom-webpack: ^19.2.5 vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@mdx-js/rollup': @@ -8725,7 +8734,7 @@ snapshots: idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@8.0.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.2)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.2))(@typescript-eslint/utils@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2)': + '@antfu/eslint-config@8.1.1(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.2))(@typescript-eslint/utils@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.2.0 @@ -8745,11 +8754,11 @@ snapshots: eslint-plugin-antfu: 3.2.2(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.2))(@typescript-eslint/utils@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-import-lite: 0.6.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-jsdoc: 62.8.1(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-jsonc: 3.1.2(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-n: 17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) eslint-plugin-no-only-tests: 3.3.0 - eslint-plugin-perfectionist: 5.7.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-plugin-perfectionist: 5.8.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) eslint-plugin-pnpm: 1.6.0(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-regexp: 3.1.0(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-toml: 1.3.1(eslint@10.2.0(jiti@2.6.1)) @@ -8766,7 +8775,7 @@ snapshots: yaml-eslint-parser: 2.0.0 optionalDependencies: '@eslint-react/eslint-plugin': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@next/eslint-plugin-next': 16.2.2 + '@next/eslint-plugin-next': 16.2.3 eslint-plugin-react-refresh: 0.5.2(eslint@10.2.0(jiti@2.6.1)) transitivePeerDependencies: - '@eslint/json' @@ -8888,27 +8897,27 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tabbable: 6.4.0 - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 @@ -8933,13 +8942,13 @@ snapshots: '@chevrotain/utils@11.1.2': {} - '@chromatic-com/storybook@5.1.1(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@chromatic-com/storybook@5.1.1(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 13.3.5 filesize: 10.1.6 jsonfile: 6.2.0 - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -9158,6 +9167,14 @@ snapshots: esquery: 1.7.0 jsdoc-type-pratt-parser: 7.1.1 + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.8 + '@typescript-eslint/types': 8.58.1 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + '@es-joy/resolve.exports@1.2.0': {} '@esbuild/aix-ppc64@0.27.2': @@ -9454,26 +9471,26 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@floating-ui/react@0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react@0.26.28(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tabbable: 6.4.0 - '@floating-ui/react@0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tabbable: 6.4.0 '@floating-ui/utils@0.2.11': {} @@ -9484,19 +9501,19 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.1 - '@headlessui/react@2.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@headlessui/react@2.2.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-virtual': 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) + '@floating-ui/react': 0.26.28(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-aria/focus': 3.21.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-aria/interactions': 3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-virtual': 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) - '@heroicons/react@2.2.0(react@19.2.4)': + '@heroicons/react@2.2.0(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 '@hono/node-server@1.19.13(hono@4.12.12)': dependencies: @@ -9700,7 +9717,7 @@ snapshots: dependencies: lexical: 0.42.0 - '@lexical/devtools-core@0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@lexical/devtools-core@0.42.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@lexical/html': 0.42.0 '@lexical/link': 0.42.0 @@ -9708,8 +9725,8 @@ snapshots: '@lexical/table': 0.42.0 '@lexical/utils': 0.42.0 lexical: 0.42.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@lexical/dragon@0.42.0': dependencies: @@ -9784,10 +9801,10 @@ snapshots: '@lexical/utils': 0.42.0 lexical: 0.42.0 - '@lexical/react@0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.30)': + '@lexical/react@0.42.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yjs@13.6.30)': dependencies: - '@floating-ui/react': 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@lexical/devtools-core': 0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@lexical/devtools-core': 0.42.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@lexical/dragon': 0.42.0 '@lexical/extension': 0.42.0 '@lexical/hashtag': 0.42.0 @@ -9804,9 +9821,9 @@ snapshots: '@lexical/utils': 0.42.0 '@lexical/yjs': 0.42.0(yjs@13.6.30) lexical: 0.42.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-error-boundary: 6.1.1(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-error-boundary: 6.1.1(react@19.2.5) transitivePeerDependencies: - yjs @@ -9884,11 +9901,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)': + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@types/mdx': 2.0.13 '@types/react': 19.2.14 - react: 19.2.4 + react: 19.2.5 '@mdx-js/rollup@3.1.1(rollup@4.59.0)': dependencies: @@ -9908,12 +9925,12 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@monaco-editor/loader': 1.7.0 monaco-editor: 0.55.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: @@ -9926,41 +9943,41 @@ snapshots: '@next/env@16.0.0': {} - '@next/env@16.2.2': {} + '@next/env@16.2.3': {} - '@next/eslint-plugin-next@16.2.2': + '@next/eslint-plugin-next@16.2.3': dependencies: fast-glob: 3.3.1 - '@next/mdx@16.2.2(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4))': + '@next/mdx@16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5))': dependencies: source-map: 0.7.6 optionalDependencies: '@mdx-js/loader': 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) - '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@next/swc-darwin-arm64@16.2.2': + '@next/swc-darwin-arm64@16.2.3': optional: true - '@next/swc-darwin-x64@16.2.2': + '@next/swc-darwin-x64@16.2.3': optional: true - '@next/swc-linux-arm64-gnu@16.2.2': + '@next/swc-linux-arm64-gnu@16.2.3': optional: true - '@next/swc-linux-arm64-musl@16.2.2': + '@next/swc-linux-arm64-musl@16.2.3': optional: true - '@next/swc-linux-x64-gnu@16.2.2': + '@next/swc-linux-x64-gnu@16.2.3': optional: true - '@next/swc-linux-x64-musl@16.2.2': + '@next/swc-linux-x64-musl@16.2.3': optional: true - '@next/swc-win32-arm64-msvc@16.2.2': + '@next/swc-win32-arm64-msvc@16.2.3': optional: true - '@next/swc-win32-x64-msvc@16.2.2': + '@next/swc-win32-x64-msvc@16.2.3': optional: true '@nodelib/fs.scandir@2.1.5': @@ -10384,235 +10401,235 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@react-aria/focus@3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/focus@3.21.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.1(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-aria/utils': 3.33.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-types/shared': 3.33.1(react@19.2.5) '@swc/helpers': 0.5.20 clsx: 2.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@react-aria/interactions@3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/interactions@3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/ssr': 3.9.10(react@19.2.5) + '@react-aria/utils': 3.33.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.5) '@swc/helpers': 0.5.20 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@react-aria/ssr@3.9.10(react@19.2.4)': + '@react-aria/ssr@3.9.10(react@19.2.5)': dependencies: '@swc/helpers': 0.5.20 - react: 19.2.4 + react: 19.2.5 - '@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/utils@3.33.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/ssr': 3.9.10(react@19.2.5) '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.1(react@19.2.4) + '@react-stately/utils': 3.11.0(react@19.2.5) + '@react-types/shared': 3.33.1(react@19.2.5) '@swc/helpers': 0.5.20 clsx: 2.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@react-stately/flags@3.1.2': dependencies: '@swc/helpers': 0.5.20 - '@react-stately/utils@3.11.0(react@19.2.4)': + '@react-stately/utils@3.11.0(react@19.2.5)': dependencies: '@swc/helpers': 0.5.20 - react: 19.2.4 + react: 19.2.5 - '@react-types/shared@3.33.1(react@19.2.4)': + '@react-types/shared@3.33.1(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 - '@reactflow/background@11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/background@11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/controls@11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/controls@11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/core@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/core@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@types/d3': 7.4.3 '@types/d3-drag': 3.0.7 @@ -10622,55 +10639,55 @@ snapshots: d3-drag: 3.0.0 d3-selection: 3.0.0 d3-zoom: 3.0.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/minimap@11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/minimap@11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@types/d3-selection': 3.0.11 '@types/d3-zoom': 3.0.8 classcat: 5.0.5 d3-selection: 3.0.0 d3-zoom: 3.0.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/node-resizer@2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/node-resizer@2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 d3-drag: 3.0.0 d3-selection: 3.0.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/node-toolbar@1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/node-toolbar@1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@remixicon/react@4.9.0(react@19.2.4)': + '@remixicon/react@4.9.0(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 '@resvg/resvg-wasm@2.4.0': {} @@ -10798,11 +10815,11 @@ snapshots: '@sentry/core@10.47.0': {} - '@sentry/react@10.47.0(react@19.2.4)': + '@sentry/react@10.47.0(react@19.2.5)': dependencies: '@sentry/browser': 10.47.0 '@sentry/core': 10.47.0 - react: 19.2.4 + react: 19.2.5 '@shikijs/core@4.0.2': dependencies: @@ -10889,15 +10906,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3)) - '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -10906,26 +10923,26 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.3.5(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-links@10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: - react: 19.2.4 + react: 19.2.5 - '@storybook/addon-onboarding@10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-onboarding@10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/addon-themes@10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-themes@10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3)) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: @@ -10933,9 +10950,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) unplugin: 2.3.11 optionalDependencies: rollup: 4.59.0 @@ -10944,23 +10961,23 @@ snapshots: '@storybook/global@5.0.0': {} - '@storybook/icons@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@storybook/icons@2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3)) - '@storybook/react': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) - '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: @@ -10971,25 +10988,25 @@ snapshots: - supports-color - webpack - '@storybook/react-dom-shim@10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/react-dom-shim@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(uglify-js@3.19.3)) - '@storybook/react': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) empathic: 2.0.0 magic-string: 0.30.21 - react: 19.2.4 + react: 19.2.5 react-docgen: 8.0.3 - react-dom: 19.2.4(react@19.2.4) + react-dom: 19.2.5(react@19.2.5) resolve: 1.22.11 - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tsconfig-paths: 4.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: @@ -10999,24 +11016,24 @@ snapshots: - typescript - webpack - '@storybook/react@10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)': + '@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) - react: 19.2.4 + '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + react: 19.2.5 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@6.0.2) - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@streamdown/math@1.0.2(react@19.2.4)': + '@streamdown/math@1.0.2(react@19.2.5)': dependencies: katex: 0.16.45 - react: 19.2.4 + react: 19.2.5 rehype-katex: 7.0.1 remark-math: 6.0.0 transitivePeerDependencies: @@ -11159,10 +11176,10 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-utils@0.4.0(@types/react@19.2.14)(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/devtools-utils@0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11)': optionalDependencies: '@types/react': 19.2.14 - react: 19.2.4 + react: 19.2.5 solid-js: 1.9.11 '@tanstack/devtools@0.11.2(csstype@3.2.3)(solid-js@1.9.11)': @@ -11196,10 +11213,10 @@ snapshots: '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.9.3 - '@tanstack/form-devtools@0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/form-devtools@0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': dependencies: '@tanstack/devtools-ui': 0.5.1(csstype@3.2.3)(solid-js@1.9.11) - '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.4)(solid-js@1.9.11) + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11) '@tanstack/form-core': 1.28.6 clsx: 2.1.1 dayjs: 1.11.20 @@ -11218,24 +11235,24 @@ snapshots: '@tanstack/query-devtools@5.96.2': {} - '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.11)': dependencies: '@tanstack/devtools': 0.11.2(csstype@3.2.3)(solid-js@1.9.11) '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - bufferutil - csstype - solid-js - utf-8-validate - '@tanstack/react-form-devtools@0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/react-form-devtools@0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.4)(solid-js@1.9.11) - '@tanstack/form-devtools': 0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11) - react: 19.2.4 + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11) + '@tanstack/form-devtools': 0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) + react: 19.2.5 transitivePeerDependencies: - '@types/react' - csstype @@ -11243,37 +11260,37 @@ snapshots: - solid-js - vue - '@tanstack/react-form@1.28.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-form@1.28.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/form-core': 1.28.6 - '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 transitivePeerDependencies: - react-dom - '@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.4))(react@19.2.4)': + '@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/query-devtools': 5.96.2 - '@tanstack/react-query': 5.96.2(react@19.2.4) - react: 19.2.4 + '@tanstack/react-query': 5.96.2(react@19.2.5) + react: 19.2.5 - '@tanstack/react-query@5.96.2(react@19.2.4)': + '@tanstack/react-query@5.96.2(react@19.2.5)': dependencies: '@tanstack/query-core': 5.96.2 - react: 19.2.4 + react: 19.2.5 - '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/store': 0.9.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) - '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-virtual@3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/virtual-core': 3.13.23 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@tanstack/store@0.9.3': {} @@ -11301,12 +11318,12 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -11782,36 +11799,36 @@ snapshots: '@typescript-eslint/types': 8.58.1 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260407.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260407.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260407.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260407.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260407.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260407.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260407.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260408.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260407.1': + '@typescript/native-preview@7.0.0-dev.20260408.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260407.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260407.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260407.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260407.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260407.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260407.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260407.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260408.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260408.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260408.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260408.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260408.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260408.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260408.1 '@ungap/structured-clone@1.3.0': {} @@ -11819,13 +11836,13 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@unpic/react@1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@unpic/core': 1.0.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -11868,21 +11885,21 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)': + '@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)': dependencies: '@rolldown/pluginutils': 1.0.0-rc.13 es-module-lexer: 2.0.0 estree-walker: 3.0.3 magic-string: 0.30.21 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) srvx: 0.11.15 strip-literal: 3.1.0 turbo-stream: 3.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) optionalDependencies: - react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)) + react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) '@vitest/coverage-v8@4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: @@ -12168,12 +12185,12 @@ snapshots: acorn@8.16.0: {} - agentation@3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + agentation@3.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - ahooks@3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + ahooks@3.9.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@babel/runtime': 7.29.2 '@types/js-cookie': 3.0.6 @@ -12181,8 +12198,8 @@ snapshots: intersection-observer: 0.12.2 js-cookie: 3.0.5 lodash: 4.18.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) react-fast-compare: 3.2.2 resize-observer-polyfill: 1.5.1 screenfull: 5.2.0 @@ -12461,14 +12478,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12866,11 +12883,11 @@ snapshots: dotenv@16.6.1: {} - echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.4): + echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.5): dependencies: echarts: 6.0.0 fast-deep-equal: 3.1.3 - react: 19.2.4 + react: 19.2.5 size-sensor: 1.0.3 echarts@6.0.0: @@ -12886,11 +12903,11 @@ snapshots: dependencies: embla-carousel: 8.6.0 - embla-carousel-react@8.6.0(react@19.2.4): + embla-carousel-react@8.6.0(react@19.2.5): dependencies: embla-carousel: 8.6.0 embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - react: 19.2.4 + react: 19.2.5 embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): dependencies: @@ -13010,7 +13027,7 @@ snapshots: esquery: 1.7.0 jsonc-eslint-parser: 3.1.0 - eslint-markdown@0.6.0(eslint@10.2.0(jiti@2.6.1)): + eslint-markdown@0.6.1(eslint@10.2.0(jiti@2.6.1)): dependencies: '@eslint/markdown': 7.5.1 micromark-util-normalize-identifier: 2.0.1 @@ -13076,12 +13093,12 @@ snapshots: dependencies: eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-jsdoc@62.8.1(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-jsdoc@62.9.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@es-joy/jsdoccomment': 0.84.0 + '@es-joy/jsdoccomment': 0.86.0 '@es-joy/resolve.exports': 1.2.0 are-docs-informative: 0.0.2 - comment-parser: 1.4.5 + comment-parser: 1.4.6 debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint: 10.2.0(jiti@2.6.1) @@ -13156,7 +13173,7 @@ snapshots: eslint-plugin-no-only-tests@3.3.0: {} - eslint-plugin-perfectionist@5.7.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-perfectionist@5.8.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: '@typescript-eslint/utils': 8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) eslint: 10.2.0(jiti@2.6.1) @@ -13270,7 +13287,7 @@ snapshots: '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.6 eslint: 10.2.0(jiti@2.6.1) - jsdoc-type-pratt-parser: 7.1.1 + jsdoc-type-pratt-parser: 7.2.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 @@ -13291,11 +13308,11 @@ snapshots: ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 - eslint-plugin-storybook@10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2): + eslint-plugin-storybook@10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): dependencies: '@typescript-eslint/utils': 8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) eslint: 10.2.0(jiti@2.6.1) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - supports-color - typescript @@ -13906,7 +13923,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.3(typescript@6.0.2): + i18next@26.0.4(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -14042,12 +14059,12 @@ snapshots: jiti@2.6.1: {} - jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): optionalDependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 '@types/react': 19.2.14 - react: 19.2.4 + react: 19.2.5 js-audio-recorder@1.0.7: {} @@ -14067,6 +14084,8 @@ snapshots: jsdoc-type-pratt-parser@7.1.1: {} + jsdoc-type-pratt-parser@7.2.0: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -14107,7 +14126,7 @@ snapshots: khroma@2.1.0: {} - knip@6.3.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + knip@6.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -14911,30 +14930,30 @@ snapshots: neo-async@2.6.2: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next-themes@0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0): + next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0): dependencies: - '@next/env': 16.2.2 + '@next/env': 16.2.3 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001781 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.2 - '@next/swc-darwin-x64': 16.2.2 - '@next/swc-linux-arm64-gnu': 16.2.2 - '@next/swc-linux-arm64-musl': 16.2.2 - '@next/swc-linux-x64-gnu': 16.2.2 - '@next/swc-linux-x64-musl': 16.2.2 - '@next/swc-win32-arm64-msvc': 16.2.2 - '@next/swc-win32-x64-msvc': 16.2.2 + '@next/swc-darwin-arm64': 16.2.3 + '@next/swc-darwin-x64': 16.2.3 + '@next/swc-linux-arm64-gnu': 16.2.3 + '@next/swc-linux-arm64-musl': 16.2.3 + '@next/swc-linux-x64-gnu': 16.2.3 + '@next/swc-linux-x64-musl': 16.2.3 + '@next/swc-win32-arm64-msvc': 16.2.3 + '@next/swc-win32-x64-msvc': 16.2.3 '@playwright/test': 1.59.1 sass: 1.98.0 sharp: 0.34.5 @@ -14969,12 +14988,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4): + nuqs@2.8.9(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react@19.2.5): dependencies: '@standard-schema/spec': 1.0.0 - react: 19.2.4 + react: 19.2.5 optionalDependencies: - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) object-assign@4.1.1: {} @@ -15359,11 +15378,11 @@ snapshots: punycode@2.3.1: {} - qrcode.react@4.2.0(react@19.2.4): + qrcode.react@4.2.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 - qs@6.15.0: + qs@6.15.1: dependencies: side-channel: '@nolyfill/side-channel@1.0.44' @@ -15381,15 +15400,15 @@ snapshots: strip-json-comments: 2.0.1 optional: true - re-resizable@6.11.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + re-resizable@6.11.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - react-18-input-autosize@3.0.0(react@19.2.4): + react-18-input-autosize@3.0.0(react@19.2.5): dependencies: prop-types: 15.8.1 - react: 19.2.4 + react: 19.2.5 react-docgen-typescript@2.4.0(typescript@6.0.2): dependencies: @@ -15410,143 +15429,143 @@ snapshots: transitivePeerDependencies: - supports-color - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.5(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 scheduler: 0.27.0 - react-draggable@4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-draggable@4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 prop-types: 15.8.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - react-easy-crop@5.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-easy-crop@5.5.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: normalize-wheel: 1.0.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tslib: 2.8.1 - react-error-boundary@6.1.1(react@19.2.4): + react-error-boundary@6.1.1(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 react-fast-compare@3.2.2: {} - react-hotkeys-hook@5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - react-i18next@17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2): + react-i18next@17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.3(typescript@6.0.2) - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + i18next: 26.0.4(typescript@6.0.2) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: - react-dom: 19.2.4(react@19.2.4) + react-dom: 19.2.5(react@19.2.5) typescript: 6.0.2 react-is@16.13.1: {} react-is@17.0.2: {} - react-multi-email@1.0.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-multi-email@1.0.25(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) react-papaparse@4.4.0: dependencies: '@types/papaparse': 5.5.2 papaparse: 5.5.3 - react-pdf-highlighter@8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-pdf-highlighter@8.0.0-rc.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: pdfjs-dist: 4.4.168 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-rnd: 10.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-rnd: 10.5.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-debounce: 4.0.0 - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - react-rnd@10.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-rnd@10.5.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - re-resizable: 6.11.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + re-resizable: 6.11.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tslib: 2.6.2 - react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)): + react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)): dependencies: acorn-loose: 8.5.2 neo-async: 2.6.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) webpack: 5.105.4(uglify-js@3.19.3) webpack-sources: 3.3.4 - react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7): + react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sortablejs@1.15.7): dependencies: '@types/sortablejs': 1.15.9 classnames: 2.3.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) sortablejs: 1.15.7 tiny-invariant: 1.2.0 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5): dependencies: '@babel/runtime': 7.29.2 - react: 19.2.4 - use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) - use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5) + use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5) transitivePeerDependencies: - '@types/react' - react@19.2.4: {} + react@19.2.5: {} - reactflow@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + reactflow@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@reactflow/background': 11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/controls': 11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/minimap': 11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/node-resizer': 2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/node-toolbar': 1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@reactflow/background': 11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/controls': 11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/minimap': 11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/node-resizer': 2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/node-toolbar': 1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer @@ -15997,10 +16016,10 @@ snapshots: std-semver@1.0.8: {} - storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 @@ -16010,7 +16029,7 @@ snapshots: open: 10.2.0 recast: 0.23.11 semver: 7.7.4 - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) ws: 8.20.0 transitivePeerDependencies: - '@testing-library/dom' @@ -16019,15 +16038,15 @@ snapshots: - react-dom - utf-8-validate - streamdown@2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + streamdown@2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 marked: 17.0.5 mermaid: 11.14.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) rehype-harden: 1.1.8 rehype-raw: 7.0.0 rehype-sanitize: 6.0.0 @@ -16096,10 +16115,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): dependencies: client-only: 0.0.1 - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@babel/core': 7.29.0 @@ -16405,50 +16424,50 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4): + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - use-context-selector@2.0.0(react@19.2.4)(scheduler@0.27.0): + use-context-selector@2.0.0(react@19.2.5)(scheduler@0.27.0): dependencies: - react: 19.2.4 + react: 19.2.5 scheduler: 0.27.0 - use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): dependencies: detect-node-es: 1.1.0 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 use-strict@1.0.1: {} - use-sync-external-store@1.6.0(react@19.2.4): + use-sync-external-store@1.6.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 util-arity@1.1.0: {} @@ -16482,21 +16501,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.40(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2): + vinext@https://pkg.pr.new/vinext@adbf24d(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2): dependencies: - '@unpic/react': 1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@unpic/react': 1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) magic-string: 0.30.21 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plugin-commonjs: 0.10.4 vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) optionalDependencies: '@mdx-js/rollup': 3.1.1(rollup@4.59.0) - '@vitejs/plugin-rsc': 0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4) - react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)) + '@vitejs/plugin-rsc': 0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5) + react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) transitivePeerDependencies: - next - supports-color @@ -16531,14 +16550,14 @@ snapshots: - typescript - ws - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) - storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) @@ -16772,23 +16791,23 @@ snapshots: dependencies: tslib: 2.3.0 - zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))): + zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))): dependencies: - zustand: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + zustand: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) - zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): + zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5): dependencies: - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 immer: 11.1.4 - react: 19.2.4 + react: 19.2.5 - zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14 immer: 11.1.4 - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7918fff1b..f715787766 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -47,7 +47,7 @@ overrides: catalog: "@amplitude/analytics-browser": 2.38.1 "@amplitude/plugin-session-replay-browser": 1.27.6 - "@antfu/eslint-config": 8.0.0 + "@antfu/eslint-config": 8.1.1 "@base-ui/react": 1.3.0 "@chromatic-com/storybook": 5.1.1 "@cucumber/cucumber": 12.7.0 @@ -73,8 +73,8 @@ catalog: "@mdx-js/react": 3.1.1 "@mdx-js/rollup": 3.1.1 "@monaco-editor/react": 4.7.0 - "@next/eslint-plugin-next": 16.2.2 - "@next/mdx": 16.2.2 + "@next/eslint-plugin-next": 16.2.3 + "@next/mdx": 16.2.3 "@orpc/client": 1.13.13 "@orpc/contract": 1.13.13 "@orpc/openapi-client": 1.13.13 @@ -120,9 +120,9 @@ catalog: "@types/sortablejs": 1.15.9 "@typescript-eslint/eslint-plugin": 8.58.1 "@typescript-eslint/parser": 8.58.1 - "@typescript/native-preview": 7.0.0-dev.20260407.1 + "@typescript/native-preview": 7.0.0-dev.20260408.1 "@vitejs/plugin-react": 6.0.1 - "@vitejs/plugin-rsc": 0.5.22 + "@vitejs/plugin-rsc": 0.5.23 "@vitest/coverage-v8": 4.1.3 abcjs: 6.6.2 agentation: 3.0.2 @@ -146,7 +146,7 @@ catalog: emoji-mart: 5.6.0 es-toolkit: 1.45.1 eslint: 10.2.0 - eslint-markdown: 0.6.0 + eslint-markdown: 0.6.1 eslint-plugin-better-tailwindcss: 4.3.2 eslint-plugin-hyoban: 0.14.1 eslint-plugin-markdown-preferences: 0.41.0 @@ -160,7 +160,7 @@ catalog: hono: 4.12.12 html-entities: 2.6.0 html-to-image: 1.11.13 - i18next: 26.0.3 + i18next: 26.0.4 i18next-resources-to-backend: 1.2.1 iconify-import-svg: 0.1.2 immer: 11.1.4 @@ -170,7 +170,7 @@ catalog: js-yaml: 4.1.1 jsonschema: 1.5.0 katex: 0.16.45 - knip: 6.3.0 + knip: 6.3.1 ky: 2.0.0 lamejs: 1.2.1 lexical: 0.42.0 @@ -178,24 +178,24 @@ catalog: mime: 4.1.0 mitt: 3.0.1 negotiator: 1.0.0 - next: 16.2.2 + next: 16.2.3 next-themes: 0.4.6 nuqs: 2.8.9 pinyin-pro: 3.28.0 postcss: 8.5.9 postcss-js: 5.1.0 qrcode.react: 4.2.0 - qs: 6.15.0 - react: 19.2.4 + qs: 6.15.1 + react: 19.2.5 react-18-input-autosize: 3.0.0 - react-dom: 19.2.4 + react-dom: 19.2.5 react-easy-crop: 5.5.7 react-hotkeys-hook: 5.2.4 react-i18next: 17.0.2 react-multi-email: 1.0.25 react-papaparse: 4.4.0 react-pdf-highlighter: 8.0.0-rc.0 - react-server-dom-webpack: 19.2.4 + react-server-dom-webpack: 19.2.5 react-sortablejs: 6.1.4 react-textarea-autosize: 8.5.9 reactflow: 11.11.4 @@ -219,7 +219,7 @@ catalog: unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 13.0.0 - vinext: 0.0.40 + vinext: https://pkg.pr.new/vinext@adbf24d vite: npm:@voidzero-dev/vite-plus-core@0.1.16 vite-plugin-inspect: 12.0.0-beta.1 vite-plus: 0.1.16 From d1e33ba9eae1135490c5ca4d93ddf31096a75c27 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 9 Apr 2026 15:59:15 +0800 Subject: [PATCH 49/53] refactor(api): reduce Dify GraphInitParams usage (#34825) --- api/core/app/apps/pipeline/pipeline_runner.py | 26 ++++----- api/core/app/apps/workflow_app_runner.py | 54 ++++++++++--------- api/core/workflow/node_factory.py | 39 ++++++++++++++ .../workflow/nodes/trigger_webhook/node.py | 2 +- api/core/workflow/workflow_entry.py | 53 ++++++++++-------- api/services/workflow_service.py | 29 ++++++---- .../core/workflow/test_node_factory.py | 45 ++++++++++++++++ .../workflow/test_workflow_entry_helpers.py | 38 +++++++------ .../services/test_workflow_service.py | 19 +++++-- 9 files changed, 215 insertions(+), 90 deletions(-) diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index b4d2310da8..36daaf09e9 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -2,7 +2,6 @@ import logging import time from typing import cast -from graphon.entities import GraphInitParams from graphon.enums import WorkflowType from graphon.graph import Graph from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent @@ -22,7 +21,7 @@ from core.app.entities.app_invoke_entities import ( ) from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository -from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id +from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry @@ -265,22 +264,23 @@ class PipelineRunner(WorkflowBasedAppRunner): # graph_config["nodes"] = real_run_nodes # graph_config["edges"] = real_edges # init graph - # Create required parameters for Graph.init - graph_init_params = GraphInitParams( + # Create explicit graph init context for Graph.init. + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=self._app_id, + user_id=self.application_generate_entity.user_id, + user_from=user_from, + invoke_from=invoke_from, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=graph_config, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=self._app_id, - user_id=self.application_generate_entity.user_id, - user_from=user_from, - invoke_from=invoke_from, - ), + run_context=run_context, call_depth=0, ) - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) if start_node_id is None: diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index caa6b82bab..437432611d 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -3,7 +3,6 @@ import time from collections.abc import Mapping, Sequence from typing import Any, cast -from graphon.entities import GraphInitParams from graphon.entities.graph_config import NodeConfigDictAdapter from graphon.entities.pause_reason import HumanInputRequired from graphon.graph import Graph @@ -67,7 +66,12 @@ from core.app.entities.queue_entities import ( QueueWorkflowSucceededEvent, ) from core.rag.entities import RetrievalSourceMetadata -from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id, resolve_workflow_node_class +from core.workflow.node_factory import ( + DifyGraphInitContext, + DifyNodeFactory, + get_default_root_node_id, + resolve_workflow_node_class, +) from core.workflow.system_variables import ( build_bootstrap_variables, default_system_variables, @@ -127,24 +131,25 @@ class WorkflowBasedAppRunner: if not isinstance(graph_config.get("edges"), list): raise ValueError("edges in workflow graph must be a list") - # Create required parameters for Graph.init - graph_init_params = GraphInitParams( + # Create explicit graph init context for Graph.init. + run_context = build_dify_run_context( + tenant_id=tenant_id or "", + app_id=self._app_id, + user_id=user_id, + user_from=user_from, + invoke_from=invoke_from, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow_id, graph_config=graph_config, - run_context=build_dify_run_context( - tenant_id=tenant_id or "", - app_id=self._app_id, - user_id=user_id, - user_from=user_from, - invoke_from=invoke_from, - ), + run_context=run_context, call_depth=0, ) # Use the provided graph_runtime_state for consistent state management - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) @@ -289,22 +294,23 @@ class WorkflowBasedAppRunner: typed_node_configs = [NodeConfigDictAdapter.validate_python(node) for node in node_configs] - # Create required parameters for Graph.init - graph_init_params = GraphInitParams( + # Create explicit graph init context for Graph.init. + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=self._app_id, + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=graph_config, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=self._app_id, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index f6c3aee4c1..b04ac7da3d 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -1,6 +1,7 @@ import importlib import pkgutil from collections.abc import Callable, Iterator, Mapping, MutableMapping +from dataclasses import dataclass from functools import lru_cache from typing import TYPE_CHECKING, Any, cast, final, override @@ -67,6 +68,31 @@ _START_NODE_TYPES: frozenset[NodeType] = frozenset( ) +@dataclass(frozen=True, slots=True) +class DifyGraphInitContext: + """Explicit graph-init values owned by the workflow layer. + + Dify is gradually removing direct `GraphInitParams` construction from its + production call sites. Keep the translation here until `graphon` exposes an + equivalent explicit API. + """ + + workflow_id: str + graph_config: Mapping[str, Any] + run_context: Mapping[str, Any] + call_depth: int + + def to_graph_init_params(self) -> "GraphInitParams": + from graphon.entities import GraphInitParams + + return GraphInitParams( + workflow_id=self.workflow_id, + graph_config=self.graph_config, + run_context=self.run_context, + call_depth=self.call_depth, + ) + + def _import_node_package(package_name: str, *, excluded_modules: frozenset[str] = frozenset()) -> None: package = importlib.import_module(package_name) for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): @@ -237,6 +263,19 @@ class DifyNodeFactory(NodeFactory): Default implementation of NodeFactory that resolves node classes from the live registry. """ + @classmethod + def from_graph_init_context( + cls, + *, + graph_init_context: DifyGraphInitContext, + graph_runtime_state: "GraphRuntimeState", + ) -> "DifyNodeFactory": + """Bridge Dify's explicit init context into the current `graphon` API.""" + return cls( + graph_init_params=graph_init_context.to_graph_init_params(), + graph_runtime_state=graph_runtime_state, + ) + def __init__( self, graph_init_params: "GraphInitParams", diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index 6a0d633627..8c866aea81 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -29,7 +29,7 @@ class TriggerWebhookNode(Node[WebhookData]): def post_init(self) -> None: from core.workflow.node_runtime import DifyFileReferenceFactory - self._file_reference_factory = DifyFileReferenceFactory(self.graph_init_params.run_context) + self._file_reference_factory = DifyFileReferenceFactory(self.run_context) @classmethod def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index cecc20145a..f0a5fbb400 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -24,7 +24,12 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_di from core.app.file_access import DatabaseFileAccessController from core.app.workflow.layers.llm_quota import LLMQuotaLayer from core.app.workflow.layers.observability import ObservabilityLayer -from core.workflow.node_factory import DifyNodeFactory, is_start_node_type, resolve_workflow_node_class +from core.workflow.node_factory import ( + DifyGraphInitContext, + DifyNodeFactory, + is_start_node_type, + resolve_workflow_node_class, +) from core.workflow.system_variables import ( default_system_variables, get_node_creation_preload_selectors, @@ -251,17 +256,18 @@ class WorkflowEntry: node_version = str(node_config_data.version) node_cls = resolve_workflow_node_class(node_type=node_type, node_version=node_version) - # init graph init params and runtime state - graph_init_params = GraphInitParams( + # init graph context and runtime state + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=workflow.graph_dict, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) graph_runtime_state = GraphRuntimeState( @@ -313,8 +319,8 @@ class WorkflowEntry: ) # init workflow run state - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) node = node_factory.create_node(node_config) @@ -409,17 +415,18 @@ class WorkflowEntry: variable_pool = VariablePool() add_variables_to_pool(variable_pool, default_system_variables()) - # init graph init params and runtime state - graph_init_params = GraphInitParams( + # init graph context and runtime state + run_context = build_dify_run_context( + tenant_id=tenant_id, + app_id="", + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id="", graph_config=graph_dict, - run_context=build_dify_run_context( - tenant_id=tenant_id, - app_id="", - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) graph_runtime_state = GraphRuntimeState( @@ -430,8 +437,8 @@ class WorkflowEntry: # init workflow run state node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": node_data}) - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) node = node_factory.create_node(node_config) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 1e3feeed29..c28704e83b 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -5,7 +5,7 @@ import uuid from collections.abc import Callable, Generator, Mapping, Sequence from typing import Any, cast -from graphon.entities import GraphInitParams, WorkflowNodeExecution +from graphon.entities import WorkflowNodeExecution from graphon.entities.graph_config import NodeConfigDict from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import ( @@ -48,7 +48,12 @@ from core.workflow.human_input_compat import ( normalize_human_input_node_data_for_graph, parse_human_input_delivery_methods, ) -from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping, is_start_node_type +from core.workflow.node_factory import ( + LATEST_VERSION, + DifyGraphInitContext, + get_node_type_classes_mapping, + is_start_node_type, +) from core.workflow.node_runtime import DifyHumanInputNodeRuntime, apply_dify_debug_email_recipient from core.workflow.system_variables import build_bootstrap_variables, build_system_variables, default_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool @@ -1132,18 +1137,20 @@ class WorkflowService: node_config: NodeConfigDict, variable_pool: VariablePool, ) -> HumanInputNode: - graph_init_params = GraphInitParams( + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + user_id=account.id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=workflow.graph_dict, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - user_id=account.id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) + graph_init_params = graph_init_context.to_graph_init_params() graph_runtime_state = GraphRuntimeState( variable_pool=variable_pool, start_at=time.perf_counter(), @@ -1153,7 +1160,7 @@ class WorkflowService: config=node_config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + runtime=DifyHumanInputNodeRuntime(run_context), ) return node diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index bc0b339fec..dfe1a47e37 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -110,6 +110,34 @@ class TestFetchMemory: ) +class TestDifyGraphInitContext: + def test_to_graph_init_params_preserves_explicit_values(self): + run_context = { + DIFY_RUN_CONTEXT_KEY: DifyRunContext( + tenant_id="tenant-id", + app_id="app-id", + user_id="user-id", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ), + "extra": "value", + } + graph_config = {"nodes": [], "edges": []} + graph_init_context = node_factory.DifyGraphInitContext( + workflow_id="workflow-id", + graph_config=graph_config, + run_context=run_context, + call_depth=2, + ) + + result = graph_init_context.to_graph_init_params() + + assert result.workflow_id == "workflow-id" + assert result.graph_config == graph_config + assert result.run_context == run_context + assert result.call_depth == 2 + + class TestDefaultWorkflowCodeExecutor: def test_execute_delegates_to_code_executor(self, monkeypatch): executor = node_factory.DefaultWorkflowCodeExecutor() @@ -172,6 +200,23 @@ class TestCodeExecutorJinja2TemplateRenderer: class TestDifyNodeFactoryInit: + def test_from_graph_init_context_translates_before_init(self): + graph_init_context = MagicMock() + graph_init_context.to_graph_init_params.return_value = sentinel.graph_init_params + + with patch.object(node_factory.DifyNodeFactory, "__init__", return_value=None) as init: + factory = node_factory.DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, + graph_runtime_state=sentinel.graph_runtime_state, + ) + + assert isinstance(factory, node_factory.DifyNodeFactory) + graph_init_context.to_graph_init_params.assert_called_once_with() + init.assert_called_once_with( + graph_init_params=sentinel.graph_init_params, + graph_runtime_state=sentinel.graph_runtime_state, + ) + def test_init_builds_default_dependencies(self): graph_init_params = SimpleNamespace(run_context={"context": "value"}) graph_runtime_state = sentinel.graph_runtime_state diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index 879c0bb721..6dcaed1143 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -349,7 +349,7 @@ class TestWorkflowEntrySingleStepRun: ] with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object( workflow_entry, "GraphRuntimeState", @@ -358,7 +358,7 @@ class TestWorkflowEntrySingleStepRun: patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeLLMNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "load_into_variable_pool"), patch.object(workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool"), patch.object( @@ -412,12 +412,12 @@ class TestWorkflowEntrySingleStepRun: raise NotImplementedError with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "add_node_inputs_to_pool") as add_node_inputs_to_pool, patch.object(workflow_entry, "load_into_variable_pool") as load_into_variable_pool, patch.object( @@ -481,12 +481,12 @@ class TestWorkflowEntrySingleStepRun: return {"question": ["node", "question"]} with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeDatasourceNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "add_node_inputs_to_pool") as add_node_inputs_to_pool, patch.object(workflow_entry, "load_into_variable_pool") as load_into_variable_pool, patch.object( @@ -541,12 +541,12 @@ class TestWorkflowEntrySingleStepRun: return "1" with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "add_node_inputs_to_pool"), patch.object(workflow_entry, "load_into_variable_pool"), patch.object(workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool"), @@ -651,14 +651,18 @@ class TestWorkflowEntryHelpers: patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool) as variable_pool_cls, patch.object(workflow_entry, "add_variables_to_pool") as add_variables_to_pool, patch.object( - workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params - ) as graph_init_params, + workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context + ) as graph_init_context_cls, patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object( workflow_entry, "build_dify_run_context", return_value={"_dify": "context"} ) as build_dify_run_context, patch.object(workflow_entry.time, "perf_counter", return_value=123.0), - patch.object(workflow_entry, "DifyNodeFactory", return_value=dify_node_factory) as dify_node_factory_cls, + patch.object( + workflow_entry.DifyNodeFactory, + "from_graph_init_context", + return_value=dify_node_factory, + ) as dify_node_factory_cls, patch.object( workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool", @@ -688,7 +692,7 @@ class TestWorkflowEntryHelpers: user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) - graph_init_params.assert_called_once_with( + graph_init_context_cls.assert_called_once_with( workflow_id="", graph_config=workflow_entry.WorkflowEntry._create_single_node_graph( "node-id", {"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR, "title": "Node"} @@ -697,7 +701,7 @@ class TestWorkflowEntryHelpers: call_depth=0, ) dify_node_factory_cls.assert_called_once_with( - graph_init_params=sentinel.graph_init_params, + graph_init_context=sentinel.graph_init_context, graph_runtime_state=sentinel.graph_runtime_state, ) mapping_user_inputs_to_variable_pool.assert_called_once_with( @@ -734,11 +738,15 @@ class TestWorkflowEntryHelpers: patch.object(workflow_entry, "default_system_variables", return_value=sentinel.system_variables), patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool), patch.object(workflow_entry, "add_variables_to_pool"), - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), - patch.object(workflow_entry, "DifyNodeFactory", return_value=dify_node_factory), + patch.object( + workflow_entry.DifyNodeFactory, + "from_graph_init_context", + return_value=dify_node_factory, + ), patch.object( workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool", diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 1b253eb2f1..76fcb19ab2 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -2753,9 +2753,9 @@ class TestWorkflowServiceFreeNodeExecution: variable_pool = MagicMock() with ( - patch("services.workflow_service.GraphInitParams") as mock_graph_init_params, + patch("services.workflow_service.DifyGraphInitContext") as mock_graph_init_context_cls, patch("services.workflow_service.GraphRuntimeState"), - patch("services.workflow_service.build_dify_run_context"), + patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context, patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls, patch("services.workflow_service.HumanInputNode") as mock_node_cls, ): @@ -2764,4 +2764,17 @@ class TestWorkflowServiceFreeNodeExecution: ) assert node == mock_node_cls.return_value mock_node_cls.assert_called_once() - mock_runtime_cls.assert_called_once_with(mock_graph_init_params.return_value.run_context) + mock_graph_init_context_cls.assert_called_once_with( + workflow_id="wf-1", + graph_config=workflow.graph_dict, + run_context=mock_build_dify_run_context.return_value, + call_depth=0, + ) + mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value) + mock_node_cls.assert_called_once_with( + id="n-1", + config=node_config, + graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value, + graph_runtime_state=ANY, + runtime=mock_runtime_cls.return_value, + ) From 1befd2a602c54c5cbfba4e93ecba3a35a53ff104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B9=B3=E8=A1=A1=E4=B8=96=E7=95=8C=E7=9A=84BOY?= <2539888062@qq.com> Date: Thu, 9 Apr 2026 16:01:23 +0800 Subject: [PATCH 50/53] fix(web): resolve Dify compact array types in tool output schema (#34804) --- .../__tests__/output-schema-utils.spec.ts | 17 +++++++++++ .../nodes/tool/output-schema-utils.ts | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts index 4d095ab189..f5179742b2 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts +++ b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts @@ -229,6 +229,23 @@ describe('output-schema-utils', () => { }) }) + describe('Dify compact types (workflow-as-tool output_schema)', () => { + it('should resolve array[string] to arrayString (issue #34428)', () => { + const result = resolveVarType({ type: 'array[string]' }) + expect(result.type).toBe(VarType.arrayString) + }) + + it('should resolve Array[string] case-insensitively', () => { + const result = resolveVarType({ type: 'Array[string]' }) + expect(result.type).toBe(VarType.arrayString) + }) + + it('should resolve array[object] to arrayObject', () => { + const result = resolveVarType({ type: 'array[object]' }) + expect(result.type).toBe(VarType.arrayObject) + }) + }) + describe('unknown types', () => { it('should resolve unknown type to any', () => { const result = resolveVarType({ type: 'unknown_type' }) diff --git a/web/app/components/workflow/nodes/tool/output-schema-utils.ts b/web/app/components/workflow/nodes/tool/output-schema-utils.ts index 141c679da0..630673e3e9 100644 --- a/web/app/components/workflow/nodes/tool/output-schema-utils.ts +++ b/web/app/components/workflow/nodes/tool/output-schema-utils.ts @@ -2,6 +2,30 @@ import type { SchemaTypeDefinition } from '@/service/use-common' import { VarType } from '@/app/components/workflow/types' import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type' +/** + * Workflow-as-tool and some internal APIs store Dify VarType strings (e.g. `array[string]`) + * in JSON Schema `type` instead of standard `{ type: 'array', items: { type: 'string' } }`. + * Map those compact strings to VarType so downstream (e.g. Code node var picker) does not + * fall back to `any` and get filtered out. + */ +const resolveDifyCompactTypeString = (typeStr: string): VarType | undefined => { + const trimmed = typeStr.trim() + const m = /^array\[(string|number|integer|boolean|object|file|any)\]$/i.exec(trimmed) + if (!m) + return undefined + const inner = m[1].toLowerCase() + const map: Record = { + string: VarType.arrayString, + number: VarType.arrayNumber, + integer: VarType.arrayNumber, + boolean: VarType.arrayBoolean, + object: VarType.arrayObject, + file: VarType.arrayFile, + any: VarType.arrayAny, + } + return map[inner] +} + /** * Normalizes a JSON Schema type to a simple string type. * Handles complex schemas with oneOf, anyOf, allOf. @@ -54,6 +78,12 @@ export const resolveVarType = ( schemaTypeDefinitions?: SchemaTypeDefinition[], ): { type: VarType, schemaType?: string } => { const schemaType = getMatchedSchemaType(schema, schemaTypeDefinitions) + if (schema && typeof schema.type === 'string') { + const compact = resolveDifyCompactTypeString(schema.type) + if (compact !== undefined) + return { type: compact, schemaType } + } + const normalizedType = normalizeJsonSchemaType(schema) switch (normalizedType) { From 03750b76acef2d00478d0b561329287e2e285417 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 9 Apr 2026 17:16:25 +0900 Subject: [PATCH 51/53] ci: bump pyrefly version (#34821) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 2f6581a199..086ce5bb72 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -171,7 +171,7 @@ dev = [ "sseclient-py>=1.8.0", "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "pyrefly>=0.59.1", + "pyrefly>=0.60.0", ] ############################################################ diff --git a/api/uv.lock b/api/uv.lock index b1145eac56..b67646cb71 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1572,7 +1572,7 @@ dev = [ { name = "lxml-stubs", specifier = "~=0.5.1" }, { name = "mypy", specifier = "~=1.20.0" }, { name = "pandas-stubs", specifier = "~=3.0.0" }, - { name = "pyrefly", specifier = ">=0.59.1" }, + { name = "pyrefly", specifier = ">=0.60.0" }, { name = "pytest", specifier = "~=9.0.2" }, { name = "pytest-benchmark", specifier = "~=5.2.3" }, { name = "pytest-cov", specifier = "~=7.1.0" }, @@ -4825,19 +4825,19 @@ wheels = [ [[package]] name = "pyrefly" -version = "0.59.1" +version = "0.60.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/ce/7882c2af92b2ff6505fcd3430eff8048ece6c6254cc90bdc76ecee12dfab/pyrefly-0.59.1.tar.gz", hash = "sha256:bf1675b0c38d45df2c8f8618cbdfa261a1b92430d9d31eba16e0282b551e210f", size = 5475432, upload-time = "2026-04-01T22:04:04.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/c7/28d14b64888e2d03815627ebff8d57a9f08389c4bbebfe70ae1ed98a1267/pyrefly-0.60.0.tar.gz", hash = "sha256:2499f5b6ff5342e86dfe1cd94bcce133519bbbc93b7ad5636195fea4f0fa3b81", size = 5500389, upload-time = "2026-04-06T19:57:30.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/10/04a0e05b08fc855b6fe38c3df549925fc3c2c6e750506870de7335d3e1f7/pyrefly-0.59.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:390db3cd14aa7e0268e847b60cd9ee18b04273eddfa38cf341ed3bb43f3fef2a", size = 12868133, upload-time = "2026-04-01T22:03:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/fa7be227c3e3fcacee501c1562278dd026186ffd1b5b5beb51d3941a3aed/pyrefly-0.59.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d246d417b6187c1650d7f855f61c68fbfd6d6155dc846d4e4d273a3e6b5175cb", size = 12379325, upload-time = "2026-04-01T22:03:42.046Z" }, - { url = "https://files.pythonhosted.org/packages/bb/13/6828ce1c98171b5f8388f33c4b0b9ea2ab8c49abe0ef8d793c31e30a05cb/pyrefly-0.59.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:575ac67b04412dc651a7143d27e38a40fbdd3c831c714d5520d0e9d4c8631ab4", size = 35826408, upload-time = "2026-04-01T22:03:45.067Z" }, - { url = "https://files.pythonhosted.org/packages/23/56/79ed8ece9a7ecad0113c394a06a084107db3ad8f1fefe19e7ded43c51245/pyrefly-0.59.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:062e6262ce1064d59dcad81ac0499bb7a3ad501e9bc8a677a50dc630ff0bf862", size = 38532699, upload-time = "2026-04-01T22:03:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/18/7d/ecc025e0f0e3f295b497f523cc19cefaa39e57abede8fc353d29445d174b/pyrefly-0.59.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ef4247f9e6f734feb93e1f2b75335b943629956e509f545cc9cdcccd76dd20", size = 36743570, upload-time = "2026-04-01T22:03:51.362Z" }, - { url = "https://files.pythonhosted.org/packages/2f/03/b1ce882ebcb87c673165c00451fbe4df17bf96ccfde18c75880dc87c5f5e/pyrefly-0.59.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a2d01723b84d042f4fa6ec871ffd52d0a7e83b0ea791c2e0bb0ff750abce56", size = 41236246, upload-time = "2026-04-01T22:03:54.361Z" }, - { url = "https://files.pythonhosted.org/packages/17/af/5e9c7afd510e7dd64a2204be0ed39e804089cbc4338675a28615c7176acb/pyrefly-0.59.1-py3-none-win32.whl", hash = "sha256:4ea70c780848f8376411e787643ae5d2d09da8a829362332b7b26d15ebcbaf56", size = 11884747, upload-time = "2026-04-01T22:03:56.776Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c1/7db1077627453fd1068f0761f059a9512645c00c4c20acfb9f0c24ac02ec/pyrefly-0.59.1-py3-none-win_amd64.whl", hash = "sha256:67e6a08cfd129a0d2788d5e40a627f9860e0fe91a876238d93d5c63ff4af68ae", size = 12720608, upload-time = "2026-04-01T22:03:59.252Z" }, - { url = "https://files.pythonhosted.org/packages/07/16/4bb6e5fce5a9cf0992932d9435d964c33e507aaaf96fdfbb1be493078a4a/pyrefly-0.59.1-py3-none-win_arm64.whl", hash = "sha256:01179cb215cf079e8223a064f61a074f7079aa97ea705cbbc68af3d6713afd15", size = 12223158, upload-time = "2026-04-01T22:04:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/31/99/6c9984a09220e5eb7dd5c869b7a32d25c3d06b5e8854c6eb679db1145c3e/pyrefly-0.60.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bf1691af0fee69d0c99c3c6e9d26ab6acd3c8afef96416f9ba2e74934833b7b5", size = 12921262, upload-time = "2026-04-06T19:57:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/b3/6216aa3c00c88e59a27eb4149851b5affe86eeea6129f4224034a32dddb0/pyrefly-0.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3e71b70c9b95545cf3b479bc55d1381b531de7b2380eb64411088a1e56b634cb", size = 12424413, upload-time = "2026-04-06T19:57:03.417Z" }, + { url = "https://files.pythonhosted.org/packages/9b/87/eb8dd73abd92a93952ac27a605e463c432fb250fb23186574038c7035594/pyrefly-0.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:680ee5f8f98230ea145652d7344708f5375786209c5bf03d8b911fdb0d0d4195", size = 35940884, upload-time = "2026-04-06T19:57:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/0d/34/dc6aeb67b840c745fcee6db358295d554abe6ab555a7eaaf44624bd80bf1/pyrefly-0.60.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d0b20dbbe4aff15b959e8d825b7521a144c4122c11e57022e83b36568c54470", size = 38677220, upload-time = "2026-04-06T19:57:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/66/6b/c863fcf7ef592b7d1db91502acf0d1113be8bed7a2a7143fc6f0dd90616f/pyrefly-0.60.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2911563c8e6b2eaefff68885c94727965469a35375a409235a7a4d2b7157dc15", size = 36907431, upload-time = "2026-04-06T19:57:15.074Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/25ea095ab2ecca8e62884669b11a79f14299db93071685b73a97efbaf4f3/pyrefly-0.60.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0a631d9d04705e303fe156f2e62551611bc7ef8066c34708ceebcfb3088bd55", size = 41447898, upload-time = "2026-04-06T19:57:19.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2c/097bdc6e8d40676b28eb03710a4577bc3c7b803cd24693ac02bf15de3d67/pyrefly-0.60.0-py3-none-win32.whl", hash = "sha256:a08d69298da5626cf502d3debbb6944fd13d2f405ea6625363751f1ff570d366", size = 11913434, upload-time = "2026-04-06T19:57:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d4/8d27fe310e830c8d11ab73db38b93f9fd2e218744b6efb1204401c9a74d5/pyrefly-0.60.0-py3-none-win_amd64.whl", hash = "sha256:56cf30654e708ae1dd635ffefcba4fa4b349dd7004a6ccc5c41e3a9bb944320c", size = 12745033, upload-time = "2026-04-06T19:57:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ad/8eea1f8fb8209f91f6dbfe48000c9d05fd0cdb1b5b3157283c9b1dada55d/pyrefly-0.60.0-py3-none-win_arm64.whl", hash = "sha256:b6d27fba970f4777063c0227c54167d83bece1804ea34f69e7118e409ba038d2", size = 12246390, upload-time = "2026-04-06T19:57:28.141Z" }, ] [[package]] From d042cbc62e24833026c0d1e8840d7ec99addb65b Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 9 Apr 2026 16:22:09 +0800 Subject: [PATCH 52/53] fix: fix remove_leading_symbols remove [ (#34832) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/tools/utils/text_processing_utils.py | 15 +++++- .../unit_tests/utils/test_text_processing.py | 52 ++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/api/core/tools/utils/text_processing_utils.py b/api/core/tools/utils/text_processing_utils.py index 4bfaa5e49b..1dd0605f28 100644 --- a/api/core/tools/utils/text_processing_utils.py +++ b/api/core/tools/utils/text_processing_utils.py @@ -19,5 +19,18 @@ def remove_leading_symbols(text: str) -> str: # Match Unicode ranges for punctuation and symbols # FIXME this pattern is confused quick fix for #11868 maybe refactor it later - pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+' + pattern = re.compile( + r""" + ^ + (?: + [\u2000-\u2025] # General Punctuation: spaces, quotes, dashes + | [\u2027-\u206F] # General Punctuation: ellipsis, underscores, etc. + | [\u2E00-\u2E7F] # Supplemental Punctuation: medieval, ancient marks + | [\u3000-\u300F] # CJK Punctuation: 、。〃「」『》』 (excludes 【】) + | [\u3012-\u303F] # CJK Punctuation: 〖〗〔〕〘〙〚〛〜 etc. + | ["#$%&'()*+,./:;<=>?@^_`~] # ASCII punctuation (excludes []【】) + )+ + """, + re.VERBOSE, + ) return re.sub(pattern, "", text) diff --git a/api/tests/unit_tests/utils/test_text_processing.py b/api/tests/unit_tests/utils/test_text_processing.py index bf61162a66..5f6ccbcdff 100644 --- a/api/tests/unit_tests/utils/test_text_processing.py +++ b/api/tests/unit_tests/utils/test_text_processing.py @@ -19,7 +19,57 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols ("[Google](https://google.com) is a search engine", "[Google](https://google.com) is a search engine"), ("[Example](http://example.com) some text", "[Example](http://example.com) some text"), # Leading symbols before markdown link are removed, including the opening bracket [ - ("@[Test](https://example.com)", "Test](https://example.com)"), + ("@[Test](https://example.com)", "[Test](https://example.com)"), + ("~~标题~~", "标题~~"), + ('""quoted', "quoted"), + ("''test", "test"), + ("##话题", "话题"), + ("$$价格", "价格"), + ("%%百分比", "百分比"), + ("&&与逻辑", "与逻辑"), + ("((括号))", "括号))"), + ("**强调**", "强调**"), + ("++自增", "自增"), + (",,逗号", "逗号"), + ("..省略", "省略"), + ("//注释", "注释"), + ("::范围", "范围"), + (";;分号", "分号"), + ("<<左移", "左移"), + ("==等于", "等于"), + (">>右移", "右移"), + ("??疑问", "疑问"), + ("@@提及", "提及"), + ("^^上标", "上标"), + ("__下划线", "下划线"), + ("``代码", "代码"), + ("~~删除线", "删除线"), + (" 全角空格开头", "全角空格开头"), + ("、顿号开头", "顿号开头"), + ("。句号开头", "句号开头"), + ("「引号」测试", "引号」测试"), + ("『书名号』", "书名号』"), + ("【保留】测试", "【保留】测试"), + ("〖括号〗测试", "括号〗测试"), + ("〔括号〕测试", "括号〕测试"), + ("~~【保留】~~", "【保留】~~"), + ('"[公告]"', '[公告]"'), + ("[公告] 更新", "[公告] 更新"), + ("【通知】重要", "【通知】重要"), + ("[[嵌套]]", "[[嵌套]]"), + ("【【嵌套】】", "【【嵌套】】"), + ("[【混合】]", "[【混合】]"), + ("normal text", "normal text"), + ("123数字", "123数字"), + ("中文开头", "中文开头"), + ("alpha", "alpha"), + ("~", ""), + ("【", "【"), + ("[", "["), + ("~~~", ""), + ("【【【", "【【【"), + ("\t制表符", "\t制表符"), + ("\n换行", "\n换行"), ], ) def test_remove_leading_symbols(input_text, expected_output): From 02c1bfc3e7932884d1b7cdc9b8fab567261c658a Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Thu, 9 Apr 2026 16:35:01 +0800 Subject: [PATCH 53/53] chore: install from npm for vinext (#34840) --- pnpm-lock.yaml | 13 ++++++------- pnpm-workspace.yaml | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b61ca1b0ee..869b425bb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,8 +514,8 @@ catalogs: specifier: 13.0.0 version: 13.0.0 vinext: - specifier: https://pkg.pr.new/vinext@adbf24d - version: 0.0.5 + specifier: 0.0.41 + version: 0.0.41 vite-plugin-inspect: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 @@ -1150,7 +1150,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: https://pkg.pr.new/vinext@adbf24d(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2) + version: 0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' @@ -8303,9 +8303,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vinext@https://pkg.pr.new/vinext@adbf24d: - resolution: {tarball: https://pkg.pr.new/vinext@adbf24d} - version: 0.0.5 + vinext@0.0.41: + resolution: {integrity: sha512-fpQjNp6cIqjYGH2/kbhN2SdIYHEu79RdlII23SWsY1Qp7LM+je8GfTJH1sxw6dASxPhZKZB/jCmTm5d2/D25zw==} engines: {node: '>=22'} hasBin: true peerDependencies: @@ -16501,7 +16500,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@https://pkg.pr.new/vinext@adbf24d(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2): + vinext@0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2): dependencies: '@unpic/react': 1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f715787766..92c7886245 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -219,7 +219,7 @@ catalog: unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 13.0.0 - vinext: https://pkg.pr.new/vinext@adbf24d + vinext: 0.0.41 vite: npm:@voidzero-dev/vite-plus-core@0.1.16 vite-plugin-inspect: 12.0.0-beta.1 vite-plus: 0.1.16