810 Commits
Author SHA1 Message Date
Aller05 8dc3aa557c feat: multi tab scrolling optimization 2024-04-18 05:25:04 +00:00
Aller05 316f5c1def feat: multi tab supports automatic scrolling, tab deletion logic optimization 2024-04-18 05:10:19 +00:00
Aller05 d80e791547 feat: delete workflow and update mulit tab at the same time 2024-04-18 02:42:09 +00:00
Aller05 b37e9b6449 feat: when dragging and loading the flow, update the tab 2024-04-18 01:50:10 +00:00
Aller05 87b82d6f2d feat: pr problem fix 2024-04-17 10:18:59 +00:00
Weixuan Fu ce56822a69 Merge branch 'dev' into multi-tab 2024-04-15 16:29:10 +08:00
Weixuan Fu 364eac4812 break resource deps form into separate component 2024-04-15 16:15:53 +08:00
yanle 1cf711e4fa Merge branch 'dev' into multi-tab 2024-04-15 01:03:31 +08:00
yanle b08c4e0079 feat: multi-tab completed 2024-04-15 01:03:09 +08:00
Weixuan Fu 8208cb2b39 uninstall framer-motion and uuid 2024-04-14 16:34:12 +08:00
Weixuan Fu - windows eed0aed508 fix last workflow item in list delete popover disappears immediately on mouse leave 2024-04-13 23:43:56 +08:00
Weixuan Fu 7477713f93 fix civit install models not showing up 2024-04-13 22:41:46 +08:00
Weixuan Fu f99e3d1e92 hide scrollbar to avoid more actions menu disappear 2024-04-12 22:50:03 +08:00
Aller05 b2052ff205 feta: multi tab style 2024-04-12 08:50:51 +00:00
yanle fb5fac0621 Merge branch 'dev' into multi-tab 2024-04-11 21:48:18 +08:00
Weixuan Fu a9462c61b3 fix download model url sometimes has extra ; symbol on filename 2024-04-09 21:08:25 +08:00
Weixuan Fu d8c7263456 remove copy all workflows to new dir dialog after change save dir 2024-04-08 16:07:36 +08:00
Weixuan Fu b38c8ffbf8 use async thread for dedup so it wont block main 2024-04-08 03:15:57 +08:00
Weixuan Fu bcede2fc71 compact list 2024-04-07 23:52:56 +08:00
Weixuan Fu 6dad3dbc8f cleanup code 2024-04-07 22:34:10 +08:00
Weixuan Fu 2eacd98c1d add dedup logic, remove add tag option 2024-04-07 21:03:02 +08:00
Weixuan Fu ca51caeed9 remove lastsaved json field. Remove delete button in save/discard dialog, cuz there is no way we can know if its been saved before 2024-04-07 20:49:08 +08:00
Weixuan Fu a93c292682 Merge pull request #303 from 11cafe/feat/dedupe-id
Add workflow ID deduplication logic
2024-04-07 20:46:53 +08:00
Weixuan Fu e6c502cd7a add try catch around save disk db to avoid error 2024-04-06 17:13:59 +08:00
Weixuan Fu 4d1226ffdc fixed shelve reboot error, make auto save false default 2024-04-04 23:55:00 +08:00
Weixuan Fu de795fdcf3 add edit download url button to download space json, fix shift+s block input 2024-04-01 21:18:10 +08:00
Weixuan Fu 04857ba913 fix slow render in recent files list, fix subfolder not respecting sortby 2024-03-31 22:42:04 +08:00
Weixuan Fu 237345bf90 return nodeType to mapping instead of set 2024-03-31 19:27:52 +08:00
Weixuan Fu bdfd0b8f2c add back gallery media action buttons to new gallery carousel view 2024-03-31 15:01:21 +08:00
Weixuan Fu 3e8f6614fa add inputName field into model deps 2024-03-30 00:53:33 +08:00
yanle 7b64353aba feat: multi tabs 30% complete 2024-03-29 22:17:33 +08:00
Weixuan Fu 3b73bda108 compact height topbar buttons 2024-03-29 18:40:47 +08:00
Weixuan Fu 74abe3b2bb fix changelogs table savedb 2024-03-28 23:56:05 +08:00
Weixuan Fu f3449814dc Merge pull request #323 from 11cafe/aller-spotlight-search
feat: listener the up/down/enter keys in the spotlight search component
2024-03-28 22:27:22 +08:00
Weixuan Fu e13ca7c116 fix carousel becoming smaller and disppears when more rows of gallery images 2024-03-28 22:24:07 +08:00
yanle f8f157db56 feat: listener the up/down/enter keys in the spotlight search component 2024-03-28 22:18:41 +08:00
Weixuan Fu c2fd17e856 fix shortcut listener block original keybiindings like arrow up/down, enter 2024-03-28 21:54:01 +08:00
Weixuan Fu decf1cb94d fix autosave even when save lock is on 2024-03-28 13:46:31 +08:00
Weixuan Fu eeb79e7d10 add hide thumbnail image option 2024-03-28 00:11:39 +08:00
Weixuan Fu dc04d495f5 fix create workflow may overwrite workflow_info.deps, fix save/discard dialog appear with deleted null cur workflow 2024-03-28 00:02:20 +08:00
Weixuan Fu bddf7ff452 rebase main, delete build 2024-03-27 22:38:25 +08:00
Weixuan Fu f55dd02e39 change default maxium changelog number 2024-03-27 22:37:27 +08:00
Weixuan Fu e95bb85c8b only show action buttons on hover to improve performance 2024-03-27 22:37:03 +08:00
Weixuan Fu e60f7c791e Merge remote-tracking branch 'origin/main' 2024-03-27 22:24:56 +08:00
Weixuan Fu 6b1aa99b3d build for autosave duration commit 2024-03-27 22:16:03 +08:00
Weixuan Fu 7e36e61036 autosave duration settings for better perf. Merge branch 'dev' into beta 2024-03-27 22:15:22 +08:00
Weixuan Fu a3073ded0b autosave duration setting, to autosave in a fixed duration 2024-03-27 22:13:07 +08:00
Weixuan Fu 0c429cfe26 Update README.md 2024-03-27 15:53:33 +08:00
Weixuan Fu cdf53e782f build new gallery, download .runner.json merge 2024-03-27 15:43:19 +08:00
Weixuan Fu 22f448e665 "model-lis 2024-03-27 15:42:33 +08:00
Weixuan Fu e1c647a53f Install missing models, download .runner.json, fix civitai images thumbnail not show up,
Install missing models, download .runner.json, fix civitai images thumbnail not show up,
2024-03-27 15:38:45 +08:00
Weixuan Fu 46f59a6cd5 breakdown workflow item right action buttons to separate component, cleanup log 2024-03-27 15:36:28 +08:00
Weixuan Fu 597fea74fb fix civitai search by hash return nsfw field type change 2024-03-27 15:17:45 +08:00
Weixuan Fu 019f5f5329 better shared version naming 2024-03-27 14:43:19 +08:00
Weixuan Fu - windows 681c25ae7c fix gallery right col header buttons squash together in edge browser 2024-03-27 01:09:06 +08:00
Weixuan Fu 840471a3e2 install missing models user flow 2024-03-26 22:41:12 +08:00
Weixuan Fu 94da7d87f1 install missing models from .runner.json 2024-03-26 00:25:06 +08:00
Weixuan Fu 3665089f5b Merge pull request #306 from 11cafe/spacejson
download .runner.json
2024-03-25 18:45:26 +08:00
Weixuan Fu c159b94f6e clenaup 2024-03-25 18:45:06 +08:00
Weixuan Fu e965b1d585 Update README.md 2024-03-25 00:40:13 +08:00
Weixuan Fu c5ec84991a spotlight search logic optimization
先合并了 之后再慢慢调整
2024-03-24 23:59:14 +08:00
Weixuan Fu 1fb766e2b9 fixed proper event listener for model list finished updating 2024-03-23 22:56:47 +08:00
Weixuan Fu d67c5b1c3e change back to use base filename without extension as models table id, update fetch refresh models list logic when filename not found 2024-03-23 22:53:57 +08:00
Weixuan Fu db2512e857 can save model deps link, can add custom node repos to deps 2024-03-23 21:30:21 +08:00
yanle fcb0406c30 Merge branch 'dev' into aller-spotlight-search 2024-03-23 21:06:32 +08:00
yanle e37ba06818 feat: spotlight search logic optimization 2024-03-23 21:06:17 +08:00
Weixuan Fu afe76076ee extract repo info 2024-03-23 20:51:05 +08:00
Weixuan Fu 8f342a8830 Merge pull request #302 from 11cafe/aller-spotlight-search
spotlight search adds buttons to select files and open them
2024-03-23 14:44:59 +08:00
yanle 2286e6c639 remove unless code 2024-03-23 14:25:42 +08:00
yanle ed7b69645d remove unless code 2024-03-23 13:55:53 +08:00
Weixuan Fu a8dd93d5b0 Merge pull request #308 from 11cafe/beta
build for civitai api change
2024-03-23 02:29:13 +08:00
Weixuan Fu 23adf59a2e fix build again 2024-03-23 02:28:34 +08:00
Weixuan Fu 7a17e93496 build civit api change 2024-03-23 02:23:06 +08:00
Weixuan Fu db636d2b0b Update README.md 2024-03-23 01:39:25 +08:00
Weixuan Fu b7037e80b8 Update README.md 2024-03-23 01:31:46 +08:00
Weixuan Fu e7bd738821 Update README.md 2024-03-23 01:30:33 +08:00
Weixuan Fu d280a4a350 fix fetching error from civitai 2024-03-23 01:21:29 +08:00
Weixuan Fu 352184f1e6 click gallery search result show metadata 2024-03-23 01:19:48 +08:00
Weixuan Fu a96707197d Update README.md 2024-03-23 01:05:46 +08:00
Weixuan Fu 4de07e0d58 Update README.md 2024-03-23 01:04:50 +08:00
yanle 5c074ec4a2 Merge branch 'dev' into aller-spotlight-search 2024-03-22 23:45:20 +08:00
yanle 36bd979bbe feat: 1. workflowsTable.listAll tow way sync; 2. shortcut trigger custom events; 2024-03-22 23:44:08 +08:00
Weixuan Fu 6a86ea9b74 Merge branch 'dev' into spacejson 2024-03-22 17:21:53 +08:00
Weixuan Fu 3df533dfd0 Merge pull request #305 from 11cafe/fix-gallery
cleanup code, display all images search res
2024-03-22 17:18:19 +08:00
Weixuan Fu 6675dc7761 auto hide all inputs if top fields are configed 2024-03-22 17:16:44 +08:00
Weixuan Fu 2fa2831fc2 cleanup code, display all images search res 2024-03-22 17:08:56 +08:00
Weixuan Fu 99f775c39d Merge pull request #304 from 11cafe/fix-gallery
improv gallery ui
2024-03-22 15:19:28 +08:00
Weixuan Fu 77c2d8ad45 polish ui 2024-03-22 15:17:32 +08:00
Weixuan Fu 9837e3650c gallery ui improv 2024-03-22 15:12:14 +08:00
Arslan Ablikim de29524dd8 Add workflow ID deduplication logic 2024-03-22 10:26:50 +08:00
Weixuan Fu 2b88926d7f better form item prop structure 2024-03-22 00:58:59 +08:00
yanle 06366981a6 style: optimize the style of dark theme 2024-03-22 00:22:46 +08:00
yanle ebd30d2d9f style: optimize the style of dark theme 2024-03-22 00:22:21 +08:00
yanle 2e5c56c007 feat: spotlight search adds buttons to select files and open them 2024-03-21 23:55:43 +08:00
Weixuan Fu 5fae575e4f better all prompt form 2024-03-21 23:50:18 +08:00
Weixuan Fu a519a6f8ea better strcuture cod 2024-03-21 19:27:47 +08:00
Weixuan Fu 88135755dc can print inputlist in execution order and child input name 2024-03-21 18:55:11 +08:00
Weixuan Fu 26ea5d4679 redo recursion 2024-03-21 00:49:44 +08:00
yanle bd793e9325 fix: clear spotlight search keyword bug fix 2024-03-20 22:07:20 +08:00
Weixuan Fu 4a3420b50d remove carousel animation, remove unnecessary states 2024-03-20 21:34:43 +08:00
Weixuan Fu 062fde9f2a Merge pull request #277 from 11cafe/gallery-modal
Gallery modal
2024-03-20 21:11:01 +08:00
Weixuan Fu e8e237ded3 Merge pull request #294 from 11cafe/aller-spotlight-search
added spotlight search
2024-03-20 19:56:02 +08:00
Weixuan Fu 0efca1f325 Merge branch 'dev' into aller-spotlight-search 2024-03-20 19:51:47 +08:00
Weixuan Fu b2a737f822 fix rebase 2024-03-20 19:39:47 +08:00
Weixuan Fu 5683b2f366 code styling move utils to separate folder 2024-03-20 19:25:01 +08:00
yanle 65755f40bf feat: added spotlight search 2024-03-20 19:25:01 +08:00
Weixuan Fu 6c4d1ce6cf Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 4c02df75fa fix some isdirty problem, like when clicking chakra menus and it will set canvas as dirty :( 2024-03-20 19:25:01 +08:00
Weixuan Fu fec47dcfb7 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu da67ea6af5 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 93002f84e9 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 5e6ccf6295 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu a05fd639e8 fix TypeError: stat: path should be string, bytes, os.PathLike or integer, not dict 2024-03-20 19:25:01 +08:00
Weixuan Fu 33094b14b0 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 141445378d Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu fe95dad65d fix bug that breaks the primitive node not showing widgets 2024-03-20 19:25:01 +08:00
Weixuan Fu bf04ca908a Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 08b9d23c2c fix server client different os and select save path not working 2024-03-20 19:25:01 +08:00
Weixuan Fu f068433bea Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu b54670c6ab Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu a8c0e4bbc1 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 563bde5394 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu c878610603 Update README.md 2024-03-20 19:25:01 +08:00
Weixuan Fu 67d847dbc3 can download .space.json ✌️ 2024-03-20 15:59:25 +08:00
Weixuan Fu e4c355b651 can upload array of file correctly 2024-03-20 02:35:40 +08:00
Weixuan Fu 9f03958056 can return back image upload url 2024-03-20 00:10:53 +08:00
Weixuan Fu 02b73cccb2 Merge remote-tracking branch 'origin/spacejson' into spacejson 2024-03-19 23:27:41 +08:00
Weixuan Fu f4d6d80baa upload to s3 finally working 2024-03-19 23:24:36 +08:00
Weixuan Fu c5e71b8ff6 model dependency check and form submit 2024-03-19 23:24:36 +08:00
Weixuan Fu 6cf4e52de8 polish ui of downloa space.json dialogu 2024-03-19 23:24:36 +08:00
Weixuan Fu 32b592832a indexdb.put models list even without filehash, always put models files list into indexdb 2024-03-19 23:24:36 +08:00
Weixuan Fu 125abb40b1 Update README.md 2024-03-19 23:23:22 +08:00
Weixuan Fu e755454265 fix some isdirty problem, like when clicking chakra menus and it will set canvas as dirty :( 2024-03-19 23:23:22 +08:00
Weixuan Fu a17dc3a6f6 Update README.md 2024-03-19 23:23:22 +08:00
Weixuan Fu 589a2580a7 Update README.md 2024-03-19 23:22:54 +08:00
Weixuan Fu 2843d87b00 Update README.md 2024-03-19 23:22:35 +08:00
Weixuan Fu bfbced5395 fix some isdirty problem, like when clicking chakra menus and it will set canvas as dirty :( 2024-03-19 23:15:57 +08:00
Weixuan Fu 00fd6b6ef3 Update README.md 2024-03-19 22:11:00 +08:00
Weixuan Fu 436bef1ab1 Update README.md 2024-03-19 16:45:04 +08:00
Weixuan Fu 6b68bf4664 fix TypeError: stat: path should be string, bytes, os.PathLike or integer, not dict 2024-03-19 16:45:04 +08:00
Weixuan Fu 2400fc95d6 Update README.md 2024-03-19 16:44:51 +08:00
Weixuan Fu 983563d197 Update README.md 2024-03-19 16:44:51 +08:00
Weixuan Fu 68bc275045 fix TypeError: stat: path should be string, bytes, os.PathLike or integer, not dict 2024-03-19 16:42:39 +08:00
Weixuan Fu 1a49411d68 fix bug that breaks the primitive node not showing widgets 2024-03-19 15:26:29 +08:00
Weixuan Fu e5a847b475 Update README.md 2024-03-19 15:26:29 +08:00
Weixuan Fu 042a104f8a fix server client different os and select save path not working 2024-03-19 15:26:29 +08:00
Weixuan Fu 7597cd8577 Update README.md 2024-03-19 15:26:29 +08:00
Weixuan Fu efd556c247 Update README.md 2024-03-19 15:26:29 +08:00
Weixuan Fu 9dcd7acfe1 Update README.md 2024-03-19 15:26:29 +08:00
Weixuan Fu 4be7c99895 Update README.md 2024-03-19 15:26:29 +08:00
Weixuan Fu 5ade47a225 Update README.md 2024-03-19 15:25:57 +08:00
Weixuan Fu 38a7cbd808 fix bug that breaks the primitive node not showing widgets 2024-03-19 15:24:41 +08:00
Weixuan Fu 4e716443cc upload to s3 finally working 2024-03-19 02:15:56 +08:00
Weixuan Fu a76eb3b67e Update README.md 2024-03-19 00:36:53 +08:00
yanle 134b2458e4 Merge branch 'dev' into aller-spotlight-search 2024-03-18 23:29:15 +08:00
yanle b58d920bbe feat: added spotlight search 2024-03-18 23:24:19 +08:00
Weixuan Fu 12dbec8d79 model dependency check and form submit 2024-03-18 22:16:19 +08:00
Weixuan Fu b4a6197f31 Merge pull request #291 from 11cafe/beta
fix server client different os and select save path not working
2024-03-18 18:28:29 +08:00
Weixuan Fu 13a85e9b75 fix server client different os and select save path not working 2024-03-18 18:26:59 +08:00
Weixuan Fu f4f156f045 fix server client different os and select save path not working 2024-03-18 18:25:26 +08:00
Weixuan Fu 82bf272c89 polish ui of downloa space.json dialogu 2024-03-18 17:53:48 +08:00
Weixuan Fu 54a4bc2fed do not shoow twoway reminder of already enabled
beta
2024-03-18 11:22:20 +08:00
Weixuan Fu 45e4e25629 Merge branch 'dev' into beta 2024-03-18 11:20:53 +08:00
Weixuan Fu 372f8f1639 do not show reminder if twoway already enabled 2024-03-18 11:20:09 +08:00
Weixuan Fu fd34e6cc43 indexdb.put models list even without filehash, always put models files list into indexdb 2024-03-18 01:15:25 +08:00
Weixuan Fu 40f00012f2 Merge pull request #287 from 11cafe/beta
fix onconfigure not setdirty
2024-03-17 18:51:54 +08:00
Weixuan Fu 02fb4f64bb build 2024-03-17 18:36:43 +08:00
Weixuan Fu 5a6e5f12ce Merge branch 'spacejson' into beta 2024-03-17 18:36:33 +08:00
Weixuan Fu 5a9800b8e6 hide space json 2024-03-17 18:36:10 +08:00
Weixuan Fu be3f1af59a fix setis dirty when onconfigure 2024-03-17 18:34:20 +08:00
ss707494 b9790c6c75 feat(gallery): delete getNodeTypeByPath 2024-03-17 16:59:42 +08:00
ss707494 bea6c316fa feat(gallery): delete getNodeTypeByPath 2024-03-17 16:59:27 +08:00
ss707494 76345c2173 feat(gallery): top field config save to database/add context 2024-03-17 11:24:53 +08:00
Weixuan Fu 4113edfa8e Merge pull request #286 from 11cafe/beta
autosave to changelog, set dirty when load/drag drop workflow to canvas
2024-03-17 00:34:54 +08:00
Weixuan Fu 39dc01ed2e build 2024-03-17 00:25:49 +08:00
Weixuan Fu a3d024803f Merge branch 'dev' into beta 2024-03-17 00:25:32 +08:00
Weixuan Fu d87d07396d able to download .space.json to include dependencies 2024-03-17 00:24:21 +08:00
Weixuan Fu 9da12ae6c4 set dirty when load/drag drop workflow to canvas 2024-03-17 00:22:58 +08:00
Weixuan Fu 08815c736f Update README.md 2024-03-16 19:03:02 +08:00
Weixuan Fu 9e033025de Update README.md 2024-03-16 19:02:33 +08:00
Weixuan Fu a20d5ccc56 autosave to changelog 2024-03-16 14:36:18 +08:00
Weixuan Fu 67da2b38ee Update README.md 2024-03-16 02:24:10 +08:00
Weixuan Fu cbd370bcd9 Merge pull request #282 from 11cafe/beta
move models button position to topbar
2024-03-16 01:54:10 +08:00
Weixuan Fu 07baf92f60 build move model button to topbar 2024-03-16 01:52:11 +08:00
Weixuan Fu f7a0cf7bec Merge branch 'dev' into beta 2024-03-16 01:51:45 +08:00
Weixuan Fu 48033894fb fix model drag drop not working after move model button 2024-03-16 01:51:11 +08:00
Weixuan Fu 4e0d3d155a move models button to topbar with folders list 2024-03-16 01:35:41 +08:00
Weixuan Fu 28344f8d34 Merge pull request #281 from 11cafe/beta
fix preview image block main thread and make list loading slow, fix portrait video thumbnail height not respecting size
2024-03-16 01:18:32 +08:00
Weixuan Fu 59494a352d build 2024-03-16 01:17:00 +08:00
Weixuan Fu 8f277846fa change autosave debounce to 1s 2024-03-16 01:16:34 +08:00
Weixuan Fu 9fbd92f1db change autosave debounce to 1s 2024-03-16 01:16:00 +08:00
Weixuan Fu 46f450e996 build image preview fix 2024-03-16 01:10:35 +08:00
Weixuan Fu 3fcdeba567 Merge branch 'dev' into beta 2024-03-16 01:10:03 +08:00
Weixuan Fu bc4c4bac34 fix list load slow with image preview blocking main thread 2024-03-16 01:09:19 +08:00
Weixuan Fu 0da4500e58 Merge pull request #280 from 11cafe/beta
debounce autosave workflow to avoid "invalid json error" thrown by server "file/save" endpoint
2024-03-15 23:43:37 +08:00
Weixuan Fu 862595edc1 add debounce autosave workflow 2024-03-15 23:41:46 +08:00
Weixuan Fu 3b056de641 add debounce workflow 2024-03-15 23:38:33 +08:00
Weixuan Fu 1b91e7bfd3 Merge pull request #279 from 11cafe/beta
fix auto save workflow always not latest version when click load
2024-03-15 23:24:10 +08:00
Weixuan Fu 093b57d05b build 2024-03-15 23:22:45 +08:00
Weixuan Fu fb58c9f765 fix auto save workflow not latest version when click load 2024-03-15 23:21:49 +08:00
gubei 928ba40abd Fixed issue where hidden connections were exposed 2024-03-15 22:36:09 +08:00
Weixuan Fu ee7fe33e72 Merge remote-tracking branch 'origin/main' 2024-03-15 19:33:48 +08:00
Weixuan Fu 8ea87fd6d9 remove updating mediaIDs on workflow 2024-03-15 19:26:42 +08:00
Weixuan Fu 71521672f7 fix copy or delete nodes not trigger auto save or is dirty 2024-03-15 19:17:04 +08:00
Weixuan Fu 0a45c5e7d1 fix drag drop, better gallery top form, git ignore dist on dev hook 2024-03-15 18:08:08 +08:00
gubei b2d737078c Refactor insertWorkflowToCanvas.
fixed 272
fixed 267
2024-03-15 17:41:56 +08:00
ss707494 99b4f8e832 feat(gallery): gallery-table/Query node information by the root node 2024-03-15 15:34:35 +08:00
ss707494 75a5d73412 feat(gallery): gallery-table/Query node information by the root node 2024-03-15 15:32:59 +08:00
ss707494 dc0574735e Merge branch 'dev' into gallery-modal 2024-03-15 10:19:23 +08:00
Weixuan Fu 2d8c3ff4a8 Update README.md 2024-03-15 01:14:36 +08:00
Weixuan Fu d88944f424 Update README.md 2024-03-15 00:05:15 +08:00
Weixuan Fu b9bcb9fe1e better message 2024-03-14 23:55:47 +08:00
Weixuan Fu 0769d232f4 delete all dist 2024-03-14 23:47:41 +08:00
Weixuan Fu 86232d04b6 Merge branch 'main' into dev 2024-03-14 23:46:22 +08:00
Weixuan Fu aa7d80c68f build 2024-03-14 23:37:34 +08:00
Weixuan Fu f98f09c2d1 Merge pull request #275 from 11cafe/remove-timer
Remove timer
2024-03-14 23:36:41 +08:00
Weixuan Fu c46a5b9446 hide discard unsaved optoin when autosave is enabled 2024-03-14 23:32:49 +08:00
Weixuan Fu 40dc33bca8 remove auto save timer, cleanup code 2024-03-14 23:28:11 +08:00
ss707494 155b282066 feat(gallery): gallery-table 2024-03-14 22:38:46 +08:00
Weixuan Fu 6dbbef7208 remove isdirty interval check 2024-03-14 21:51:40 +08:00
Weixuan Fu 5cdfadaab0 Update README.md 2024-03-14 18:54:26 +08:00
Weixuan Fu 5e8ec89662 temp remove missing models check 2024-03-14 17:04:28 +08:00
Weixuan Fu fa79c46aad delete dist folder 2024-03-14 16:24:11 +08:00
Weixuan Fu 7e2961126f fix promptkey nodeType not match 2024-03-14 16:23:24 +08:00
Weixuan Fu 509570fe20 fix gallery crash 2024-03-14 15:19:08 +08:00
Weixuan Fu 67a81cc4a1 Merge pull request #264 from longgui0318/dev
Workflow dirt check Supplement
2024-03-13 18:24:21 +08:00
Weixuan Fu 9523536f1e Merge pull request #258 from 11cafe/feat/gallery-search
feat(gallery): add search
2024-03-13 18:22:32 +08:00
Weixuan Fu 8a4d8625bb Merge pull request #256 from 11cafe/feat/upload-thumbnail
Feat/upload thumbnail
2024-03-13 18:12:50 +08:00
Weixuan Fu f13b122afe Merge pull request #271 from 11cafe/git-hooks
Git hooks
2024-03-13 18:10:15 +08:00
Weixuan Fu 2ea3d34a82 Update README.md 2024-03-13 16:01:39 +08:00
Weixuan Fu c9f35ae8b0 Update README.md 2024-03-13 14:26:56 +08:00
yanle 6168fb737c update developer readme 2024-03-12 23:42:36 +08:00
yanle 9420600db4 Add npm run setupGithooks 2024-03-12 23:30:48 +08:00
yanle c98408809c Add npm run setupGithooks 2024-03-12 23:24:36 +08:00
Weixuan Fu 90e85c2986 add try catch to model preview processing, to avoid one bad file reading crashing entire process 2024-03-12 16:11:05 +08:00
Weixuan Fu eea6dcd9c2 Update README_for_developer.md 2024-03-12 16:10:32 +08:00
Weixuan Fu c527550158 Update README_for_developer.md 2024-03-12 16:10:32 +08:00
Weixuan Fu dae81155d6 Update README_for_developer.md 2024-03-12 16:10:32 +08:00
Weixuan Fu c80c4836bb Update issue templates 2024-03-12 16:10:32 +08:00
Weixuan Fu 849a3805af Update issue templates 2024-03-12 16:10:32 +08:00
Weixuan Fu 08c2849a9b Create FUNDING.yml 2024-03-12 16:10:32 +08:00
Weixuan Fu b1006efa33 fix drag build 2024-03-12 16:10:32 +08:00
Weixuan Fu b389f3e052 fix drag drop not working with workflow with reroute nodes 2024-03-12 16:10:25 +08:00
Weixuan Fu 0f7339e78f create invisible temp canvas to copy clipboard from 2024-03-12 16:10:25 +08:00
Weixuan Fu dee8846a80 fix entry abs path load input, not working in cloud gpu 2024-03-12 16:10:25 +08:00
Weixuan Fu 1130030754 Update README.md 2024-03-12 16:10:25 +08:00
Weixuan Fu f10bcede6e build 2024-03-12 16:10:25 +08:00
Weixuan Fu 926b3fcd75 Update README.md 2024-03-12 16:10:14 +08:00
Weixuan Fu bdb2e1dc98 build dist 2024-03-12 16:10:14 +08:00
Weixuan Fu 3942b0c9f6 Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu cc93fcc1dd Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu 10c270c4fe Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu 9979001936 Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu 479d15a1c9 Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu 8d200f78ee Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu 15819c426d Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu 7751bf032e Update README.md 2024-03-12 16:10:06 +08:00
Weixuan Fu d3c3c60876 fix aliyun plugin not loading 2024-03-12 16:10:06 +08:00
Arslan Ablikim 195ffd9cd9 fix path 2024-03-12 15:51:14 +08:00
Weixuan Fu eefb71744c add try catch to model preview processing, to avoid one bad file reading crashing entire process 2024-03-12 15:02:05 +08:00
Weixuan Fu f0fcbcb0da Update README_for_developer.md 2024-03-12 12:46:15 +08:00
Weixuan Fu 79d7872ea0 Update README_for_developer.md 2024-03-12 12:45:36 +08:00
Weixuan Fu ee3002d26a Update README_for_developer.md 2024-03-12 12:37:25 +08:00
Weixuan Fu 7644d7a2dc Update issue templates 2024-03-12 12:10:55 +08:00
Weixuan Fu e456fa5e70 Update issue templates 2024-03-12 12:09:43 +08:00
ss707494 2f0a5795ea feat(gallery): change to prompt/ add error catch 2024-03-11 23:18:32 +08:00
ss707494 10dd69856e feat: upload-thumbnail/add latestImage 2024-03-11 23:13:12 +08:00
Weixuan Fu 5a2504e57b Create FUNDING.yml 2024-03-11 21:57:12 +08:00
ss707494 4e5d0a2faa Merge branch 'dev' into feat/upload-thumbnail
# Conflicts:
#	service/file_sync_service.py
2024-03-11 21:06:26 +08:00
Weixuan Fu 333143f19c fix drag build 2024-03-11 18:49:56 +08:00
Weixuan Fu 5f09bb4041 Merge pull request #265 from 11cafe/fix-drag2
fix drag drop not working for workflows with reroute nodes
2024-03-11 18:48:54 +08:00
Weixuan Fu 02800b95e8 fix drag drop not working with workflow with reroute nodes 2024-03-11 18:45:48 +08:00
Weixuan Fu d2d2f0c1f1 create invisible temp canvas to copy clipboard from 2024-03-11 18:41:00 +08:00
Weixuan Fu 1cdb120658 fix entry abs path load input, not working in cloud gpu 2024-03-11 15:26:47 +08:00
gubei cc2a351581 Add: Ignore Dirty operations caused by order position changes due to click actions.
Add: Ignore Dirty operations caused by position corrections due to click actions.
2024-03-11 15:16:43 +08:00
Weixuan Fu 43d7d3eb1b Update README.md 2024-03-11 01:26:08 +08:00
Weixuan Fu 9fc9168a8b Merge pull request #262 from 11cafe/beta
non dismissable twoway sync dialog
2024-03-11 00:59:09 +08:00
Weixuan Fu 0df4842d38 Update README.md 2024-03-11 00:33:45 +08:00
Weixuan Fu 4f76b7b5a8 build 2024-03-11 00:20:47 +08:00
Weixuan Fu d7cd1c4e25 non auto dismiss twoway sync dialog 2024-03-11 00:20:00 +08:00
Weixuan Fu c1cae9a9d2 non auto dismiss twoway sync dialog 2024-03-11 00:19:48 +08:00
Weixuan Fu 3272bc9108 fix merge conflicts 2024-03-11 00:00:18 +08:00
Weixuan Fu 1373c7364b Merge branch 'beta' 2024-03-10 23:55:50 +08:00
Weixuan Fu f551431618 build dist 2024-03-10 23:22:13 +08:00
Weixuan Fu 57d53dd263 Merge pull request #261 from 11cafe/dev model manager fix, gallery fits all workflow, workflow file lock
model manager fix, gallery fits all workflow, workflow file lock
2024-03-10 23:19:47 +08:00
Weixuan Fu 80067f544d Merge pull request #253 from 11cafe/feat/data-meta
Feat/data meta
2024-03-10 23:13:54 +08:00
Weixuan Fu 491407ff5b Merge pull request #260 from 11cafe/fix-bulkadd
fix bulk add throw error
2024-03-10 23:10:16 +08:00
Weixuan Fu 5d675e1c89 fix bulk add throw error 2024-03-10 23:06:39 +08:00
Weixuan Fu 81baee7a3e Merge pull request #257 from 11cafe/aller
Copy all files to the new directory when switching the save directory
2024-03-10 22:33:48 +08:00
Weixuan Fu 2664fb50aa Update README.md 2024-03-10 19:42:17 +08:00
ss707494 0f14daaefd feat(gallery): add search 2024-03-10 12:57:36 +08:00
yanle 6ab117bdf8 Merge branch 'dev' into aller 2024-03-10 12:53:02 +08:00
yanle 46f007812a feat: copy all files to the new directory when switching the save directory 2024-03-10 12:49:09 +08:00
ss707494 f8d66e94d4 feat: upload-thumbnail 2024-03-10 11:50:29 +08:00
ss707494 3bc055fda7 feat: upload-thumbnail 2024-03-09 23:22:49 +08:00
ss707494 a4a80f671b feat: upload-thumbnail 2024-03-09 23:20:39 +08:00
Weixuan Fu 4cf90ed351 Merge pull request #254 from 11cafe/fix/graph
Fix drag import issues in utils.tsx
2024-03-09 17:00:33 +08:00
Arslan Ablikim bebcaab69f Fix drag import issues in utils.tsx 2024-03-09 16:48:49 +08:00
ss707494 e0c5da3911 feat: meta all prompt expand 2024-03-09 09:32:40 +08:00
Weixuan Fu 72b0e6b241 Update README.md 2024-03-08 23:58:39 +08:00
ss707494 625ef96434 feat: meta all prompt expand 2024-03-08 23:46:35 +08:00
ss707494 9ceaeb2134 feat: meta all prompt expand 2024-03-08 23:43:59 +08:00
Weixuan Fu 6fd98b12db Update README.md 2024-03-08 18:51:39 +08:00
Weixuan Fu 1a65dd8c2c Merge pull request #251 from 11cafe/feat/data-meta
Feat/data meta
2024-03-08 15:50:33 +08:00
Weixuan Fu 3e6fd15059 fix rewrite all workflows resulting two machines data discrapency 2024-03-08 15:38:09 +08:00
Weixuan Fu b6fe30e997 Update README.md 2024-03-08 14:59:19 +08:00
Weixuan Fu fb66dbf464 Merge pull request #246 from longgui0318/dev
Workflow dirt check Adjustment.
2024-03-08 13:24:22 +08:00
ss707494 da74f715f6 feat: meta pin 2024-03-08 12:20:42 +08:00
ss707494 f70950647e feat: meta add check 2024-03-08 10:48:00 +08:00
ss707494 8db0946727 feat: meta basic form 2024-03-08 08:57:22 +08:00
ss707494 ab586f994f feat: meta basic form 2024-03-07 23:02:13 +08:00
Weixuan Fu b349028b0e fix double save toast 2024-03-07 18:33:36 +08:00
Weixuan Fu 2d2538661f Merge pull request #244 from 11cafe/feat/data-meta
feat: meta box
2024-03-07 17:56:46 +08:00
ss707494 9979a342ed feat: meta basic form 2024-03-07 17:24:49 +08:00
Weixuan Fu 204f533b68 Merge pull request #241 from 11cafe/aller-lock
workflow lock
2024-03-07 14:26:39 +08:00
Weixuan Fu d69aa597d7 Merge pull request #247 from 11cafe/feat/cancel-download
Add cancel to install model
2024-03-07 14:15:09 +08:00
Arslan Ablikim d670a6e90a Add cancel to install model 2024-03-07 12:21:00 +08:00
gubei 0750359675 Workflow dirt check Adjustment.
Ignore dirt check inaccuracy after clicking node
2024-03-07 11:23:59 +08:00
yanle b1699f89a9 Merge branch 'aller-lock' of https://github.com/11cafe/comfyui-workspace-manager into aller-lock 2024-03-06 22:56:40 +08:00
yanle 4ba4c2948b feat: workflow right-click copy function merged into moreActionMenu 2024-03-06 22:56:35 +08:00
yanle c1736ae23d Merge branch 'dev' into aller-lock 2024-03-06 22:45:40 +08:00
ss707494 93f045a0ba feat: meta box 2024-03-06 20:04:52 +08:00
Weixuan Fu e14287795f Update README.md 2024-03-06 18:50:50 +08:00
Weixuan Fu 3abcf556ad build 2024-03-06 16:41:46 +08:00
Weixuan Fu 0ab3eb59d9 Merge branch 'dev' into beta 2024-03-06 16:41:19 +08:00
Weixuan Fu 0458f7b16e cleanup coef 2024-03-06 16:40:11 +08:00
ss707494 9bad877047 feat: meta box 2024-03-06 16:14:50 +08:00
Weixuan Fu 1047cb1958 Update README.md 2024-03-06 16:14:33 +08:00
Weixuan Fu 1cd018b275 build dist 2024-03-06 16:02:34 +08:00
Weixuan Fu 28f85992ff Merge pull request #243 from 11cafe/dev
fix my_workflows path picker on windows
2024-03-06 16:01:39 +08:00
Weixuan Fu 04c8bece6b ui improv 2024-03-06 15:51:14 +08:00
Weixuan Fu e5ae7960a8 Merge pull request #242 from 11cafe/aller
fix: compatible with windows file path separators
2024-03-06 15:00:17 +08:00
Aller05 24b003c9ff fix: compatible with windows file path separators 2024-03-06 03:08:25 +00:00
yanle 730ddb9b6f feat: add tag popover position modification 2024-03-05 23:59:55 +08:00
yanle 957ec7577e feat: disable manual saving and automatic saving when flow is locked 2024-03-05 23:32:16 +08:00
yanle 03666bcb96 feat: disable manual saving and automatic saving when flow is locked 2024-03-05 23:25:38 +08:00
Weixuan Fu b9cdd81538 build dist 2024-03-05 22:32:13 +08:00
Weixuan Fu a6ee4b9144 remove json diff compare package 2024-03-05 21:38:35 +08:00
Weixuan Fu ac58fe7645 build dist 2024-03-05 21:36:12 +08:00
yanle 7c27d8f60a Merge branch 'dev' into aller-lock 2024-03-05 21:35:29 +08:00
Weixuan Fu 8286681636 Merge pull request #240 from 11cafe/dev
models list search, sort improvements
2024-03-05 21:26:16 +08:00
Weixuan Fu c6696c0736 Merge pull request #235 from 11cafe/feat/model-item-style
model drawer redone
2024-03-05 21:24:45 +08:00
Weixuan Fu 7f5ed27bb8 fix styling, weir render bug when searching rendering extra non relavant content 2024-03-05 21:05:39 +08:00
Arslan Ablikim 04f8ac11de use mdoel.put in ModelItem and add index to key in ModelsList 2024-03-05 20:50:05 +08:00
Weixuan Fu 13297d69e0 Update README.md 2024-03-05 16:07:42 +08:00
Weixuan Fu ae082b8ee5 Update README.md 2024-03-05 16:05:47 +08:00
Weixuan Fu 2fa649ec32 Merge pull request #238 from 11cafe/dev
fix put file in file explore can't import to workspace
2024-03-05 01:40:27 +08:00
Weixuan Fu a0c7ea43c6 fix put file in file explore can't import to workspace 2024-03-05 01:39:20 +08:00
Weixuan Fu 6ff5a10211 🦄🦄v2.0 upgrade- two way sync and more 2024-03-05 01:02:34 +08:00
Weixuan Fu 3c502b312d Merge pull request #234 from 11cafe/aller
add save as shortcut
2024-03-05 00:38:56 +08:00
Weixuan Fu e7dc0dbaf0 Merge pull request #237 from 11cafe/fix-twoway
show upgrade 2.0 dialog
2024-03-05 00:37:44 +08:00
Weixuan Fu 24373a109e fix bug 2024-03-05 00:35:58 +08:00
yanle bbd5aa35bf Merge branch 'dev' into aller-lock 2024-03-04 23:45:53 +08:00
yanle 9b27a471a7 feat: workflow lock ui 2024-03-04 23:45:00 +08:00
Aller05 c942839905 Merge branch 'dev' into aller 2024-03-04 23:41:45 +08:00
Weixuan Fu 23dcafa973 Merge pull request #236 from 11cafe/fix-twoway
Fix twoway sync to use rel path to be compatible with windows
2024-03-04 23:33:56 +08:00
Weixuan Fu 1b11dca467 cleanup code 2024-03-04 23:25:10 +08:00
Weixuan Fu da628cd565 fixed folder rename move create 2024-03-04 23:16:27 +08:00
yanle b891cbf0a1 Merge branch 'aller' of https://github.com/11cafe/comfyui-workspace-manager into aller 2024-03-04 22:43:07 +08:00
yanle cacc6beb05 feat: The save as modal box displays using route control 2024-03-04 22:40:02 +08:00
Weixuan Fu - windows 62a965f8b7 fix get myworkflows endpoint, settings myworkflows dir fetch from server instead of from indexdb 2024-03-04 22:28:12 +08:00
Weixuan Fu - windows 556780de24 fix title not working, fix open workflow not working when my_workflows url is broken 2024-03-04 21:14:49 +08:00
Weixuan Fu 2612e4df9f fix save file, create file 2024-03-04 20:18:56 +08:00
Weixuan Fu 0b8f9982fc fix two way sync to window 2024-03-04 20:05:05 +08:00
Aller05 45bcd222f5 workflow lock temporary storage 2024-03-04 10:44:38 +00:00
Arslan Ablikim 28e6e01b38 get model name from db to use in search 2024-03-04 18:40:29 +08:00
Arslan Ablikim 0264c2259d add sortby function and fuzzy search 2024-03-04 18:11:01 +08:00
Arslan Ablikim 66c73e3108 Refactor ModelItem component and add modelName property to Model interface 2024-03-04 16:09:55 +08:00
Aller05 1ac36860d8 clean up useless code 2024-03-04 01:27:13 +00:00
Weixuan Fu 77d90eb047 fix aliyun plugin not loading 2024-03-04 01:49:41 +08:00
yanle 228d884a1f Merge branch 'dev' into aller 2024-03-03 19:59:02 +08:00
yanle b02b792e8c fix: delay updating topbar status 2024-03-03 19:56:45 +08:00
yanle 88ec20d789 feat: add save as shortcut 2024-03-03 19:44:21 +08:00
yanle f7b5813cad feat: add save as shortcut 2024-03-03 19:40:19 +08:00
Weixuan Fu 84f264c433 when importing use existing workflowID in workspace_info if its not occupied already 2024-03-03 15:26:48 +08:00
Weixuan Fu 4597e4610a ui list tweak 2024-03-03 01:15:26 +08:00
Weixuan Fu a9ddeaee43 Merge pull request #230 from 11cafe/recursive-scan
version name dropdown in topbar
2024-03-03 00:58:30 +08:00
Weixuan Fu f65738cc2e fix ui 2024-03-02 23:18:46 +08:00
Weixuan Fu c46ed18f2d open in comfyui with versions 2024-03-02 23:09:23 +08:00
Weixuan Fu e8199b8228 ui fixes 2024-03-02 22:21:18 +08:00
Weixuan Fu 91ab7423b7 switch versions 2024-03-02 20:44:27 +08:00
Weixuan Fu 936f5355f7 add version name dropdown in topbar, move topbar pos drag logic from App to Topbar 2024-03-02 19:29:59 +08:00
Weixuan Fu 36e2fd91eb Merge pull request #228 from 11cafe/recursive-scan
Recursive scan, fix my workflows default dir
2024-03-02 00:19:31 +08:00
Weixuan Fu 666913db3a fix default my_workflows dir, add recursive scan for folder 2024-03-02 00:14:29 +08:00
Weixuan Fu 5256127069 add recursive scan method to substitute scan 2024-03-01 23:04:22 +08:00
Weixuan Fu 3ae8ce92b5 fix custom url download not working 2024-03-01 22:03:54 +08:00
Weixuan Fu 8dff03932a fix some styling 2024-03-01 21:32:08 +08:00
Weixuan Fu fb97479954 📣merge and build v2-twoway sync, share dialog 2024-03-01 20:39:18 +08:00
Weixuan Fu f10ff88fde display shared topbar button 2024-03-01 20:38:27 +08:00
Weixuan Fu 34ece000cb cleanup logs 2024-03-01 20:36:52 +08:00
Weixuan Fu 917966129b display shared topbar button 2024-03-01 20:32:19 +08:00
Weixuan Fu 7cb0ddc38e Merge pull request #225 from 11cafe/v2
V2
2024-03-01 20:05:48 +08:00
Weixuan Fu 03ac2e2b4e use clipboard for insert subworkflow 2024-03-01 20:03:26 +08:00
Weixuan Fu 7d94708dad fixed cloud verion cloudid 2024-03-01 19:32:21 +08:00
Arslan Ablikim 7f8550645b Fix default folders initialization 2024-02-29 23:01:48 +08:00
Weixuan Fu 71349f475f Merge pull request #223 from 11cafe/v2
V2
2024-02-29 21:00:10 +08:00
Weixuan Fu 4c2c727af9 cleanup logs 2024-02-29 20:59:21 +08:00
Weixuan Fu 94b00ccc05 folder containing curworkflow auto expand, fix workflow name strip only the end .json extension 2024-02-29 20:39:55 +08:00
Weixuan Fu 3c8fdbb8a2 add manual save toast for autosave, fix settings bugs, introduce records limit for changelog 2024-02-29 20:04:06 +08:00
Weixuan Fu 633e48b07f fix everywhere drag drop not working 2024-02-28 21:47:21 +08:00
Weixuan Fu ce8c23f978 Update README.md 2024-02-28 20:57:29 +08:00
Weixuan Fu 32f7812e3d Merge pull request #219 from 11cafe/twoway-no-indexdb
Twoway no indexdb
2024-02-28 20:29:42 +08:00
Weixuan Fu 84a1563162 fixed drag drop/load flows with id bug 2024-02-28 20:18:41 +08:00
Weixuan Fu fd58f6d903 fix save and isDirty check to use workflowsTable.curWorkflow 2024-02-28 18:13:35 +08:00
Weixuan Fu 59f900dd08 add two way sync confirmation dialog 2024-02-28 00:50:33 +08:00
Weixuan Fu 3a0f48d8a8 fix timestamp not showing and not preserved 2024-02-28 00:03:38 +08:00
Weixuan Fu 6051add542 Merge pull request #220 from 11cafe/feat/download-path
Refactor download paths and add default download folders
2024-02-27 21:37:22 +08:00
Weixuan Fu dcc6599d12 fix download zip has no folder structure 2024-02-27 20:59:18 +08:00
Arslan Ablikim 57e51bf5b1 Refactor download paths and add default download folders 2024-02-27 19:41:39 +08:00
Weixuan Fu e4c89fef74 isdirty check from indexdb 2024-02-27 14:18:48 +08:00
Weixuan Fu 9ef6a9c99f fixed import folder, fixed edit folder name unique name check 2024-02-27 13:59:40 +08:00
Weixuan Fu 642760f9e0 fixed rename workflow but 2024-02-27 13:09:19 +08:00
Weixuan Fu b0639efa54 fixed save workflow 2024-02-27 12:22:49 +08:00
Weixuan Fu d0ae998f11 splitted updateFlow into updateMetaInfo updateName updateFolder for better mainainability 2024-02-27 12:12:31 +08:00
Weixuan Fu 4fbf25c403 code cleanup 2024-02-27 02:22:39 +08:00
Weixuan Fu 9e7c28c47c fix nested folder deletion bug 2024-02-27 01:38:19 +08:00
Weixuan Fu 17805e3e82 fix edit name of workflow blocked by duplicate name 2024-02-27 00:23:38 +08:00
Weixuan Fu 0c652de424 fix rename 2024-02-26 23:58:48 +08:00
Weixuan Fu a5662f9ea3 fix move folder/file bug 2024-02-26 21:30:55 +08:00
Weixuan Fu 6663acca54 workflow rename, move, delete works 2024-02-26 00:50:11 +08:00
Weixuan Fu c750a7dc18 fix renme error thrown 2024-02-25 23:21:07 +08:00
Weixuan Fu 79a1fa127f update file workf 2024-02-25 22:13:22 +08:00
Weixuan Fu fe5cf26134 better code 2024-02-25 21:48:44 +08:00
Weixuan Fu b926dd956f fix create flow err, updated name not updated back in indexdb 2024-02-25 21:15:02 +08:00
Weixuan Fu c2f610c85e create new workflow workingggit add . 2024-02-25 20:41:48 +08:00
Weixuan Fu 7b243df47d add path sanitizer for abs and rel path 2024-02-25 18:32:31 +08:00
Weixuan Fu 8d0fa86062 better code structure 2024-02-25 16:15:00 +08:00
Weixuan Fu 1a2300e914 get workflow twoway sync worksgit add . 2024-02-25 15:31:22 +08:00
Weixuan Fu 3596d54bbd basic files list works, no indexdb updates 2024-02-25 01:14:49 +08:00
Weixuan Fu e1eaa51e99 fix upate_file() FileExistsError, caused by indexdb folder table is having xyz.json (file name) as folder name 2024-02-24 22:27:23 +08:00
Weixuan Fu 52a47b1a0e two way sync and sync to indexdb 2024-02-24 16:26:33 +08:00
Weixuan Fu f4e1e79abd put 2024-02-24 15:06:16 +08:00
Weixuan Fu 7925ddf748 if else version 2024-02-24 15:06:16 +08:00
Weixuan Fu c1465bddfe indexdb put all scan workflows 2024-02-24 15:06:16 +08:00
Weixuan Fu a298eeaf2d basic two way sync working 2024-02-24 15:05:45 +08:00
Weixuan Fu a7f6474f7f add initial two way sync 2024-02-24 15:05:45 +08:00
Weixuan Fu 3134d31777 Merge pull request #217 from 11cafe/model-improv
Model improv to dev
2024-02-24 15:03:32 +08:00
Weixuan Fu 51084bb038 Merge pull request #216 from 11cafe/beta
model installer improvements and bug fixing
2024-02-24 00:56:08 +08:00
Weixuan Fu 4d9cc0b893 dist 2024-02-24 00:55:03 +08:00
Weixuan Fu 0b2762a757 Merge pull request #215 from 11cafe/model-improv
model installer improve: use mili search instead of api to increase loading speed, fix installing toast bug, fix install custom url bug
2024-02-24 00:53:00 +08:00
Weixuan Fu 2f13f6cf5e fix install custom url error cuz file_hash not exist in json_data 2024-02-24 00:50:32 +08:00
Weixuan Fu cfc8230fe9 model installer improv 2024-02-24 00:32:36 +08:00
Weixuan Fu 649b86a584 Create LICENSE 2024-02-23 23:30:14 +08:00
Weixuan Fu 71b70b54f5 Merge pull request #214 from 11cafe/beta
fix drop image to load image not creates new workflow
2024-02-23 23:24:12 +08:00
Weixuan Fu f17b2e6fbe fix drop image to load image not creates new workflow 2024-02-23 23:22:01 +08:00
Weixuan Fu 570272b702 Update README.md 2024-02-23 01:12:33 +08:00
Weixuan Fu 2d7b3d04a1 Merge pull request #209 from 11cafe/cloud-event
Cloud event
2024-02-22 17:09:59 +08:00
Weixuan Fu 0fbc224d9f keep indexdb version2 to avoid bug when the user for some reason revert back to earlier codebase version hence pulling earlier indexdb schema version 2024-02-22 17:09:04 +08:00
Weixuan Fu 13e01eb7e2 opens a workflow version from cloud 2024-02-22 17:06:21 +08:00
Weixuan Fu 07467c6cbd Merge branch 'dev' into cloud-event 2024-02-22 15:35:40 +08:00
Weixuan Fu ed1de8a522 Merge branch 'beta' into dev 2024-02-22 14:49:53 +08:00
Weixuan Fu f0e734c538 Merge pull request #206 from 11cafe/beta
share dialog, use cv model meilli search, drag and drop overwrite setting, fix save as operation
2024-02-22 14:43:11 +08:00
Weixuan Fu 9f11223a92 fix media size preview stripped metadata info and break image prompt inspector 2024-02-22 14:39:01 +08:00
Weixuan Fu 1809b10096 hide share buttons for release 2024-02-22 13:37:07 +08:00
Weixuan Fu a92240c5c7 add server event listen scrip 2024-02-22 13:13:44 +08:00
Weixuan Fu a140535f27 support 'open workflow in local comfyui' from cloud site 2024-02-21 22:02:31 +08:00
Weixuan Fu 282f50171d Merge remote-tracking branch 'origin/dev' into dev 2024-02-21 22:01:43 +08:00
Weixuan Fu 2be9e78ddc npm run build 2024-02-21 13:00:56 +08:00
Weixuan Fu ac3833738e update unlisted option label 2024-02-21 13:00:56 +08:00
Weixuan Fu 6f3bc4f278 fix save as bug 2024-02-21 13:00:56 +08:00
Weixuan Fu 8ef0c15349 fetch privacy label 2024-02-21 13:00:56 +08:00
Arslan Ablikim 57019e26f6 Add image thumbnail support for API view_media 2024-02-21 13:00:56 +08:00
yanle f6b73d7e4f feat: modify key 2024-02-21 13:00:56 +08:00
yanle aa09c2319d feat: add a setting to "overwrite current workflow when drag and drop workflow file to canvas" 2024-02-21 13:00:56 +08:00
Weixuan Fu 2fcf6df0e6 use hovermenu component in dropdown title 2024-02-21 13:00:56 +08:00
Weixuan Fu 53e1b1eb8b fix sharedialog radio picker 2024-02-21 13:00:56 +08:00
Weixuan Fu bb178fdc1f fix redirect to url 2024-02-21 13:00:56 +08:00
Weixuan Fu 8886b32412 share versions ui 2024-02-21 13:00:56 +08:00
Weixuan Fu 5ec8a6d402 radio button select versions 2024-02-21 13:00:56 +08:00
Weixuan Fu 94cc97ee0b pass privacy settings to share api 2024-02-21 13:00:56 +08:00
Arslan Ablikim effb37357d Refactor model_list.py to use is_populating flag instead of populate_started and populate_done flags 2024-02-21 13:00:56 +08:00
Weixuan Fu ef42432418 move model list hash compute to after clicked model list button 2024-02-21 13:00:56 +08:00
Arslan Ablikim 28629bd8a2 Fix image URL assignment in ModelItem component 2024-02-21 13:00:56 +08:00
Arslan Ablikim fd44d6376e Add local preview file functionality 2024-02-21 13:00:56 +08:00
Weixuan Fu 13253ccfb5 create different versions in same workflow 2024-02-21 13:00:56 +08:00
Weixuan Fu 06b338faba share cloud version success can send back data 2024-02-21 13:00:56 +08:00
Weixuan Fu b28b8e88f7 switch to postMessage() to post json payload and skip create record in TempData table 2024-02-21 13:00:56 +08:00
Weixuan Fu 135ec97f34 share confirm flow 2024-02-21 13:00:56 +08:00
Weixuan Fu 3cd1df5e9a share to lcoud 2024-02-21 13:00:56 +08:00
Arslan Ablikim a3f457b7d1 fix quotes 2024-02-21 13:00:56 +08:00
Arslan Ablikim 9bc832b274 add default 2024-02-21 13:00:56 +08:00
Arslan Ablikim 866fa0c11b added NSFW switch for install model 2024-02-21 13:00:56 +08:00
Arslan Ablikim 96e5f36c4e Refactor find image logic 2024-02-21 13:00:56 +08:00
Weixuan Fu 42c55c660b use new meili search in install missing models drawer 2024-02-21 13:00:56 +08:00
Arslan Ablikim 7fdaa1d4b7 Refactor ModelCard component and add getFileEssential function 2024-02-21 13:00:56 +08:00
Arslan Ablikim 412886227b Add filename to download tasks and progress list 2024-02-21 13:00:56 +08:00
Arslan Ablikim 7d74d0b4bf Refactor model installation code to support both APIs 2024-02-21 13:00:56 +08:00
Arslan Ablikim 86a260a69d Update nsfw property in Image interface and fix fileSelected prop in ChooseFolder component 2024-02-21 13:00:56 +08:00
Arslan Ablikim 018beb589d Switch CivitAI search API 2024-02-21 13:00:56 +08:00
Weixuan Fu bdc6fc8cb4 update unlisted option label 2024-02-21 12:56:15 +08:00
Weixuan Fu 6d62691db4 Merge pull request #205 from 11cafe/weixuan
fetch privacy label for cloud flow, fix save as bug
2024-02-21 12:30:08 +08:00
Weixuan Fu b6ffc34b25 Merge pull request #201 from 11cafe/feat/view_media-use-thumbnail
Add image thumbnail support for API view_media
2024-02-21 12:28:45 +08:00
Weixuan Fu de1ca75747 fix save as bug 2024-02-21 01:21:20 +08:00
Weixuan Fu c3fb501da8 fetch privacy label 2024-02-21 00:49:10 +08:00
Weixuan Fu 04f9446b5f Merge pull request #197 from 11cafe/aller
feat: add a setting to "overwrite current workflow when drag and drop…
2024-02-20 13:47:39 +08:00
Weixuan Fu 844fd89b18 Merge pull request #203 from 11cafe/share-cloud
use hovermenu component in dropdown title
2024-02-20 00:38:38 +08:00
Weixuan Fu 71e6c00e01 use hovermenu component in dropdown title 2024-02-20 00:37:43 +08:00
Weixuan Fu 64b708bb8d Merge pull request #202 from 11cafe/share-cloud
Share cloud
2024-02-20 00:37:07 +08:00
Weixuan Fu 96473555b7 fix sharedialog radio picker 2024-02-20 00:27:37 +08:00
Weixuan Fu ee00b34f9b fix redirect to url 2024-02-20 00:27:37 +08:00
Weixuan Fu 886e83c8b4 share versions ui 2024-02-20 00:27:37 +08:00
Weixuan Fu 16a53b7064 radio button select versions 2024-02-20 00:27:37 +08:00
Weixuan Fu d73d0f0d2f pass privacy settings to share api 2024-02-20 00:27:37 +08:00
Arslan Ablikim a862cbb612 Add image thumbnail support for API view_media 2024-02-19 21:36:14 +08:00
Arslan Ablikim 9bcf595271 Refactor model_list.py to use is_populating flag instead of populate_started and populate_done flags 2024-02-19 21:23:01 +08:00
yanle 6e32723f5e feat: modify key 2024-02-19 20:24:25 +08:00
Weixuan Fu cc14009440 move model list hash compute to after clicked model list button 2024-02-19 20:01:45 +08:00
Weixuan Fu cd7c831e4a Merge pull request #196 from 11cafe/feat/local-preview
Add local preview file functionality
2024-02-19 19:31:42 +08:00
Arslan Ablikim db73a34be6 Fix image URL assignment in ModelItem component 2024-02-19 09:01:32 +08:00
yanle 77293573cd feat: add a setting to "overwrite current workflow when drag and drop workflow file to canvas" 2024-02-18 18:05:48 +08:00
Arslan Ablikim c69fa3f1bb Add local preview file functionality 2024-02-17 19:17:00 +08:00
Weixuan Fu 5d438e610f Merge pull request #191 from 11cafe/share-cloud
share to lcoud
2024-02-16 15:29:46 +08:00
Weixuan Fu bd6f1811ef create different versions in same workflow 2024-02-16 15:27:37 +08:00
Weixuan Fu 6fa99e5055 share cloud version success can send back data 2024-02-16 14:30:12 +08:00
Weixuan Fu 9733506605 switch to postMessage() to post json payload and skip create record in TempData table 2024-02-16 14:30:12 +08:00
Weixuan Fu ef8b2a3383 share confirm flow 2024-02-16 14:30:12 +08:00
Weixuan Fu a2071cbe47 share to lcoud 2024-02-16 14:30:12 +08:00
Weixuan Fu c87f9e2ead Merge pull request #190 from 11cafe/feat/switch-civitai-search-api
added NSFW switch for install model
2024-02-16 14:22:40 +08:00
Weixuan Fu 72b6de53eb Update README.md 2024-02-11 09:06:35 +08:00
Weixuan Fu d6d2b76183 do not use two way sync 2024-02-11 08:36:41 +08:00
Weixuan Fu a78d6785b9 Update README.md 2024-02-11 08:36:41 +08:00
Weixuan Fu 39677c8405 build dist 2024-02-11 08:36:41 +08:00
Weixuan Fu 4d1d834b8a Update README.md 2024-02-11 08:36:41 +08:00
Weixuan Fu 07709b3b4f do not use two way sync 2024-02-11 08:35:43 +08:00
Weixuan Fu dd4e61daed Update README.md 2024-02-09 03:15:18 +08:00
Arslan Ablikim 54165bbd77 fix quotes 2024-02-08 23:57:02 +08:00
Arslan Ablikim 414b358463 add default 2024-02-08 23:54:51 +08:00
Arslan Ablikim a9afeb9bbe added NSFW switch for install model 2024-02-08 23:53:21 +08:00
Weixuan Fu f75703567b Merge pull request #186 from 11cafe/feat/switch-civitai-search-api
Switch CivitAI search API
2024-02-08 23:44:35 +08:00
Arslan Ablikim 572ca51316 Refactor find image logic 2024-02-08 21:55:44 +08:00
Weixuan Fu 0d2062997c use new meili search in install missing models drawer 2024-02-08 16:01:31 +08:00
Weixuan Fu bc388f4e12 Merge pull request #188 from 11cafe/beta
fix red discard button bug, when switching workflow isDirty, click red discard button in prompt then only name changed but workflow not change
2024-02-07 21:45:01 +08:00
Weixuan Fu 1424326de5 build dist 2024-02-07 21:39:38 +08:00
Weixuan Fu afc6cef622 Merge pull request #164 from 11cafe/hide-discard
fix discard change not clearing is dirty state
2024-02-07 21:37:08 +08:00
Weixuan Fu 6f842a3de7 add delete confirm prompt 2024-02-07 21:35:08 +08:00
Weixuan Fu d3462cd040 fix async 2024-02-07 21:30:00 +08:00
Weixuan Fu d7a48c5228 fix red discard bug when switching flow, only name changed but flow not changed 2024-02-07 21:14:18 +08:00
Weixuan Fu 964f38d36d Merge branch 'dev' into hide-discard 2024-02-07 20:59:44 +08:00
Weixuan Fu 2c865681c6 fix model drawer header padding 2024-02-07 00:55:20 +08:00
Weixuan Fu 3bc2aaeaa1 Update README.md 2024-02-07 00:48:32 +08:00
Arslan Ablikim 5f5d4d1378 Refactor ModelCard component and add getFileEssential function 2024-02-06 20:44:57 +08:00
Arslan Ablikim d0ed4e4df0 Add filename to download tasks and progress list 2024-02-06 20:09:25 +08:00
Arslan Ablikim 6ca34f4055 Refactor model installation code to support both APIs 2024-02-06 19:37:27 +08:00
Arslan Ablikim 4598d5b2ca Update nsfw property in Image interface and fix fileSelected prop in ChooseFolder component 2024-02-06 18:27:11 +08:00
Arslan Ablikim bb1d58b5a6 Switch CivitAI search API 2024-02-06 15:38:32 +08:00
Weixuan Fu 03472b3502 Merge pull request #185 from 11cafe/beta
folder import, folder always at top setting, show nsfw setting, create version dialog, drag drop folder to folder
2024-02-06 11:52:43 +08:00
Weixuan Fu 9ee70bf5f2 bring back twoway sync setting 2024-02-06 11:45:22 +08:00
Weixuan Fu 8f678f24f9 resolve merge conflict readme 2024-02-06 11:42:06 +08:00
Weixuan Fu a6cba06617 build dist 2024-02-06 11:40:43 +08:00
Weixuan Fu a9ddeb2ef2 Merge pull request #184 create version dialog, show nsfw setting #184
Dev
2024-02-06 11:39:25 +08:00
Weixuan Fu 8358fe5072 hide share button 2024-02-06 11:36:48 +08:00
Weixuan Fu cb665c5f51 Update README.md 2024-02-06 01:35:15 +08:00
Weixuan Fu 7af06bdfd1 ui improvements, switch between versions and show version active status 2024-02-05 23:06:39 +08:00
Weixuan Fu 68efbf3c93 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 0462db86be Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 0137057f59 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 479dde5e2b Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu ef3ce487ea Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 155ca19e26 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 624586aa96 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 852f8cf3a2 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 6e8b00a6ab Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 63de317493 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 5ad413dcbb Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 3ae308794b Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 956cb86684 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 1a77f3ac09 Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu bde420d1ff Update README.md 2024-02-05 21:42:01 +08:00
Weixuan Fu 476bf8315a naming change 2024-02-05 21:42:01 +08:00
Weixuan Fu 1ceddcbaca Merge pull request #182 from 11cafe/aller
fix toast z-index
2024-02-05 21:39:54 +08:00
yanle 17e6276ae3 Merge branch 'dev' into aller 2024-02-05 21:35:08 +08:00
yanle f84488161e fix: fix toast z-index 2024-02-05 21:33:42 +08:00
Weixuan Fu 9b74f23950 Merge pull request #179 from 11cafe/aller
Add version manage
2024-02-05 21:29:36 +08:00
Weixuan Fu 3fb8c83606 small git tweaks 2024-02-05 21:18:52 +08:00
Weixuan Fu 4e26211265 ui imporvements 2024-02-05 20:53:02 +08:00
Weixuan Fu 55daca626a Merge pull request #181 from 11cafe/feat/user-reported-issues
some ui fix for show nsfw and model list search
2024-02-05 20:32:08 +08:00
Weixuan Fu ef88681ed2 some ui fix 2024-02-05 20:27:34 +08:00
Weixuan Fu 1c9d9719ec Merge pull request #178 from 11cafe/feat/user-reported-issues
Fix various bugs and add new features
2024-02-05 20:02:16 +08:00
yanle 1482f24428 feat: add key for changelog map 2024-02-05 09:18:11 +08:00
yanle 4ec9e8c908 feat: add version delete 2024-02-05 08:59:20 +08:00
yanle ce685c1632 feat: improve version management related functions 2024-02-04 23:49:46 +08:00
Arslan Ablikim 1fc27200b8 fix checker state 2024-02-04 20:14:33 +08:00
Arslan Ablikim 23265c6a99 Add thumbnail fetching and update showNsfwModelThumbnail 2024-02-04 19:56:18 +08:00
Arslan Ablikim 4347c3aa7c fix: review issue 2024-02-04 19:38:31 +08:00
Arslan Ablikim 3898ebd5aa Fix various bugs and add new features 2024-02-04 18:28:55 +08:00
Weixuan Fu c3e4f87a3e Update README.md 2024-02-04 17:27:21 +08:00
Weixuan Fu a24326c960 Update README.md 2024-02-04 17:22:06 +08:00
Weixuan Fu d109acd64a Update README.md 2024-02-04 17:21:52 +08:00
Weixuan Fu 31d9c0db2d Update README.md 2024-02-04 04:42:09 +08:00
Weixuan Fu 7f85c95b69 Update README.md 2024-02-04 03:21:41 +08:00
Weixuan Fu cbf392ba03 Update README.md 2024-02-04 03:19:08 +08:00
Weixuan Fu 3457ec8689 Update README.md 2024-02-04 03:17:18 +08:00
Weixuan Fu 391f92221d build dist 2024-02-04 02:23:03 +08:00
Weixuan Fu 7e7eb073fd dist files 2024-02-04 02:21:38 +08:00
Weixuan Fu fc16343456 add dist files 2024-02-04 02:21:38 +08:00
Weixuan Fu 8c97a81177 Merge pull request #177 from 11cafe/custom-dropdown
Custom dropdown
2024-02-04 02:16:29 +08:00
Weixuan Fu d930b84f40 cleanup 2024-02-04 02:11:11 +08:00
Weixuan Fu 55fd98cfcb add custom menu with no animation 2024-02-04 02:09:35 +08:00
Weixuan Fu d85823a4e9 dropdown title ui fix 2024-02-04 02:09:35 +08:00
Weixuan Fu 742b6834f7 Update README.md 2024-02-02 19:09:44 +08:00
Weixuan Fu 0e1b9266ec Update README.md 2024-02-02 19:04:03 +08:00
Weixuan Fu a8e2d1bb42 Update README.md 2024-02-02 18:06:42 +08:00
Weixuan Fu 63018bae84 Update README.md 2024-02-02 18:04:22 +08:00
Weixuan Fu a227763937 Update README.md 2024-02-02 18:01:45 +08:00
Weixuan Fu c895dc3a43 Update README.md 2024-02-02 17:54:52 +08:00
Weixuan Fu d2120d8b18 Update README.md 2024-02-02 17:53:31 +08:00
Weixuan Fu b777ab96ac Merge pull request #174 from 11cafe/hide-broken-cover
hide broken cover
2024-02-02 17:34:43 +08:00
Weixuan Fu 0a3f0bf9b8 hide broken cover 2024-02-02 17:31:22 +08:00
Weixuan Fu dd3015f153 Merge pull request #169 from 11cafe/share
Share
2024-02-02 16:54:06 +08:00
Weixuan Fu 55e051da0d move png metadata extract into separate util file 2024-02-02 16:48:37 +08:00
Weixuan Fu 2f73ff304a revert loaddbs change 2024-02-02 16:21:49 +08:00
Weixuan Fu def5ada66d share button 2024-02-02 14:39:59 +08:00
Weixuan Fu ed4cdf68f9 share privacy selector 2024-02-02 14:39:59 +08:00
Weixuan Fu 21893bb583 Merge pull request #173 from 11cafe/hide-broken-image
hide broken image link
2024-02-02 14:38:42 +08:00
Weixuan Fu 8453086472 hide broken image link 2024-02-02 14:37:58 +08:00
Weixuan Fu 6ffcd06606 Update README.md 2024-02-02 13:59:59 +08:00
Weixuan Fu 470802fb04 Merge pull request #170 from 11cafe/aller
folder always at top, move to user settings page
2024-02-02 00:22:39 +08:00
yanle 2cd16aaeaa feat: folder always at top, move to user settings page 2024-02-01 23:37:47 +08:00
yanle ce57c1a4fb feat: folder always at top, move to user settings page 2024-02-01 23:36:56 +08:00
yanle 41e2dd281e feat: folder always at top, move to user settings page 2024-02-01 23:34:59 +08:00
Weixuan Fu 16187c7fdc Merge pull request #166 from 11cafe/fix/model-download
Add file hash saving functionality and update UI components
2024-02-01 22:37:58 +08:00
Weixuan Fu cc1e0f5943 Merge pull request #168 from 11cafe/aller
modelManagerTopBar supports drag and drop + modify z-index
2024-02-01 22:37:39 +08:00
yanle 19a4f227ee feat: modelManagerTopBar supports drag and drop 2024-02-01 22:16:14 +08:00
Arslan Ablikim c8e17ad53d Add file hash saving functionality and update UI components 2024-02-01 19:31:12 +08:00
yanle bed10b0952 fix: When modifying workflow and Folder names, if the content is empty, Enter submission is prohibited. 2024-01-31 22:47:58 +08:00
yanle 63809a8acf feat: modify z-index in theme 2024-01-31 22:46:56 +08:00
Weixuan Fu 435038ab00 fix 2024-01-31 21:43:14 +08:00
Weixuan Fu 07aff5db5b remove deep compare 2024-01-31 21:36:37 +08:00
Weixuan Fu 8ed78e6110 fix discard 2024-01-31 21:35:01 +08:00
Weixuan Fu 2dabb0cd4d discard fix 2024-01-31 21:11:51 +08:00
Weixuan Fu cfd0f8959e hide discard button in dialog when there is no lastSavedJson! you can't discard when no last saved json 2024-01-31 21:07:19 +08:00
Weixuan Fu 4df6d640b8 fix discard unsaved changes cannot clear isDirty state 2024-01-31 20:50:03 +08:00
Weixuan Fu a77c47b6e1 Merge pull request #162 from 11cafe/aller
add folder import
2024-01-31 20:26:08 +08:00
yanle a4217859a2 Merge branch 'dev' into aller 2024-01-31 20:01:24 +08:00
yanle 20c6273b55 feat: Folders are collapsed by default 2024-01-31 19:59:38 +08:00
Weixuan Fu f043816c7b fix discard changes not actually discarding changes and the isDirty always come back 2024-01-31 19:40:45 +08:00
yanle b461110ea3 feat: move generateUniqueName to workflowsTable 2024-01-31 19:37:51 +08:00
Weixuan Fu bba5fb11f6 disable twoway sync until this feature is ready 2024-01-31 18:49:35 +08:00
Weixuan Fu 45bc0c7864 Merge pull request #163 from 11cafe/fix/hover-check
fix: rm hover check
2024-01-31 18:47:16 +08:00
Arslan Ablikim 6286b52627 fix: hover check 2024-01-31 16:10:30 +08:00
yanle 8619727278 fix: Multi-tab autosave logic optimization 2024-01-31 13:26:55 +08:00
yanle f81108e4a7 feat: If the currently open flow is deleted during batch deletion, clear the curFlow related cache. 2024-01-31 13:14:54 +08:00
yanle 16877cb494 Merge branch 'dev' into aller 2024-01-31 13:02:47 +08:00
Arslan Ablikim 421b644f08 Merge pull request #160 from 11cafe/feat/folder-drag-drop
feat: folder drag drop
2024-01-31 11:15:11 +08:00
yanle 6bda357eaa feat: Only perform workflow duplicate name verification under the same file 2024-01-31 02:15:14 +08:00
yanle 5ec4f7fc75 Merge branch 'dev' into aller 2024-01-31 01:45:29 +08:00
yanle 251b0ea595 feat: add folder import 2024-01-31 01:43:57 +08:00
Arslan Ablikim 75447025d3 fix: put folder on top into userSettings table 2024-01-30 17:54:40 +08:00
Weixuan Fu 3c96f077d5 Merge pull request #161 from 11cafe/dev-beta
📣Dev beta: breaking change, indexdb migrate, all tables async, updateFiles py async multithread
2024-01-30 16:50:21 +08:00
Weixuan Fu e793d69f8c Merge branch 'main' into dev-beta 2024-01-30 16:08:28 +08:00
Weixuan Fu 186364a902 add dist files 2024-01-30 16:06:49 +08:00
Arslan Ablikim 1399adfce3 fix: drag flicker 2024-01-30 12:57:30 +08:00
Arslan Ablikim 0a56d12eff feat: add folders on top setting 2024-01-30 12:43:06 +08:00
yanle 6358cbd293 feat: remove getRecords;fix image duplication issue 2024-01-29 21:01:03 +08:00
Arslan Ablikim 941aa7cb13 fix: drag style 2024-01-29 18:58:07 +08:00
Arslan Ablikim 6a2c9e386f feat: folder drag drop 2024-01-29 18:28:35 +08:00
Weixuan Fu 0f8e30cf20 Merge pull request #155 from 11cafe/aller
Add data consistency check during automatic saving
2024-01-29 15:25:39 +08:00
Weixuan Fu da8ca89e17 Merge pull request #159 from 11cafe/feat/eslint-fix
feat: eslint fix
2024-01-29 14:46:22 +08:00
Weixuan Fu 99f5657cb2 allow func parenthesis 2024-01-29 13:49:07 +08:00
Arslan Ablikim b9920efe1a feat: eslint fix 2024-01-29 10:52:42 +08:00
Weixuan Fu bf1b227995 Update README.md 2024-01-29 02:00:20 +08:00
Weixuan Fu b7dcf47afc Merge pull request #157 from 11cafe/missing-install
ui polishing for Missing install draw items
2024-01-28 22:53:07 +08:00
Weixuan Fu c63810a604 use model id as hf button name 2024-01-28 22:52:07 +08:00
Weixuan Fu c8370bae3a missing models hf logo 2024-01-28 22:41:49 +08:00
yanle e5236c2318 Merge branch 'dev' into aller 2024-01-27 22:39:31 +08:00
yanle 64981e63e2 feat: Add data consistency check during automatic saving 2024-01-27 22:35:49 +08:00
Weixuan Fu 60a254a7c6 missing model install directly from hf 2024-01-27 17:14:12 +08:00
Weixuan Fu a624e51f9d fix cache not working for civit model browser 2024-01-27 15:30:41 +08:00
Weixuan Fu 95f29ace2e Merge pull request #154 from 11cafe/feat/api-key-input
feat: api key input
2024-01-27 15:17:45 +08:00
Weixuan Fu 90958fb5df ui fix, remove listener, put custom url input into dialog 2024-01-27 15:13:30 +08:00
Arslan Ablikim 96c3f3385c feat: api key input 2024-01-27 13:47:51 +08:00
yanle 59ee241c3d fix: fixed the issue where unsaved interception reminders failed 2024-01-27 12:26:18 +08:00
Weixuan Fu 3d3761ae78 Merge pull request #151 from 11cafe/aller
Add id field to tagsTable, migrate userSettings, and add id field for historical data adaptation;
2024-01-27 00:25:40 +08:00
yanle a9a8b2ada3 feat: remove tagsTable id 2024-01-27 00:10:17 +08:00
yanle ef23141d7f feat: remove tagsTable id 2024-01-26 23:21:18 +08:00
yanle d9d2bfb932 fix: pr comments fix 2024-01-26 23:00:10 +08:00
yanle 5abae0d05b Merge branch 'dev' into aller 2024-01-26 21:04:57 +08:00
Arslan Ablikim 2142ff29b0 Merge pull request #149 from 11cafe/feat/model-suggestion
feat: missing model suggestion
2024-01-26 14:47:41 +08:00
yanle 70ecdce3a8 feat: userSettings historical data compatible id 2024-01-26 00:41:31 +08:00
yanle 18cd843fac feat: userSettings migrate 2024-01-25 23:49:08 +08:00
yanle fcd3b64b05 Merge branch 'dev' into aller 2024-01-25 19:08:02 +08:00
Arslan Ablikim ea49e462f9 subfolder handle 2024-01-25 11:37:27 +08:00
Arslan Ablikim b23b3c7c33 feat: missing model suggestion 2024-01-25 11:26:58 +08:00
Weixuan Fu 44d040fadd Merge pull request #114 from 11cafe/py-async
offload py server file io to another thread to reduce impact on main …
2024-01-25 02:20:45 +08:00
Weixuan Fu c160276fde fix bug when manual save workflow and it delete file on disk too cuz mistakenly use !==null instead of !=null 2024-01-25 02:13:06 +08:00
Weixuan Fu 42784a1fca offload delete files to async, handle delete current flow and set curFlowID to null so it will not stuck in loop of all alert to save or discard 2024-01-25 02:02:19 +08:00
Weixuan Fu ab9a711b3e clean up async thread code, remove save_backup unused functions 2024-01-25 01:15:53 +08:00
Weixuan Fu 615549fc77 cleanup 2024-01-25 01:15:53 +08:00
Weixuan Fu 9d5c04b0e4 make async func inside endpoint 2024-01-25 01:15:53 +08:00
Weixuan Fu 72e560759e offload py server file io to another thread to reduce impact on main thread perf 2024-01-25 01:15:53 +08:00
Weixuan Fu c413dfecaf Merge pull request #147 from 11cafe/feat/missing-model-drawer
feat: missing modal drawer
2024-01-25 01:04:58 +08:00
Weixuan Fu db8072a835 small ui fixes 2024-01-25 01:03:45 +08:00
yanle 1da6df36da perf: reduce repeated calls to tagsTable.listAll 2024-01-24 22:12:57 +08:00
yanle 3e77ea4e5c feat: add id to tag table 2024-01-24 22:05:47 +08:00
yanle 8fca92b893 feat: add id to tag table 2024-01-24 22:01:43 +08:00
Weixuan Fu 698d38af57 friendly reminder 2024-01-24 19:05:19 +08:00
Weixuan Fu 461789d803 ui fix 2024-01-24 18:37:15 +08:00
Arslan Ablikim 25dd6c55e0 feat: missing modal drawer 2024-01-24 16:28:07 +08:00
Arslan Ablikim 85e5f429ac Merge pull request #144 from 11cafe/fix/hover-menu
fix: hover menu
2024-01-24 14:39:54 +08:00
Arslan Ablikim 4f6029ba0b fix drag icon 2024-01-24 10:03:47 +08:00
Weixuan Fu 5d6088fa1f use back delayed close 2024-01-24 09:57:58 +08:00
Weixuan Fu 2d905af784 remove delay 2024-01-24 09:57:58 +08:00
Arslan Ablikim 9e15da127c fix: hover menu 2024-01-24 09:57:48 +08:00
Weixuan Fu f85fad8486 Update README.md 2024-01-24 03:21:52 +08:00
Weixuan Fu 1962a39a00 Update README.md 2024-01-24 03:13:28 +08:00
Weixuan Fu 3f1ce9d59a Update README.md 2024-01-24 03:04:32 +08:00
Weixuan Fu 61712fe51d save lastSavedJson by default on duplicate or save as operation 2024-01-24 02:33:15 +08:00
Weixuan Fu b0a7bf731a fix refresh close confirm not working in async mode 2024-01-24 02:04:13 +08:00
Weixuan Fu 92c4a7771f Merge pull request #146 from 11cafe/aller
migrate workflow to indexdb
2024-01-23 23:54:01 +08:00
yanle 23152b7dcf fix: userSettings backfill invalid 2024-01-23 22:55:35 +08:00
yanle 024cefc52e fix: test bug fix 2024-01-23 22:00:57 +08:00
yanle 524d52e14b fix: create a new folder saveDB and call it repeatedly 2024-01-23 20:52:51 +08:00
yanle c813fe5f36 Merge branch 'dev' into aller 2024-01-23 20:45:49 +08:00
yanle e2145f546a feat: workflow CRUD changed to async 2024-01-23 20:31:57 +08:00
Weixuan Fu 908912f342 Merge pull request #145 from 11cafe/weixuan
move model topbar to left corner
2024-01-23 19:36:43 +08:00
Weixuan Fu d32a7defed move model topbar to left corner 2024-01-23 18:45:23 +08:00
Weixuan Fu e70e98343a better ui in settings choose my workflows dir 2024-01-23 17:52:01 +08:00
Weixuan Fu f98923e44c migrate saveDB to saveDiskDB in mediaTable, changelogsTable, etc. 2024-01-23 16:06:24 +08:00
Weixuan Fu b580acfb17 Update README.md 2024-01-23 01:02:31 +08:00
Weixuan Fu b840d94483 Update README.md 2024-01-23 01:01:04 +08:00
Weixuan Fu 302bd8f0cd Update README.md 2024-01-23 00:52:28 +08:00
Weixuan Fu 39b52202d1 Update README.md 2024-01-22 21:15:54 +08:00
Weixuan Fu a1fae01723 Merge pull request #142 from 11cafe/FEAT/DRAG-DROP-MODELS
feat: drag drop models
2024-01-22 17:49:47 +08:00
Weixuan Fu 1298bbcd8e disable default image drop action 2024-01-22 17:48:36 +08:00
Arslan Ablikim 7876bad7b3 feat: drag drop models 2024-01-22 16:26:20 +08:00
Weixuan Fu 09992a68e3 delete file hash folder 2024-01-22 14:37:10 +08:00
Weixuan Fu d688277325 fix two way sync setting changing autosave 2024-01-22 14:35:41 +08:00
Weixuan Fu 0922ec61f4 only cache with no search query 2024-01-22 14:28:58 +08:00
Weixuan Fu be99a8cb43 add cache for civit ai query resp 2024-01-22 14:26:31 +08:00
Weixuan Fu 9309014cd6 add savedb for table base and add modelTable 2024-01-22 13:45:36 +08:00
Weixuan Fu 5a056ae341 load indexdb in loaddbs 2024-01-22 12:10:11 +08:00
Weixuan Fu 739918ec9f Update README.md 2024-01-21 23:30:19 +08:00
Weixuan Fu 015425af84 Update README.md 2024-01-21 23:27:07 +08:00
Weixuan Fu 5a8a4461e5 change indexdb to v1 2024-01-21 23:21:27 +08:00
Weixuan Fu 855df75d0c Merge pull request #140 from 11cafe/model-ui
Model UI
2024-01-21 23:20:40 +08:00
Weixuan Fu a577512c4b Merge pull request #139 from 11cafe/aller
feat: folder CRUD changed to async
2024-01-21 23:03:12 +08:00
Weixuan Fu 537835ebf9 save model data to indexdb 2024-01-21 20:35:26 +08:00
yanle bd96c96cce Merge branch 'dev' into aller 2024-01-21 18:06:06 +08:00
yanle f480b093a0 feat: folder async pr problem fix 2024-01-21 18:02:21 +08:00
Weixuan Fu 614bd17121 better model topbar ui 2024-01-21 17:42:49 +08:00
Weixuan Fu bb7a7e3f0a Merge pull request #135 from 11cafe/feat/model-list-refresh
hash after building file list
2024-01-21 17:38:52 +08:00
Weixuan Fu 3ddf59e4b2 small fix func call pos 2024-01-21 17:38:30 +08:00
yanle a22af1b8b0 feat: App.js merged two useEffects into one. 2024-01-21 17:06:44 +08:00
yanle 66900a6c3c feat: nested folder deletion logic reconstruction 2024-01-21 16:49:44 +08:00
yanle d6268b8411 feat: CRUD changed to async 2024-01-21 11:55:07 +08:00
Weixuan Fu ed7cf5b909 Merge pull request #136 from 11cafe/dev_meta_data_info
Dev meta data info
2024-01-19 23:40:09 +08:00
ss707494 96764e40b3 feat: Get meta data in different workflows/add prettier 2024-01-19 21:23:05 +08:00
Weixuan Fu 156671d4a4 cursor 2024-01-19 19:35:09 +08:00
Weixuan Fu 84c523e2b4 lazy load gallery 2024-01-19 19:34:36 +08:00
Weixuan Fu bab82ff1fe fixed ui style 2024-01-19 19:25:57 +08:00
Arslan Ablikim efe51bf9e7 hash after building file list 2024-01-19 16:32:33 +08:00
ss707494 6ace5ad61d Merge branch 'dev' into dev_meta_data_info 2024-01-19 16:09:11 +08:00
ss707494 335b303185 feat: Get meta data in different workflows 2024-01-19 16:08:49 +08:00
Weixuan Fu 8cae187755 Merge pull request #132 from 11cafe/feat/model-list-refresh
model list should refresh when new one is downloaded
2024-01-19 14:53:55 +08:00
Weixuan Fu 2f490d48d8 fix import fail and console error 2024-01-19 14:06:34 +08:00
Arslan Ablikim bb3b92716c fix bug 2024-01-19 13:29:06 +08:00
Weixuan Fu 70beda2f2a fix dist missing 2024-01-19 06:09:25 +08:00
Weixuan Fu f201ac5af4 Update README.md 2024-01-19 01:07:30 +08:00
Arslan Ablikim 715672d5ae use ws 2024-01-19 01:00:03 +08:00
Weixuan Fu 47c3931584 small improvements for multi browser not overwriting to wrong workflow 2024-01-19 00:52:09 +08:00
Arslan Ablikim a40c4ed965 Merge branch 'dev' into feat/model-list-refresh 2024-01-18 23:46:20 +08:00
Weixuan Fu 2f2fb3ae68 Merge pull request #131 from 11cafe/hmr-clean
support hmr
2024-01-18 23:37:21 +08:00
Weixuan Fu d28e7dba0e fix ignore 2024-01-18 23:31:47 +08:00
yanle 47657fe915 doc: add developer readme 2024-01-18 23:16:22 +08:00
Arslan Ablikim beeda511d1 model list should refresh when new one is downloaded 2024-01-18 23:14:19 +08:00
yanle 97bc9fae20 feat: update hmr 2024-01-18 22:32:26 +08:00
ss707494 9761b3d389 feat: get video meta info 2024-01-18 21:27:06 +08:00
Weixuan Fu cf52340d04 Update README.md 2024-01-18 14:17:41 +08:00
ss707494 5c140f9a4f feat: gallery image add meta info button 2024-01-18 13:59:27 +08:00
Weixuan Fu c1dd8cadd8 clean hmr 2024-01-18 13:49:29 +08:00
Weixuan Fu d24eafa642 Merge pull request #129 from arslan2012/feat/queue
update queue to show queue in the modal and added custom url download
2024-01-18 10:26:34 +08:00
Arslan Ablikim 1292913b28 add custom url download 2024-01-18 02:35:33 +08:00
Arslan Ablikim 4564d1c69c update queue to show queue in the modal 2024-01-18 00:59:57 +08:00
Weixuan Fu 6c6308a53e folder right click to create new file, migrate createFlow to be async 2024-01-18 00:37:14 +08:00
Weixuan Fu 039b822396 fix manage tags crash bug 2024-01-17 21:16:20 +08:00
Weixuan Fu f10173dfd5 Merge pull request #127 from 11cafe/indexdb
Indexdb migrate: migrate workflows and folders table, disable legacy indexdb backup
2024-01-17 17:09:54 +08:00
Weixuan Fu a158775c7d cleanup code 2024-01-17 17:08:46 +08:00
Weixuan Fu 2486cb37fb merge 2024-01-17 17:05:55 +08:00
Weixuan Fu 5f255d1181 migrated workflows table 2024-01-17 17:02:38 +08:00
Weixuan Fu 021d50d6b7 migrate folders 2024-01-17 16:35:35 +08:00
Weixuan Fu fcd8c212e4 make generateFilePath async 2024-01-17 16:35:35 +08:00
Weixuan Fu f0d356e0c4 Merge pull request #121 from 11cafe/feature/model-drawer
feat: show model image
2024-01-17 15:57:47 +08:00
Weixuan Fu 28687fca44 use folder_paths functions 2024-01-17 15:49:00 +08:00
Weixuan Fu d10d2833ec Merge pull request #126 from arslan2012/feat/queue
feat(models): added queue feature to the model downloader and switch to using interval get progress
2024-01-17 14:39:18 +08:00
Weixuan Fu 09764e903b migrate folders 2024-01-17 14:21:09 +08:00
Arslan Ablikim 44c89bea5a feat(models): added queue feature to the model downloader and switch to using interval get progress 2024-01-17 12:44:45 +08:00
Weixuan Fu bf527483a3 make generateFilePath async 2024-01-17 10:57:43 +08:00
Weixuan Fu 9fc01b3120 Merge pull request #125 from 11cafe/indexdb
migrate tables to Indexdb
2024-01-17 10:28:36 +08:00
Weixuan Fu ae479fbe9b migrate settings 2024-01-17 03:20:04 +08:00
Weixuan Fu e64c226ad7 migrate tag 2024-01-17 02:45:42 +08:00
Weixuan Fu 81497fc746 migrate changelogs table 2024-01-17 01:19:54 +08:00
Weixuan Fu 90b7f3e6be migrated media tabl 2024-01-17 00:23:02 +08:00
Weixuan Fu fa12d791ec more indexdb migration, multi browser tab name 2024-01-16 21:43:41 +08:00
Weixuan Fu f429fa7d79 migrate workflows table 2024-01-16 19:47:04 +08:00
Weixuan Fu fb286f0d66 created schema and backfill function 2024-01-16 19:17:26 +08:00
Weixuan Fu f46427f20b ui improvements 2024-01-16 14:29:09 +08:00
Weixuan Fu 4348b37d51 Merge pull request #124 from 11cafe/new-window-url add url hash, open flow in new tab, full rewrite /my_workflows to inject workspace_info.id
add url hash, open flow in new tab, full rewrite /my_workflows to inject workspace_info.id
2024-01-16 14:07:22 +08:00
Weixuan Fu 3d0e87fba4 add try catch to full rewrite my_workflows/ 2024-01-16 14:00:06 +08:00
Weixuan Fu 1a75e49df8 use loadworkflowID in first setup 2024-01-16 13:57:27 +08:00
yanle 04b0600d5e feat: add url hash, open flow in new tab, Added the function of fully rewriting local files 2024-01-16 13:50:04 +08:00
Weixuan Fu 974e08cb78 Merge pull request #123 from 11cafe/missing-model
Missing models button
2024-01-16 13:30:12 +08:00
Weixuan Fu 920cd8e4c6 ui improvement 2024-01-16 13:20:29 +08:00
Astray_ 1e92a02bc2 change to folder_path 2024-01-15 17:40:21 +08:00
Astray_ c755f9c879 feat: use folder_paths to get models 2024-01-15 17:35:10 +08:00
Astray_ 00f1a1a7a9 feat: show model image 2024-01-15 16:00:45 +08:00
Weixuan Fu a9ce2bb865 able to extract model folder param and send back to react 2024-01-15 15:43:49 +08:00
Weixuan Fu 55c24860b2 Merge pull request #119 from 11cafe/missing-model
ui fixes
2024-01-15 02:53:04 +08:00
Weixuan Fu 95b31db15c fixed size save buttons 2024-01-15 02:49:58 +08:00
Weixuan Fu 18b1a86c0a add intall models button into my models modal 2024-01-15 02:14:33 +08:00
Weixuan Fu 61c7d957bb cleanup code 2024-01-15 00:58:04 +08:00
184 changed files with 15105 additions and 5653 deletions
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: [11cafe]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1 -2
View File
@@ -1,6 +1,5 @@
dist
__pycache__
dist
hash
.env
# Logs
logs
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+138 -40
View File
@@ -1,14 +1,17 @@
# ☕️ ComfyUI Workspace Manager - Comfyspace
A ComfyUI workflow management extension to **organize and manage all your workflows and generated images in one place**. Seamlessly switch between workflows, track version history and image generation history within a single workspace. We are reimagining the way to manage all your resources as a whole, workflows, models and custom nodes.
A ComfyUI workflow and model manager extension to **organize and manage all your workflows, models and generated images in one place**. Seamlessly switch between workflows, track version history and image generation history, 1 click install models from Civit ai, browse/update your installed models
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
<a href="https://discord.gg/HHvfEurv2Z">
<img src="https://animeartmagazine.com/wp-content/uploads/2022/05/discord-button600.webp" alt="Join Discord" width="120"/>
</a>
[New!] we're building a comfyui custom nodes library wiki like Pypi: https://www.nodecafe.org/
welcome any feedbacks!
🔴‼️ If you updated or cloned this repo between March 14-17, 2024, please update it again to latest verison, there were some bugs that may cause you lose changes in Auto Save mode.
‼️ [3-23-2024]**search and install models from civitai is not working in previous versions**, because civitai changed something in their api. **Please update to the latest version** if you are seeing blank screen in Install Models dialog
<img width="610" alt="Screenshot 2024-03-08 at 6 41 18 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/30455eb1-3d12-4930-bb81-28b33148fbaa">
## Installation
@@ -29,36 +32,72 @@ and restart your ComfyUI.
It is recommended to do git clone than downloading zip folder, because you can get latest updates by `git pull`!
## Features
### 📣Update 01/03/2024 - Gallery and cover images
Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow
<img width="400" alt="Screenshot 2024-01-03 at 9 05 01 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/879ea664-c06b-4abb-b4f0-a9305ade3bc9">
<img width="394" alt="Screenshot 2024-01-03 at 7 35 20 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/ae818652-8680-48a2-bdd3-bd02cb20ad16">
**Workflows manager**
### 📣Update 12/29/2023 - Manual save, version history
**Manually save** your workflow by either click the 💾 floppy disk icon button or Shift+S shortcut (customize this shortcut in Settings)
Everytime you save, it will create a new record in **Version History**. You can go back and switch versions in version history like git.
<img width="400" alt="Screenshot 2023-12-29 at 9 59 57 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/88c82672-ebf2-4feb-bf5b-74b447843c2c">
- 🔁Effortlessly switch between different workflows in your workspace.
- 🔁Switch between different workflows easily. 1click open workflow in multiple browser tabs.
- 🤏**Drag and drop** to insert subworkflows into current flow.
- Create and name workflows
- 🗂️Organize workflows with folders, 🏷️tags
- 📂Saves all your workflows in a single folder (by default under `/ComfyUI/my_workflows`), customize this location in Settings
- 🖼️Gallery and cover images: Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow
- 🕛**Version control** of workflow, never lose any changes (Everytime you press save, it will log a new version in history. you can easily revert back to any versions like git)
- **Bulk import** workflows into your workspace
- 📑Quickly duplicate flow in right-click menu
- 🗂️Organize workflows with folders, tags.
- 📂Save and sync all your workflows in a local folder (by default under `/ComfyUI/my_workflows` customize in Settings)
- **Save workflow by pressing the 💾 button, or ⌨Shift+S shortcut** (customize in Settings).
- Every time you save, it will create a new record in **Version History**. You can easily revert to any version.
- **Enable/disable autosave workflow** (please disable autosave if you are experiencing UI lagging)
- You can move files using OS File Explorer and it will be **two-way synced** with workspace.
- 🕛**Version control** of workflow, create/switch versions
- Bulk import workflows into your workspace
- 1click download all your workflows into a .zip
**🔮Models manager**:
- 1click install models from civitai to `models/` subfolder of the matching model type
- Browse installed models with thumbnail image
- drag and drop to insert "load model_type" node into workflow
**🖼️Image Gallery**: Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow
- Dark/light mode
- cloud sync & backup workspace so you will never lose your data (Upcoming!)
- One-click share workflow (Upcoming!)
- [beta!]One-click share workflow
### Where are my workflow and workspace data stored?
Recommend using Chrome browser for the best experience. Safari doesn't work well. Edge or firefox should be fine too.
🍺 All your workflows are now output to a folder in `/ComfyUI/my_workflows` ⚠️Note: this is a ONE-WAY sync folder by default, that will only reflect changes made from your ComfyUI workspace browser; If you add some files manually using your OS file system (e.g. Finder in MacOS, File Explorer in Windows), workspace manager will not be able to pickup those changes. You need to use the "Import" button in files side bar to add new files to workspace manager.
[中文版教程](https://www.uisdc.com/workspace-manager) &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [Roadmap](https://github.com/orgs/11cafe/projects/1/views/1)
Our internal db data is stored in your disk under `/ComfyUI/custom_nodes/comfyui-workspace-manager/db/` We also store a backup copy of your db data in your browser cache in case you lose your workspace data when deleting comfyui-workspace-manager and reinstall. Press F12 or Inspect in right click browser menu, go to Application -> IndexedDB -> WorkspaceDB to see your data
# <<<<<<< HEAD
🚧🔴 Drag and drop to insert subworkflow is not working. I have debugged for many hours but just can't make it work perfectly. If anyone is interested here is the code:
https://github.com/11cafe/comfyui-workspace-manager/blob/d2d2f0c1f1ace35236e73fa5074e9feb83342773/ui/src/utils.tsx#L192
I would really appreciate it if someone can find a solution to insert a subworkflow to current canvas given the json object.
### 🦄v2.0 beta: Two-way sync with local folder, select model install path, cloud sync workflows
> > > > > > > dev
**🦩v2.1 New gallery coming soon! Inspect image metadata, search image by prompt, model/lora used**
<img width="700" alt="Screenshot 2024-03-23 at 12 58 38 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/5bac9793-7491-4131-b850-037094d03823">
Control+P quick spotlight search (like VSCode)
<img width="500" alt="Screenshot 2024-03-27 at 3 49 35 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/47c14904-180c-4c52-a644-223d0936c9fa">
**🦄v2.0 Two-way sync with local folder, select model install path, cloud sync workflows**
- Two-way sync with selected local folder so you can move files using File Explore or Finder to sync them with workspace!
- **Models manager support external models path like Automatic1111**! Choose models downloading folder from added external paths.
- Add models thumbnails image by putting a image file with the same name in models folder like A1111
- One click sharing to cloud to backup your workflows to cloud
v2.0 demo: https://youtu.be/7Hht_BMe844
**v1.7 ComfyUI model manager, view your installed models, install models from civit ai, open workflow in multiple browser tabs**
<img width="700" alt="Screenshot 2024-02-02 at 5 51 13 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/86dc9007-c45c-4b7b-b74a-76ddc93c89a8">
<img width="400" alt="Screenshot 2024-01-23 at 12 27 34 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/3b543024-3a6b-4fca-9980-e535d83e206e">
<img width="200" alt="Screenshot 2024-01-23 at 12 26 56 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/d29d0831-a15a-4597-acf6-facee4dbc76e">
<img width="460" alt="gallery" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/2b7886c9-2d4a-46c6-adac-429720f74f11">
switch between flows:
@@ -68,32 +107,42 @@ switch between flows:
<img src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/7540c45b-be51-4428-8558-96adcc1024c8" width="480">
Choose the folder location where you want to save your workflows .json files in Settings:
<img width="400" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4751b096-0886-4a32-95b1-87c7ce5b3787">
Bulk import flows:
<img width="400" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/884c8852-73c7-41d6-9179-7fb1fcea9d04">
add tags to flow:
add tags to flow:
<img width="400" alt="Screenshot 2023-12-13 at 1 54 04 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/6bf4d22b-f52a-4d23-8882-4a31162616f3">
Workflow operations: Manual Save, Download, Discard unsaved changes:
Workflow operations: Manual Save, Download, Discard unsaved changes:
<img width="500" alt="Screenshot 2023-12-29 at 9 59 57 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/88c82672-ebf2-4feb-bf5b-74b447843c2c">
Version history:
### How do I save my workflow?
<img width="387" alt="Screenshot 2023-12-29 at 11 17 37 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/ac8c52d7-c339-46a0-8682-ed03b0cab75d">
Save your workflow by either clicking the 💾 floppy disk icon button or ⌨**Shift+S** shortcut (customize this shortcut in Settings)
Every time you save, it will create a new record in **Version History**. You can go back and switch versions in version history like git.
duplicate flow by right click menu option:
<img width="385" alt="Screenshot 2023-12-29 at 9 58 26 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/b74c4405-8232-4e39-b995-3b4877d27c9e">
<img width="350" alt="Screenshot 2024-01-23 at 12 22 22 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/cc4eb504-6a65-44af-ad7f-d4a8b4ac7b9a">
<img width="300" alt="Screenshot 2024-01-23 at 12 23 21 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/041c2da8-d8cf-46b7-b9d8-45b415e764f8">
By default, We also enabled auto-saving your current workflow as a cache in case you forgot to save it manually. It may affect UI performance, if you experience UI lagging, ‼️‼️**you can disable auto-saving in Settings:**
<img width="297" alt="Screenshot 2024-01-23 at 12 37 37 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/9ca0a63e-ddae-4436-a374-6ef0276c23fb">
<img width="402" alt="Screenshot 2024-01-22 at 2 33 00 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/061a8463-f7e6-4985-9516-8117d7a5edd5">
### Where are my workflow and workspace data stored?
🗂️All your workflows are saved in a folder in `/ComfyUI/my_workflows`, you can change this path in settings.
<img width="360" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4751b096-0886-4a32-95b1-87c7ce5b3787">
Other metadata (versions, gallery images) is primarily stored in your browser cache (IndexDB) and backedup in your disk under `/ComfyUI/custom_nodes/comfyui-workspace-manager/db/`. Press F12 or Inspect in right click browser menu, go to Application -> IndexedDB -> comfyui_workspace_db to see your data (workflows, version history, gallery, etc.)
**If you switch browser, your will not see your previous metadata (version history, gallery images) in your new browser**
<img width="607" alt="303892585-c04ca874-03f0-4569-9a40-d8453dd9b031" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/17097e17-743d-499f-8f86-0eb1193d0e46">
**Looking for colaborators and coworkers** to develop this ComfyUI project management tool. If you know React, Python or ML model deployment, please reach out to weixuanfu01@gmail.com We want to make it easy to **manage, share and deploy** ComfyUI project.
@@ -114,7 +163,37 @@ duplicate flow by right click menu option:
Right now you need to have python and GPU server up to run ComfyUI or Automatic111, that’s really painful for people who do not own a GPU. You should be able to run UI independently without paying for GPU💰.
➡️ Share and deploy your workflow to cloud in 1click and other people can easily run in browser using cloud GPU with no setup. It should be as easy as running Google Docs or Figma.
## Beta Preview
We're constantly pushing new features in beta branch. Here is how to switch to beta branch to receive latest feature updates pre-launch! It requires you have some basic knowledge about command line and git.
- Make sure you have [git installed](https://git-scm.com/downloads)
- Open your terminal, go to the ComfyUI folder (where you do `python main.py`)
- in ComfyUI root directory, `cd custom_nodes && cd comfyui-workspace-manager`
- inside custom_nodes/comfyui-workspace-manager, do `git fetch origin beta && git checkout beta`, you should see something like:
```
From https://github.com/11cafe/comfyui-workspace-manager
* branch beta -> FETCH_HEAD
Switched to branch 'beta'
Your branch is up to date with 'origin/beta'.
```
- Restart ComfyUI python server by `python main.py`
‼️ Be careful that beta branch can be unstable, so please please make sure you have backups for all your workflows! **Before switching to beta, please make a copy of these folders somewhere:**
`ComfyUI/custom_nodes/comfyui-workspace-manager/db`
or the entire `ComfyUI/custom_nodes/comfyui-workspace-manager/` folder
if anything goes wrong, you can always paste back these folders. Also, you should have your /my_workflows folder (workspace save directory) in safe place always
To revert back to main:
- inside custom_nodes/comfyui-workspace-manager, do `git checkout main`, then restart your python server and you are back into main branch!
## Credits
ComfyUI: https://github.com/comfyanonymous/ComfyUI
ComfyUI Browser: https://github.com/talesofai/comfyui-browser (Media display code for reference)
@@ -143,3 +222,22 @@ cd custom_nodes && git clone https://github.com/11cafe/comfyui-workspace-manager
5. run ComfyUI server
inside `/ComfyUI`
do `python main.py` or `python3 main.py` depending on your version
#### IndexDB trouble shooting
If you see indexdb version mismatch error in console that looks like:
```
VersionError: The requested version (10) is less than the existing version (30)."
name
:
"DatabaseClosedError"
```
please delete indexdb manually F12 Dev tools -> Application -> IndexDB -> WorkspaceManagerDB
<img width="460" alt="Screenshot 2024-01-21 at 11 21 55 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/adcde47f-c620-42ad-9367-e16e5be92be1">
and delete localstorage.WORKSPACE_INDEXDB_BACKFILL
<img width="460" alt="Screenshot 2024-01-21 at 11 29 02 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/f3c17034-c863-481a-8771-8e7e87bcee07">
only do this if you are a developer
+54
View File
@@ -0,0 +1,54 @@
# ☕️ ComfyUI Workspace Manager - Comfyspace
This is a document specially prepared for developers, explaining some special development details.Installation
## Clean up indexdb if it's in bad state
Make sure you have .json files under `ComfyUI/comfyui-workspace-manager/db`, so your indexdb data (version history, gallery image data) can be recovered after deleting
<img width="318" alt="Screenshot 2024-03-12 at 12 35 24 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/722a96ca-82a3-4126-83fd-2951de0a18cb">
1. F12 -> Application -> IndexedDB -> delete current indexdb
<img width="731" alt="Screenshot 2024-03-12 at 12 30 48 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4c4f0f6a-e402-4fd5-94cd-b00ff6f2a96f">
3. If you want to recover your data like version history, gallery image data, F12 -> Application -> Local Storage -> delete WORKSPACE_INDEXDB_BACKFILL key in localstorage so your indexdb can be backedup (you don't have to do this if you only need workflows .json data)
<img width="723" alt="Screenshot 2024-03-12 at 12 33 08 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/c9ca969d-fdee-4c1e-bfc5-c646ce241fd8">
## Install custom git hooks
When running the project for the first time, it is recommended that you execute the following command to install our customized git hooks
```javascript
cd ui
npm run setupGithooks
```
Current hooks include:
1. When switching to a non-main/beta branch, additional .gitignore logic is automatically added to ignore the "/dist" folder.
## How to use Hot Module Replacement
1. npm run dev starts the project;
2. If the dist directory currently exists, please delete the dist directory or keep the dist directory empty;
3. Modify ComfyUI/web/index.html and add the following code. It should be noted that the port number in localhost:5173 needs to be consistent with the port number of the vite local service started by npm run dev.![image](https://github.com/11cafe/comfyui-workspace-manager/assets/26196917/ef7eabc5-8683-4f9a-93f3-e3ba2b0d3449)
```javascript
import RefreshRuntime from "http://localhost:5173/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
const head = document.getElementsByTagName("head")[0];
const viteClientScript = document.createElement("script");
viteClientScript.src = "http://localhost:5173/@vite/client";
viteClientScript.type = "module";
head.appendChild(viteClientScript);
const workspaceMainScript = document.createElement("script");
workspaceMainScript.src = "http://localhost:5173/src/main.tsx";
workspaceMainScript.type = "module";
head.appendChild(workspaceMainScript);
```
+60 -180
View File
@@ -1,18 +1,21 @@
import asyncio
import server
from aiohttp import web
import aiohttp
import requests
import folder_paths
import shutil
import os
import sys
import threading
import subprocess # don't remove this
from urllib.parse import urlparse
import subprocess
import os
import json
from .version_control import update_version_if_outdated
from .service.model_manager.model_installer import download_url_with_wget
from .service.model_manager.model_list import get_model_list
from .service.media_service import *
from .service.file_sync_service import *
from .service.db_service import get_my_workflows_dir
from .service.node_service import *
WEB_DIRECTORY = "entry"
NODE_CLASS_MAPPINGS = {}
@@ -24,85 +27,15 @@ workspace_path = os.path.join(os.path.dirname(__file__))
comfy_path = os.path.dirname(folder_paths.__file__)
db_dir_path = os.path.join(workspace_path, "db")
workspace_app = web.Application()
workspace_app.add_routes([
web.static("/", os.path.join(workspace_path, 'dist/workspace_web')),
])
server.PromptServer.instance.app.add_subapp("/workspace_web/", workspace_app)
@server.PromptServer.instance.routes.post("/workspace/save_db")
async def save_db(request):
# Extract parameters from the request
data = await request.json()
table = data['table']
json_data = data['json']
file_name = f'{db_dir_path}/{table}.json'
if not os.path.exists(db_dir_path):
os.makedirs(db_dir_path)
# Write the JSON data to the specified file
with open(file_name, 'w') as file:
file.write(json.dumps(json_data, indent=4))
return web.Response(text=f"JSON saved to {file_name}")
def read_table(table):
if not table:
return None
file_name = f'{db_dir_path}/{table}.json'
if not os.path.exists(file_name):
return None
with open(file_name, 'r') as file:
data = json.load(file)
return data
@server.PromptServer.instance.routes.get("/workspace/get_db")
async def get_workspace(request):
# Extract the table parameter from the query string
table = request.query.get('table')
data = read_table(table)
return web.json_response(data)
dist_path = os.path.join(workspace_path, 'dist/workspace_web')
if os.path.exists(dist_path):
server.PromptServer.instance.app.add_routes([
web.static('/workspace_web/', dist_path),
])
BACKUP_DIR = os.path.join(workspace_path, "backup")
MAX_BACKUP_FILES = 20
@server.PromptServer.instance.routes.post("/workspace/save_backup")
async def save_backup(request):
try:
data = await request.json()
file_path = data.get('file_path')
json_str = data.get('json_str')
file_path = os.path.join(BACKUP_DIR, file_path)
if not file_path or not json_str:
return web.Response(text=json.dumps({"error": "file_path and json_str are required"}), status=400)
directory = os.path.dirname(file_path)
# Create the directory if it does not exist
os.makedirs(directory, exist_ok=True)
with open(file_path, 'w') as file:
file.write(json_str)
# Check the number of files in the directory after writing the new file
files = [f for f in os.listdir(directory) if os.path.isfile(
os.path.join(directory, f))]
if len(files) > MAX_BACKUP_FILES:
# Find the oldest file (smallest filename)
oldest_file = min(files, key=lambda x: x)
# Delete the oldest file
os.remove(os.path.join(directory, oldest_file))
return web.Response(text=json.dumps({"message": "File saved successfully"}), status=200)
except Exception as e:
return web.Response(text=json.dumps({"error": str(e)}), status=500)
@server.PromptServer.instance.routes.post("/workspace/list_backup")
async def list_backup(request):
try:
@@ -148,31 +81,35 @@ async def get_system_dir(request):
return web.Response(text=json.dumps({"dir_path": dir_path, "dir_contents": dir_contents}), content_type='application/json')
except Exception as e:
print('🔴🔴Error',e)
return web.Response(text=json.dumps({"error": str(e)}), status=500)
def get_my_workflows_dir():
data = read_table('userSettings')
if (data):
records = json.loads(data)
curDir = records['myWorkflowsDir'] if records else None
if curDir:
return curDir
return os.path.join(comfy_path, 'my_workflows')
@server.PromptServer.instance.routes.get("/workspace/get_my_workflows_dir")
async def fetch_my_workflows_dir(request):
try:
dir = get_my_workflows_dir()
return web.Response(text=json.dumps({"path": dir, "os": sys.platform}), content_type='application/json')
except Exception as e:
return web.Response(text=json.dumps({"error": str(e)}), status=500)
@server.PromptServer.instance.routes.post("/workspace/update_file")
async def update_file(request):
data = await request.json()
file_path = data['file_path']
json_str = data['json_str']
my_workflows_dir = get_my_workflows_dir()
full_path = os.path.join(my_workflows_dir, file_path)
# Create the directory if it doesn't exist
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w', encoding='utf-8') as file:
file.write(json_str)
def write_json_to_file(json_str):
my_workflows_dir = get_my_workflows_dir()
full_path = os.path.join(my_workflows_dir, file_path)
print(f"Writing to file: {full_path}")
# Create the directory if it doesn't exist
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w', encoding='utf-8') as file:
file.write(json_str)
# Offload the file update to a separate thread
await asyncio.to_thread(write_json_to_file, json_str)
return web.Response(text="File updated successfully")
@@ -181,22 +118,30 @@ async def delete_file(request):
data = await request.json()
file_path = data['file_path']
delete_empty_folder = data['deleteEmptyFolder']
my_workflows_dir = get_my_workflows_dir()
full_path = os.path.join(my_workflows_dir, file_path)
if os.path.exists(full_path):
os.remove(full_path)
def sync_delete_file(file_path, delete_empty_folder):
my_workflows_dir = get_my_workflows_dir()
full_path = os.path.join(my_workflows_dir, file_path)
# Check if the directory is empty after deleting the file
directory = os.path.dirname(full_path)
if delete_empty_folder and not os.listdir(directory):
# If the directory is empty, remove the directory
os.rmdir(directory)
return web.Response(text="File and empty directory deleted successfully")
if os.path.exists(full_path):
os.remove(full_path)
directory = os.path.dirname(full_path)
if delete_empty_folder and not os.listdir(directory):
# If the directory is empty, remove the directory
os.rmdir(directory)
return "File and empty directory deleted successfully"
else:
return "File deleted successfully"
else:
return web.Response(text="File deleted successfully")
return "File not found"
# Run the synchronous file operation in a separate thread
response_text = await asyncio.to_thread(sync_delete_file, file_path, delete_empty_folder)
if response_text == "File not found":
return web.Response(text=response_text, status=404)
else:
return web.Response(text="File not found", status=404)
return web.Response(text=response_text)
@server.PromptServer.instance.routes.post("/workspace/rename_file")
@@ -211,37 +156,6 @@ async def rename_file(request):
else:
return web.Response(text="File not found", status=404)
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
video_extensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']
@server.PromptServer.instance.routes.get("/workspace/view_media")
async def api_view_file(request):
filename = request.query.get("filename", None)
if not filename:
return web.Response(status=404)
output_path = folder_paths.get_output_directory()
file_path = os.path.join(output_path, filename)
if not os.path.exists(file_path):
return web.Response(status=200)
with open(file_path, 'rb') as f:
media_file = f.read()
content_type = 'application/json'
file_extension = os.path.splitext(filename)[1].lower()
if file_extension in image_extensions:
content_type = f'image/{file_extension[1:]}'
if file_extension in video_extensions:
content_type = f'video/{file_extension[1:]}'
return web.Response(
body=media_file,
content_type=content_type,
headers={"Content-Disposition": f"filename=\"{filename}\""}
)
@server.PromptServer.instance.routes.post("/workspace/open_workflow_file_browser")
async def open_workflow_file_browser(request):
my_workflows_dir = get_my_workflows_dir()
@@ -256,48 +170,14 @@ async def open_workflow_file_browser(request):
except Exception as e:
return web.Response(text=json.dumps({"error": str(e)}), status=500)
@server.PromptServer.instance.routes.post("/workspace/delete_folder")
async def delete_folder(request):
data = await request.json()
folder_path = data['folder_path']
def file_handle(name, file, existFlowIds, fileList):
json_data = json.load(file)
fileInfo = {
'json': json.dumps(json_data),
'name': '.'.join(name.split('.')[:-1])
}
if 'extra' in json_data and 'workspace_info' in json_data['extra'] and 'id' in json_data['extra']['workspace_info']:
if json_data['extra']['workspace_info']['id'] not in existFlowIds:
fileList.append(fileInfo)
if os.path.exists(folder_path):
shutil.rmtree(folder_path)
return web.Response(text="Successfully deleted folder: {folder_path}")
else:
fileList.append(fileInfo)
# Scan all files and subfolders in the local save directory.
# For files, compare the extra.workspace_info.id in the json format file with the flow of the current DB to determine whether it is a flow that needs to be added;
# For subfolders, scan the json files in the subfolder and use the same processing method as the file to determine whether it is a flow that needs to be added;
@server.PromptServer.instance.routes.post("/workspace/scan_local_new_files")
async def scan_local_new_files(request):
reqJson = await request.json()
path = reqJson['path']
existFlowIds = reqJson['existFlowIds']
fileList = []
folderList = []
for item in os.listdir(path):
item_path = os.path.join(path, item)
if os.path.isfile(item_path) and item_path.endswith('.json'):
with open(item_path, 'r') as f:
file_handle(item, f, existFlowIds, fileList)
elif os.path.isdir(item_path):
folder = {
'name': item,
'list': []
}
for sub_item in os.listdir(item_path):
sub_item_path = os.path.join(item_path, sub_item)
if os.path.isfile(sub_item_path) and sub_item_path.endswith('.json'):
with open(sub_item_path, 'r') as f:
file_handle(sub_item, f, existFlowIds, folder['list'])
if len(folder['list']) > 0:
folderList.append(folder)
return web.Response(text=json.dumps({'fileList': fileList, 'folderList': folderList}), content_type='application/json')
return web.Response(text="folder not found: {folder_path}", status=404)
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{r as o,j as t,B as e}from"./input.js";import{app as r}from"/scripts/app.js";function n({}){return o.useEffect(()=>{const s=r.graph.serialize();console.log(s)},[]),t.jsx(e,{size:"sm","aria-label":"missing models",px:2,children:"Install Missing Models"})}export{n as default};
-7
View File
@@ -1,7 +0,0 @@
import{f as u,r as i,j as e,b as d,e as x,B as f,R as S,_ as T}from"./input.js";import{G as E}from"./chunk-JARCRF6W-n_XCkAFM.js";import{B as g,O as W,T as L,a6 as I}from"./App-ZBQ9LoZK.js";import{app as m}from"/scripts/app.js";import"/scripts/api.js";var j=u(function(s,r){const{spacing:o="0.5rem",spacingX:a,spacingY:p,children:c,justify:n,direction:k,align:_,className:M,shouldWrapChildren:h,...y}=s,b=i.useMemo(()=>h?i.Children.map(c,(v,w)=>e.jsx(l,{children:v},w)):c,[c,h]);return e.jsx(d.div,{ref:r,className:x("chakra-wrap",M),...y,children:e.jsx(d.ul,{className:"chakra-wrap__list",__css:{display:"flex",flexWrap:"wrap",justifyContent:n,alignItems:_,flexDirection:k,listStyleType:"none",gap:o,columnGap:a,rowGap:p,padding:"0"},children:b})})});j.displayName="Wrap";var l=u(function(s,r){const{className:o,...a}=s;return e.jsx(d.li,{ref:r,__css:{display:"flex",alignItems:"flex-start"},className:x("chakra-wrap__listitem",o),...a})});l.displayName="WrapItem";function R({selectedModel:t,setSelectedModel:s,modelTypeList:r}){const o=a=>{s(a)};return e.jsx(j,{children:r.map(a=>e.jsx(l,{children:e.jsx(f,{colorScheme:"teal",variant:t===a?"solid":"outline",onClick:()=>o(a),size:"sm",children:a})}))})}const C="https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/27fd7433-cb0a-4a87-88c1-21ccb2b1a842/width=450/00060-881622046.jpeg";function z({list:t}){return e.jsx(E,{templateColumns:"repeat(3, 1fr)",gap:1,marginTop:2,children:t.map(s=>e.jsx(l,{children:e.jsxs(g,{position:"relative",borderRadius:4,children:[e.jsx(W,{src:C,boxSize:"100%",objectFit:"cover",borderRadius:4}),e.jsx(L,{position:"absolute",bottom:"0",left:"0",right:"0",bg:"rgba(0, 0, 0, 0.5)",color:"white",textAlign:"center",p:"0",fontSize:12,children:s})]},s)}))})}function N({onClose:t}){const[s,r]=i.useState("checkpoints"),[o,a]=i.useState(["checkpoints","clip","clip_vision","configs","controlnet","diffusers","embeddings","gligen","hypernetworks","loras"]);i.useState(["checkpoints","checkpoints2","checkpoints3","checkpoints4","checkpoints5","checkpoints6","checkpoints7","checkpoints8","checkpoints9","checkpoints10"]);const[p,c]=i.useState([]);i.useEffect(()=>{},[s]),i.useEffect(()=>(m.canvasEl.addEventListener("click",t),()=>{m.canvasEl.removeEventListener("click",t)}),[]);const n=440;return e.jsx(g,{style:{width:n},children:e.jsxs(I,{width:n,height:"100vh",pl:4,pr:5,pt:4,gap:4,position:"absolute",top:0,left:0,shadow:"xl",zIndex:1e3,overflow:"auto",children:[e.jsx(R,{modelTypeList:o,setSelectedModel:r,selectedModel:s}),e.jsx(z,{list:p})]})})}S.lazy(()=>T(()=>import("./InstallMissingModelsButton-Icn58Ii5.js"),__vite__mapDeps([0,1])));function q({}){const[t,s]=i.useState(!1);return e.jsxs(e.Fragment,{children:[e.jsx(f,{size:"sm",variant:"outline",colorScheme:"teal","aria-label":"My models",onClick:()=>s(!0),px:2,children:"My models"}),t&&e.jsx(N,{onClose:()=>s(!1)})]})}export{q as default};
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = ["workspace_web/InstallMissingModelsButton-Icn58Ii5.js","workspace_web/input.js"]
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{f as G,j as R,b as c}from"./input.js";var C=G(function(o,r){const{templateAreas:a,gap:s,rowGap:t,columnGap:e,column:i,row:d,autoFlow:l,autoRows:m,templateRows:p,autoColumns:u,templateColumns:n,...g}=o,w={display:"grid",gridTemplateAreas:a,gridGap:s,gridRowGap:t,gridColumnGap:e,gridAutoColumns:u,gridColumn:i,gridRow:d,gridAutoFlow:l,gridAutoRows:m,gridTemplateRows:p,gridTemplateColumns:n};return R.jsx(c.div,{ref:r,__css:w,...g})});C.displayName="Grid";export{C as G};
-687
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -2,6 +2,7 @@ const targetNode = document.body;
// Options for the observer (which mutations to observe)
const config = { childList: true, subtree: true };
const api_base = window.location.pathname.split("/").slice(0, -1).join("/");
// @ts-ignore
const callback = function (mutationsList, observer) {
@@ -11,7 +12,7 @@ const callback = function (mutationsList, observer) {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === "CANVAS") {
// @ts-ignore
import("/workspace_web/input.js");
import(api_base + "/workspace_web/input.js");
observer.disconnect();
}
});
+57
View File
@@ -0,0 +1,57 @@
const fs = require('fs');
const { exec } = require('child_process');
const path = require('path');
const files = [
{ path: '../.git/info/exclude.dist', content: '/dist' },
{
path: '../.git/hooks/post-checkout',
content: `
#!/bin/sh
# Get the current branch name
BRANCH_NAME=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD)
# Remove previously set exclude files
rm -f .git/info/exclude
# When not in the beta/main branch, add additional files that need to be ignored
if [ "$BRANCH_NAME" != "beta" ] && [ "$BRANCH_NAME" != "main" ]; then
cp .git/info/exclude.dist .git/info/exclude
# elif [ "$BRANCH_NAME" = "xxx" ]; then
# cp .git/info/exclude.xxx .git/info/exclude
fi`,
},
];
function createFile(filePath, content) {
fs.writeFile(filePath, content, (err) => {
if (err) {
console.error(`An error occurred while creating file ${filePath}:`, err);
}
});
}
function executeCommand(command, workingDirectory = '../') {
exec(command, { cwd: path.resolve(__dirname, workingDirectory) }, (error, stdout, stderr) => {
if (error) {
console.error(`An error occurred while executing command "${command}":`, error);
return;
}
if (stderr) {
console.error(`Command error "${stderr}"`);
return;
}
});
}
function main() {
files.forEach((file) => {
createFile(file.path, file.content);
});
executeCommand('git config advice.ignoredHook false');
executeCommand('chmod +x .git/hooks/post-checkout');
console.log('Custom git hooks installation completed')
}
main();
+72
View File
@@ -0,0 +1,72 @@
import server
import os
import folder_paths
import json
import asyncio
from aiohttp import web
from .model_manager.model_preview import get_thumbnail_for_image_file
workspace_path = os.path.join(os.path.dirname(os.path.dirname(__file__)))
comfy_path = os.path.dirname(folder_paths.__file__)
db_dir_path = os.path.join(workspace_path, "db")
DEFAULT_USER = "guest"
@server.PromptServer.instance.routes.post("/workspace/save_db")
async def save_db(request):
# Extract parameters from the request
data = await request.json()
table = data['table']
json_data = data['json']
file_name = f'{db_dir_path}/{table}.json'
# Offload file writing to a separate thread
def write_json_string_to_db(file_name, json_data):
if not os.path.exists(db_dir_path):
os.makedirs(db_dir_path)
# Write the JSON data to the specified file
with open(file_name, 'w') as file:
file.write(json.dumps(json_data, indent=4))
await asyncio.to_thread(write_json_string_to_db, file_name, json_data)
return web.Response(text=f"JSON saved to {file_name}")
def read_table(table):
if not table:
return None
file_name = f'{db_dir_path}/{table}.json'
if not os.path.exists(file_name):
return None
try:
with open(file_name, 'r', encoding='utf-8') as file:
data = json.load(file)
return data
except json.JSONDecodeError as e:
return None
@server.PromptServer.instance.routes.get("/workspace/get_db")
async def get_workspace(request):
# Extract the table parameter from the query string
table = request.query.get('table')
data = await asyncio.to_thread(read_table, table)
return web.json_response(data)
def get_my_workflows_dir():
data = read_table('userSettings')
if (data):
records = json.loads(data)
if DEFAULT_USER in records and 'myWorkflowsDir' in records[DEFAULT_USER]:
curDir = records[DEFAULT_USER]['myWorkflowsDir']
elif 'myWorkflowsDir' in records:
curDir = records['myWorkflowsDir']
# this is to be compatible of a bug that a dict is stored in userSettings.myWorkflowsDir
# should not be needed once all users refresh their settings
if not isinstance(curDir, str):
curDir = curDir.get('path', None)
if curDir and os.path.exists(curDir):
return curDir
return os.path.join(comfy_path, 'my_workflows')
+357
View File
@@ -0,0 +1,357 @@
import asyncio
import folder_paths
from datetime import datetime
import platform
import tempfile
import server
import os
from aiohttp import web
import json
import uuid
from pathlib import Path
from .twoway_sync_folder_service import *
import shutil
from .scan_my_workflows_folder import *
@server.PromptServer.instance.routes.post('/workspace/file/save')
async def save_file(request):
reqJson = await request.json()
data = await asyncio.to_thread(save_file_sync, reqJson)
return web.json_response(data, content_type='application/json')
def save_file_sync(reqJson):
current_path = reqJson.get('path')
new_json_data_str = reqJson.get('json')
current_path = os.path.join(get_my_workflows_dir(), current_path)
try:
new_json_data = json.loads(new_json_data_str)
except json.JSONDecodeError:
return { "error": "New JSON data is not valid JSON."}
expected_id = new_json_data.get('extra', {}).get('workspace_info', {}).get('id', None)
try:
with open(current_path, 'r', encoding='utf-8') as file:
current_json_data = json.load(file)
# Extract the id from the current file's content
current_id = current_json_data.get('extra', {}).get('workspace_info', {}).get('id', None)
# Compare the current id with the expected id
if current_id is not None and current_id != expected_id:
return {"error": "Mismatching workspace_info.id."}
except FileNotFoundError:
return {"error": f"File not found in path {current_path}"}
except Exception as e:
return { "error": str(e)}
# If checks pass, write the new JSON data to the file
try:
with open(current_path, 'w', encoding='utf-8') as file:
file.write(new_json_data_str)
return {"success": True}
except Exception as e:
return { "error": str(e)}
@server.PromptServer.instance.routes.post('/workspace/file/rename')
async def rename_file(request):
reqJson = await request.json()
data = await asyncio.to_thread(rename_file_sync, reqJson)
return web.json_response(data, content_type='application/json')
def rename_file_sync(reqJson):
current_path = Path(get_my_workflows_dir()) / reqJson['path']
new_name = reqJson.get('newName')
try:
new_path = current_path.parent / new_name
Path(current_path).rename(new_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@server.PromptServer.instance.routes.post('/workspace/file/move')
async def move_file(request):
reqJson = await request.json()
data = await asyncio.to_thread(move_file_sync, reqJson)
return web.json_response(data, content_type='application/json')
def move_file_sync(reqJson):
current_path = reqJson.get('path')
new_path = reqJson.get('newParentPath')
current_path = os.path.join(get_my_workflows_dir(), current_path)
new_path = os.path.join(get_my_workflows_dir(), new_path)
try:
shutil.move(current_path, new_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@server.PromptServer.instance.routes.post('/workspace/create_workflow_file')
async def create_workflow_file(request):
reqJson = await request.json()
data = await asyncio.to_thread(create_workflow_file, reqJson)
return web.json_response(data, content_type='application/json')
def create_workflow_file(reqJson):
try:
parentFolderPath = reqJson.get('parentFolderPath')
name = reqJson.get('name')
workflow_json = reqJson.get('json')
if not name or workflow_json is None:
print('🔴Create workflow missing necessary data')
return {} # Missing necessary data
abs_path = Path(get_my_workflows_dir()) / parentFolderPath
# Create the directory if it does not exist
abs_path.mkdir(parents=True, exist_ok=True)
# Ensuring the file name is unique
base_name, extension = os.path.splitext(name)
if extension != '.json': # Ensure the extension is .json
extension = '.json'
new_file_name = base_name + extension
counter = 1
while (abs_path / new_file_name).exists():
new_file_name = f"{base_name}_{counter}{extension}"
counter += 1
new_file_path = abs_path / new_file_name
# Writing JSON data to the file
with open(new_file_path, 'w', encoding='utf-8') as file:
file.write(workflow_json)
new_base_name,ext = os.path.splitext(new_file_name)
return {"name": new_base_name}
except Exception as e:
print(f"Failed to create workflow file: {e}")
return {} # In case of any error
def read_workflow_file(path, id):
abs_path = os.path.join(get_my_workflows_dir(), path)
create_time, update_time = getFileCreateTime(abs_path)
with open(abs_path, 'r', encoding='utf-8') as f:
json_data = json.load(f)
if 'extra' in json_data and 'workspace_info' in json_data['extra'] and 'id' in json_data['extra']['workspace_info']:
workflow_id = json_data['extra']['workspace_info']['id']
if workflow_id == id:
return {"json": json_data, "createTime": create_time, "updateTime": update_time}
return {"error": "Workflow ID doesn't match"}
return {"error": "No workspace_info.id found in the file"}
def check_and_update_workflow_id(file_path, seen_ids):
with open(file_path, "r", encoding="utf-8") as f:
json_data = json.load(f)
if (
"extra" in json_data
and "workspace_info" in json_data["extra"]
and "id" in json_data["extra"]["workspace_info"]
):
workflow_id = json_data["extra"]["workspace_info"]["id"]
create_time, update_time = getFileCreateTime(file_path)
if workflow_id in seen_ids:
if create_time > seen_ids[workflow_id]["time"]:
# Update the current file
json_data["extra"]["workspace_info"]["id"] = str(uuid.uuid4())
with open(file_path, "w", encoding="utf-8") as f:
json.dump(json_data, f)
else:
# Update the previously seen file
old_file_path = seen_ids[workflow_id]["path"]
with open(old_file_path, "r", encoding="utf-8") as f:
old_json_data = json.load(f)
old_json_data["extra"]["workspace_info"]["id"] = str(uuid.uuid4())
with open(old_file_path, "w", encoding="utf-8") as f:
json.dump(old_json_data, f)
seen_ids[workflow_id] = {
"time": create_time,
"path": file_path,
}
def dedupe_workflow_ids():
abs_path = get_my_workflows_dir()
seen_ids = {}
for root, dirs, files in os.walk(abs_path):
for file in files:
if not file.endswith(".json"):
continue
file_path = os.path.join(root, file)
check_and_update_workflow_id(file_path, seen_ids)
@server.PromptServer.instance.routes.get('/workspace/deduplicate_workflow_ids')
async def deduplicate_workflow_ids(request):
await asyncio.to_thread(dedupe_workflow_ids)
return web.json_response(text="success", content_type='application/json')
@server.PromptServer.instance.routes.post('/workspace/get_workflow_file')
async def get_workflow_file(request):
reqJson = await request.json()
path = reqJson.get('path')
id = reqJson.get('id')
data = await asyncio.to_thread(read_workflow_file, path, id)
return web.json_response(data, content_type='application/json')
# Scan .json and folders in the given path
@server.PromptServer.instance.routes.post("/workspace/scan_my_workflows_files")
async def scan_local_new_files(request):
reqJson = await request.json()
path = reqJson['path']
fileList = await asyncio.to_thread(folder_handle, path)
return web.Response(text=json.dumps(fileList), content_type='application/json')
def folder_handle(path):
fileList = []
for item in os.listdir(path):
try:
item_path = os.path.join(path, item)
if os.path.isfile(item_path) and item_path.endswith('.json'):
with open(item_path, 'r', encoding='utf-8') as f:
file_handle(item, f, fileList, item_path)
elif os.path.isdir(item_path):
createTime, updateTime = getFileCreateTime(item_path)
fileList.append({
'name': item,
'type': 'folder',
'createTime': createTime,
'updateTime': updateTime
})
except Exception as e:
print(f"Error scanning file {item}: {e}")
return fileList
def file_handle(name, file, fileList, file_path):
json_data = json.load(file)
createTime, updateTime = getFileCreateTime(file_path)
if 'extra' in json_data and 'workspace_info' in json_data['extra'] and 'id' in json_data['extra']['workspace_info']:
workflow_id = json_data['extra']['workspace_info']['id']
else:
# If ID does not exist, generate a new UUID and add it to the JSON data
new_id = str(uuid.uuid4())
json_data.setdefault('extra', {}).setdefault('workspace_info', {})['id'] = new_id
with open(file_path, 'w', encoding='utf-8') as file:
json.dump(json_data, file, ensure_ascii=False, indent=4)
fileInfo = {
'json': json.dumps(json_data),
'name': name,
'type': "workflow",
'id': json_data['extra']['workspace_info']['id'],
'createTime': createTime,
'updateTime': updateTime
}
fileList.append(fileInfo)
@server.PromptServer.instance.routes.post('/workspace/file/gen_unique_name')
async def generate_unique_file_name(request):
reqJson = await request.json()
data = await asyncio.to_thread(generate_unique_file_name_sync, reqJson)
return web.json_response(data, content_type='application/json')
def generate_unique_file_name_sync(reqJson):
file_path = reqJson.get('path')
if not file_path:
return {"success": False, "error": "File path is required"}
path = Path(get_my_workflows_dir()) / file_path
if not path.parent.is_dir():
return {"success": False, "error": "Directory of the provided path does not exist"}
original_name = path.stem
extension = path.suffix
directory = path.parent
counter = 1
new_name = f"{original_name}{extension}"
while (directory / new_name).exists():
new_name = f"{original_name}{counter}{extension}"
counter += 1
return {"success": True, "uniqueName": new_name}
@server.PromptServer.instance.routes.post('/workspace/file/count_files')
async def count_files(request):
reqJson = await request.json()
data = await asyncio.to_thread(count_files_sync, reqJson)
return web.json_response(data, content_type='application/json')
def count_files_sync(reqJson):
directory_path = reqJson.get('path')
if not directory_path:
return {"success": False, "error": "Directory path is required"}
path = Path(get_my_workflows_dir()) / directory_path
if not path.is_dir():
return {"success": False, "error": "Provided path is not a directory"}
file_count = 0
for file in path.rglob('*.json'): # Only consider .json files
if file.is_file():
file_count += 1
return {"success": True, "count": file_count}
# Begin: Copy all files to the new directory when switching the save directory
@server.PromptServer.instance.routes.post('/workspace/copy_json_files')
async def copy_json_files(request):
reqJson = await request.json()
src_dir = reqJson['src']
dest_dir = reqJson['dst']
try:
for item in os.listdir(src_dir):
src_item_path = os.path.join(src_dir, item)
dest_item_path = os.path.join(dest_dir, item)
if os.path.isdir(src_item_path):
dest_item_path = await get_unique_dir_name(dest_item_path)
await asyncio.to_thread(shutil.copytree, src_item_path, dest_item_path)
elif item.endswith('.json'):
dest_item_path = await get_unique_file_name(dest_item_path)
await asyncio.to_thread(shutil.copy2, src_item_path, dest_item_path)
return web.json_response({"success": True}, content_type='application/json')
except Exception as e:
return web.json_response({"success": False, "error": str(e)}, content_type='application/json')
async def get_unique_file_name(path):
base, extension = os.path.splitext(path)
counter = 1
while os.path.exists(path):
path = f"{base}_{counter}{extension}"
counter += 1
return path
async def get_unique_dir_name(path):
original_path = path
counter = 1
while os.path.exists(path):
path = f"{original_path}_{counter}"
counter += 1
return path
# End: Copy all files to the new directory when switching the save directory
@server.PromptServer.instance.routes.post('/workspace/images/save')
async def save_images(request):
reader = await request.multipart()
subfolder = "workspace_manager" # 默认子文件夹,实际上你可能会从表单中动态获取
output_path = folder_paths.get_output_directory()
safe_dir = Path(os.path.join(output_path, subfolder))
if not safe_dir.exists():
safe_dir.mkdir(parents=True, exist_ok=True)
# 用于存储保存的文件路径
files_saved = []
# 处理multipart/form-data中的每一部分
async for part in reader:
if part.name.startswith('images'):
filename = part.filename
if not filename:
continue
file_path = safe_dir / filename
loop = asyncio.get_running_loop()
# 异步写入文件
with open(file_path, 'wb') as f:
while True:
chunk = await part.read_chunk() # 默认8192 bytes
if not chunk:
break
await loop.run_in_executor(None, f.write, chunk)
files_saved.append(str(file_path))
return web.Response(text=json.dumps({"imgPaths": files_saved}), content_type='application/json')
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import json
import server
from aiohttp import FormData, web, ClientSession
import folder_paths
import os
from .model_manager.model_preview import get_thumbnail_for_image_file
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
video_extensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']
API_URL = 'https://www.nodecafe.org/api/image/upload'
def view_media(filename, isPreview = False, isInput = False):
if not filename:
return web.Response(status=404)
# validation for security: prevent accessing arbitrary path
if filename[0] == '/' or '..' in filename:
return web.Response(status=400)
path = folder_paths.get_input_directory() if isInput else folder_paths.get_output_directory()
file_path = os.path.join(path, filename)
if not os.path.exists(file_path):
return web.Response(status=404)
file_extension = os.path.splitext(filename)[1].lower()
if isPreview and file_extension in image_extensions:
return web.json_response(
body=get_thumbnail_for_image_file(file_path),
content_type='image/jpeg',
headers={"Content-Disposition": f"filename=\"{filename}.jpg\""}
)
with open(file_path, 'rb') as f:
media_file = f.read()
content_type = 'application/json'
if file_extension in image_extensions:
content_type = f'image/{file_extension[1:]}'
if file_extension in video_extensions:
content_type = f'video/{file_extension[1:]}'
return web.Response(
body=media_file,
content_type=content_type,
headers={"Content-Disposition": f"filename=\"{filename}\""}
)
@server.PromptServer.instance.routes.get("/workspace/preview_media")
async def preview_file(request):
filename = request.query.get("filename", None)
return await asyncio.to_thread(view_media, filename, True)
@server.PromptServer.instance.routes.get("/workspace/view_media")
async def view_file(request):
filename = request.query.get("filename", None)
isPreview = request.query.get("isPreview", False)
isInput = request.query.get("isInput", False)
return await asyncio.to_thread(view_media, filename, isPreview, isInput)
@server.PromptServer.instance.routes.post("/workspace/upload_image") # Handle POST requests
async def upload_image_handler(request):
data = await request.json()
image_paths = data.get("images")
if not image_paths:
return web.Response(status=400, text="Images parameter is required and should be a list of image paths.")
# Initialize FormData for multipart upload
multipart_data = FormData()
files_not_found = []
# Iterate over image paths to add them to FormData
for image_path in image_paths:
abs_path = os.path.join(folder_paths.get_input_directory(), image_path)
if not os.path.exists(abs_path):
files_not_found.append(image_path)
continue # Skip this file and proceed with the next
with open(abs_path, 'rb') as img:
multipart_data.add_field('file', img.read(),
filename=os.path.basename(image_path),
content_type='application/octet-stream') # Adjust MIME type as necessary
# Handle case where one or more files were not found
if files_not_found:
return web.Response(status=404, text=f"Image file(s) not found: {', '.join(files_not_found)}")
try:
async with ClientSession() as session:
async with session.post(API_URL, data=multipart_data) as response:
if response.status != 200:
text = await response.text()
return web.Response(status=response.status, text=text)
result = await response.json()
return web.Response(text=json.dumps(result), content_type='application/json')
except Exception as e:
return web.Response(status=500, text=f"Error forwarding the images to the Next.js API: {e}")
+142
View File
@@ -0,0 +1,142 @@
import asyncio
import json
import server
from aiohttp import web
import nodes
import ast
import inspect
async def validate_prompt_input_in_list():
obj_class = nodes.NODE_CLASS_MAPPINGS[class_type]
class_inputs = obj_class.INPUT_TYPES()
required_inputs = class_inputs['required']
errors = []
valid = True
validate_function_inputs = []
if hasattr(obj_class, "VALIDATE_INPUTS"):
validate_function_inputs = inspect.getfullargspec(obj_class.VALIDATE_INPUTS).args
for x in required_inputs:
val = inputs[x]
info = required_inputs[x]
type_input = info[0]
if not isinstance(val, list):
if x not in validate_function_inputs:
if isinstance(type_input, list):
if val not in type_input:
input_config = info
list_info = ""
# Don't send back gigantic lists like if they're lots of
# scanned model filepaths
if len(type_input) > 20:
list_info = f"(list of length {len(type_input)})"
input_config = None
else:
list_info = str(type_input)
error = {
"type": "value_not_in_list",
"message": "Value not in list",
"details": f"{x}: '{val}' not in {list_info}",
"extra_info": {
"input_name": x,
"input_config": input_config,
"received_value": val,
}
}
errors.append(error)
continue
@server.PromptServer.instance.routes.post("/model_manager/find_missing_models")
async def find_missing_models(request):
json_data = await request.json()
workflow = json_data['workflow']
# def missing_nodes_in_workflow():
models_nodes = {}
for node in workflow['nodes']:
try:
node_type = node['type']
models_dir= extract_folder_paths_arguments_with_key(node_type)
if(models_dir):
models_nodes[node['id']] = {
'type': node_type,
'models_dir': models_dir
}
print('find_missing_models node_type', node_type, models_dir)
except Exception as e:
print('find_missing_models exception', e)
continue
# await asyncio.to_thread(missing_nodes_in_workflow, file_name, json_data)
# return web.Response(json=models_nodes)
return web.Response(text=json.dumps(models_nodes), status=500)
def extract_folder_paths_argument(node_type: str):
class_def = nodes.NODE_CLASS_MAPPINGS[node_type]
source = inspect.getsource(class_def)
tree = ast.parse(source)
for node in ast.walk(tree):
# Check for the INPUT_TYPES method
if isinstance(node, ast.FunctionDef) and node.name == 'INPUT_TYPES':
for sub_node in ast.walk(node):
# Check for a call to folder_paths.get_filename_list
if isinstance(sub_node, ast.Call) and hasattr(sub_node.func, 'attr'):
if sub_node.func.attr == 'get_filename_list':
# Extract the argument passed to the function
if sub_node.args:
return sub_node.args[0].s # Assuming it's a string argument
return None
def extract_folder_paths_arguments_with_key(node_type: str):
class_def = nodes.NODE_CLASS_MAPPINGS[node_type]
source = inspect.getsource(class_def)
tree = ast.parse(source)
arguments = {}
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == 'INPUT_TYPES':
for sub_node in ast.walk(node):
# Check if the node is a dictionary
if isinstance(sub_node, ast.Dict):
for key, value in zip(sub_node.keys, sub_node.values):
# Check if the value is a call to folder_paths.get_filename_list
if isinstance(value, ast.Tuple) and value.elts:
call_node = value.elts[0]
if isinstance(call_node, ast.Call) and hasattr(call_node.func, 'attr'):
if call_node.func.attr == 'get_filename_list':
if call_node.args:
key_name = key.s # Extract the key name
# print('key_name', key_name)
folder_name = call_node.args[0].s # Extract the argument
# print('folder_name', folder_name)
arguments[key_name] = folder_name
return arguments
def find_folder_paths_arguments(class_def):
source = inspect.getsource(class_def)
tree = ast.parse(source)
arguments = {}
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == 'INPUT_TYPES':
for sub_node in ast.walk(node):
if isinstance(sub_node, ast.Call) and hasattr(sub_node.func, 'attr'):
if sub_node.func.attr == 'get_filename_list':
if sub_node.args:
# This will get the name of the key under which the call is made
parent = next(ast.iter_child_nodes(sub_node.parent))
if isinstance(parent, ast.Str):
key_name = parent.s
print('key_name', key_name)
arguments[key_name] = sub_node.args[0].s
print('arguments[key_name]', arguments[key_name])
return arguments
+156 -100
View File
@@ -6,14 +6,21 @@ import sys
from aiohttp import web
import folder_paths
import urllib.request
import threading
import server
import server
import time
from .missing_models import find_missing_models # type: ignore noqa
from .model_list import start_populate_file_hash_dict, save_file_hash
comfy_path = os.path.dirname(folder_paths.__file__)
def download_url_with_wget(url, save_path):
print(f"Downloading {url} to {save_path} ...")
if not is_wget_installed():
print("wget is not installed. Please install wget or use a different download method.")
print(
"wget is not installed. Please install wget or use a different download method."
)
return False
try:
@@ -23,7 +30,7 @@ def download_url_with_wget(url, save_path):
while True:
output = process.stderr.readline()
if process.poll() is not None and output == b'':
if process.poll() is not None and output == b"":
break
if output:
progress = parse_wget_output(output.decode())
@@ -41,156 +48,205 @@ def download_url_with_wget(url, save_path):
print("\nDownload completed successfully.")
return True
def is_wget_installed():
try:
subprocess.run("wget --version", shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
"wget --version",
shell=True,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return True
except subprocess.CalledProcessError:
return False
def parse_wget_output(output):
"""
Parse wget output to extract and return the download progress.
"""
match = re.search(r'\d+%|\d+K \d+Kb/s', output)
match = re.search(r"\d+%|\d+K \d+Kb/s", output)
return match.group(0) if match else ""
from concurrent.futures import ThreadPoolExecutor
import asyncio
# Create a dictionary to store download tasks
download_tasks = []
# Create a lock object to synchronize access to the dictionary
download_tasks_lock = threading.Lock()
# Create a thread that will execute the download tasks
download_thread = None
# Create a variable to store the path of the download that needs to be canceled
cancel_path = None
def download_worker():
while True:
task = None
with download_tasks_lock:
# Get a task from the list
if download_tasks:
task = download_tasks.pop(0)
if task is not None:
# Execute the download task and update the download progress
download_url_with_agent(
url=task["url"],
save_path=task["save_path"],
file_name=task["filename"],
file_hash=task.get("file_hash"),
force_filename=task.get("force_filename", False),
)
# calculate newly downloaded models' hash
start_populate_file_hash_dict()
else:
# No more tasks, break the loop
send_ws("download_progress", [])
break
@server.PromptServer.instance.routes.post("/model_manager/install_model")
async def install_model(request):
global download_thread
json_data = await request.json()
url = json_data['url']
save_path = get_model_path(json_data)
with download_tasks_lock:
# Add the task to the list
download_tasks.append(json_data)
# Define the wrapper function for the executor
def download_wrapper():
return download_url_with_agent(url, save_path)
# If the previous thread is not active, start a new one
if download_thread is None or not download_thread.is_alive():
download_thread = threading.Thread(target=download_worker)
download_thread.daemon = True
download_thread.start()
# Run the download function in a separate thread
with ThreadPoolExecutor() as executor:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(executor, download_wrapper)
return web.Response(text=f"Download complete. {save_path}" if result else "Download failed.")
return web.Response(
text=f"Downloading {json_data['url']} to {json_data['save_path']} ..."
)
@server.PromptServer.instance.routes.post("/model_manager/install_model_stream")
async def install_model_stream(request):
@server.PromptServer.instance.routes.post("/model_manager/cancel_installation")
async def cancel_installation(request):
global download_thread, cancel_path
json_data = await request.json()
model_path = get_model_path(json_data)
save_path = json_data["save_path"]
is_canceled = False
with download_tasks_lock:
# Remove the task from the list
for task in download_tasks:
if task["filename"] == save_path:
download_tasks.remove(task)
is_canceled = True
break
print(download_tasks)
response = web.StreamResponse()
response.headers['Content-Type'] = 'text/plain'
await response.prepare(request)
print(f"🖌️Model Manager: Installing model '{json_data['name']}' into '{model_path}' ...")
async def progress_callback(progress):
progress_message = f"Installing {json_data['name']}: {progress:.2f}%\n"
await response.write(progress_message.encode('utf-8'))
# if the download is not in task list, the download is in progress, cancel it
if not is_canceled:
cancel_path = save_path
try:
if model_path is not None:
result = await download_url_with_agent(json_data['url'], model_path, progress_callback)
if result:
await response.write(b"Installation complete.\n")
else:
await response.write(b"Installation failed.\n")
else:
print(f"Model installation error: invalid model type - {json_data['type']}")
await response.write(b"Installation failed: Invalid model type.\n")
return web.Response(text=f"Canceled download to {save_path} ...")
except Exception as e:
print(f"[ERROR] {e}", file=sys.stderr)
await response.write(f"Installation failed: {e}\n".encode('utf-8'))
await response.write_eof()
return response
def download_url_with_agent(url, save_path, progress_callback=None):
print('download_url_with_agent', url, save_path)
temp_save_path = save_path + ".temp" # Temporary file
def download_url_with_agent(url, save_path, file_name, file_hash, force_filename=False):
print("download_url_with_agent", url, save_path)
global cancel_path
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}
print(f"Downloading {url} to {temp_save_path} ...")
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
}
print(f"Downloading {url} to {save_path} ...")
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req) as response:
file_size = int(response.headers.get('content-length', 0))
if response.status != 200:
raise Exception(f"Request returned status code {response.status}")
file_size = int(response.headers.get("content-length", 0))
print(f"File size: {file_size} bytes")
content_disposition = response.headers.get("Content-Disposition")
if not force_filename and content_disposition:
pattern = re.compile(r'filename="([^"]+)"')
match = pattern.search(content_disposition)
if match:
file_name = match.group(1)
file_path = os.path.join(get_model_dir(save_path), file_name)
if file_hash is not None:
save_file_hash(file_path, file_hash)
temp_file_path = file_path + ".temp" # Temporary file
chunk_size = 1024 # 1KB per chunk
downloaded = 0
if not os.path.exists(os.path.dirname(temp_save_path)):
os.makedirs(os.path.dirname(temp_save_path))
if file_size == 0:
html = response.read().decode()
title = re.search(r"<title>(.*?)</title>", html)[0]
if "Sign in" in title:
raise ValueError("You need to add API key to download this model.")
else:
raise ValueError(f"Download failed. {title}")
with open(temp_save_path, 'wb') as f:
if not os.path.exists(os.path.dirname(temp_file_path)):
os.makedirs(os.path.dirname(temp_file_path))
with open(temp_file_path, "wb") as f:
while True:
if cancel_path == file_path:
break
chunk = response.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
progress = (downloaded / file_size) * 100
print(f'\rProgress: {progress:.2f}%', end='')
print(f"\rProgress: {progress:.2f}%", end="")
send_download_status({"save_path": file_path, "progress": progress})
# if progress_callback:
# progress_callback(progress)
if cancel_path == file_path:
print(f"\nDownload to {save_path} is canceled.")
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
cancel_path = None
return False
# Rename temp file to final filename after successful download
os.rename(temp_save_path, save_path)
os.rename(temp_file_path, file_path)
except Exception as e:
print(f"\nDownload error: {url} / {e}", file=sys.stderr)
if os.path.exists(temp_save_path):
os.remove(temp_save_path) # Clean up the temporary file in case of failure
send_ws("download_error", f"{url} / {e}")
if os.path.exists(temp_file_path):
os.remove(temp_file_path) # Clean up the temporary file in case of failure
return False
print("\nDownload complete. File saved as:", save_path)
return True
def get_model_dir(data):
if data['save_path'] != 'default':
if '..' in data['save_path'] or data['save_path'].startswith('/'):
print(f"[WARN] '{data['save_path']}' is not allowed path. So it will be saved into 'models/etc'.")
base_model = "etc"
else:
if data['save_path'].startswith("custom_nodes"):
base_model = os.path.join(comfy_path, data['save_path'])
else:
base_model = os.path.join(folder_paths.models_dir, data['save_path'])
def get_model_dir(save_path):
if os.path.isabs(save_path):
return save_path
else:
model_type = data['type']
if model_type == "checkpoints":
base_model = folder_paths.folder_names_and_paths["checkpoints"][0][0]
elif model_type == "unclip":
base_model = folder_paths.folder_names_and_paths["checkpoints"][0][0]
elif model_type == "VAE":
base_model = folder_paths.folder_names_and_paths["vae"][0][0]
elif model_type == "lora":
base_model = folder_paths.folder_names_and_paths["loras"][0][0]
elif model_type == "T2I-Adapter":
base_model = folder_paths.folder_names_and_paths["controlnet"][0][0]
elif model_type == "T2I-Style":
base_model = folder_paths.folder_names_and_paths["controlnet"][0][0]
elif model_type == "controlnet":
base_model = folder_paths.folder_names_and_paths["controlnet"][0][0]
elif model_type == "clip_vision":
base_model = folder_paths.folder_names_and_paths["clip_vision"][0][0]
elif model_type == "gligen":
base_model = folder_paths.folder_names_and_paths["gligen"][0][0]
elif model_type == "upscale":
base_model = folder_paths.folder_names_and_paths["upscale_models"][0][0]
elif model_type == "embeddings":
base_model = folder_paths.folder_names_and_paths["embeddings"][0][0]
else:
base_model = "etc"
return base_model
return folder_paths.folder_names_and_paths[save_path][0][0]
def get_model_path(data):
base_model = get_model_dir(data)
return os.path.join(base_model, data['filename'])
last_call_time = 0
def send_download_status(data):
global last_call_time
current_time = time.time()
if current_time - last_call_time < 0.1:
return
last_call_time = time.time()
with download_tasks_lock:
progress_list = [data]
for task in download_tasks:
progress_list.append({"save_path": task["filename"], "progress": 0})
send_ws("download_progress", progress_list)
loop = asyncio.get_event_loop()
def send_ws(event, data):
asyncio.run_coroutine_threadsafe(
server.PromptServer.instance.send(event, data), loop
)
+145
View File
@@ -0,0 +1,145 @@
import asyncio
import os
import folder_paths
import server
from aiohttp import web
import hashlib
import shelve
import threading
import os
import urllib.request
import json
from .model_preview import preview_file
# The path to the file where file_hash_dict will be saved
FILE_HASH_DICT_FOLDER_PATH = os.path.join(os.path.dirname(__file__), "../../hash")
FILE_HASH_DICT_PATH = os.path.join(FILE_HASH_DICT_FOLDER_PATH, "file_hash_dict")
if not os.path.exists(FILE_HASH_DICT_FOLDER_PATH):
os.makedirs(FILE_HASH_DICT_FOLDER_PATH)
# Early open shelve for better performance
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
print(f"Workspace manager - Openning file hash dict")
file_list = []
file_list_lock = threading.Lock()
# Add a global variable to track if populate_file_hash_dict is done
is_populating = False
def save_file_hash(file_path, file_hash):
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
file_hash_dict[file_path] = file_hash
def compute_hash(file_path):
# sha256 hash of the file
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
# Read and update hash string value in blocks of 4K
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def process_file(folder, file):
file_path = folder_paths.get_full_path(folder, file)
# check if the file hash is already calculated
file_hash = None
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
if file_path in file_hash_dict:
file_hash = file_hash_dict[file_path]
[model_name, model_extension] = os.path.splitext(file)
return {
"model_name": model_name,
"model_type": folder,
"model_extension": model_extension,
"file_hash": file_hash,
"preview": preview_file(file_path),
"date": os.path.getmtime(file_path),
}
def populate_file_hash_dict():
global is_populating, file_list
local_file_list = []
folder_list = dict(folder_paths.folder_names_and_paths)
for folder in folder_list:
if folder == "configs" or folder == "custom_nodes":
continue
files = folder_paths.get_filename_list(folder)
for file in files:
try:
local_file_list.append(process_file(folder, file))
except Exception as e:
print(f"Error processing file: {file} in folder: {folder}. Error: {e}")
with file_list_lock:
file_list = local_file_list
send_ws("model_list", file_list) # send the list without hash to the client
# After all files are added, calculate hashes for those that don't have it yet
for file in file_list:
try:
if file["file_hash"] is None:
file_path = folder_paths.get_full_path(
file["model_type"], file["model_name"] + file["model_extension"]
)
file_hash = compute_hash(file_path)
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
file_hash_dict[file_path] = file_hash
with file_list_lock:
file["file_hash"] = file_hash
send_ws("model_list", file_list) # update the list with the new hash
except Exception as e:
print(f"Error calculating hash for file: {file}. Error: {e}")
# Set populate_done to True when done
is_populating = False
def civit_get_model_by_hash(file_hash: str):
if file_hash is None:
return None
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
}
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
req = urllib.request.Request(url, headers=headers)
# Making a GET request
with urllib.request.urlopen(req) as response:
data = response.read()
encoding = response.info().get_content_charset("utf-8")
result = json.loads(data.decode(encoding))
return result
except Exception as e:
return None
def start_populate_file_hash_dict():
global populate_thread, is_populating
if is_populating:
return
is_populating = True
populate_thread = threading.Thread(target=populate_file_hash_dict)
populate_thread.daemon = True
populate_thread.start()
@server.PromptServer.instance.routes.get("/model_manager/get_model_list")
def get_model_list(request):
start_populate_file_hash_dict()
with file_list_lock:
return web.json_response(file_list, content_type="application/json")
@server.PromptServer.instance.routes.get("/model_manager/get_folder_list")
def get_folder_list(request):
dirList = {k: v[0] for k, v in folder_paths.folder_names_and_paths.items()}
return web.json_response(dirList, content_type="application/json")
loop = asyncio.get_event_loop()
def send_ws(event, data):
asyncio.run_coroutine_threadsafe(
server.PromptServer.instance.send(event, data), loop
)
+46
View File
@@ -0,0 +1,46 @@
import os
from PIL import Image
import base64
from io import BytesIO
from pathlib import Path
MAX_IMAGE_SIZE = 250
def preview_file(filename: str):
preview_exts = [".jpg", ".png", ".jpeg", ".gif"]
preview_exts = [*preview_exts, *[".preview" + x for x in preview_exts]]
for ext in preview_exts:
try:
pathStr = os.path.splitext(filename)[0] + ext
path = Path(pathStr).resolve()
if os.path.exists(path):
# because ComfyUI has extra model path feature
# the path might not be relative to the ComfyUI root
# so instead of returning the path, we return the image data directly, to avoid security issues
bytes = get_thumbnail_for_image_file(path)
# Get the base64 string
img_base64 = base64.b64encode(bytes).decode()
# Return the base64 string
return f"data:image/jpeg;base64, {img_base64}"
except Exception as e:
print(f"Error opening image preview: {e}")
return None
def get_thumbnail_for_image_file(file_path: Path):
try:
with Image.open(file_path) as img:
# If the image is too large, resize it
if img.width > MAX_IMAGE_SIZE and img.height > MAX_IMAGE_SIZE:
# Calculate new width to maintain aspect ratio
width = int(img.width * MAX_IMAGE_SIZE / img.height)
# Resize the image
img = img.resize((width, MAX_IMAGE_SIZE))
img = img.convert("RGB")
# Save the image to a BytesIO object
buffer = BytesIO()
img.save(buffer, format="JPEG", quality=85)
return buffer.getvalue()
except Exception as e:
print(f"Error opening image preview: {e}")
return None
+192
View File
@@ -0,0 +1,192 @@
import inspect
import json
import os
import subprocess
from nodes import NODE_CLASS_MAPPINGS
import server
from aiohttp import web
BUILT_IN_NODE_TYPES = {
"BasicScheduler",
"CLIPLoader",
"CLIPMergeSimple",
"CLIPSave",
"CLIPSetLastLayer",
"CLIPTextEncode",
"CLIPTextEncodeSDXL",
"CLIPTextEncodeSDXLRefiner",
"CLIPVisionEncode",
"CLIPVisionLoader",
"Canny",
"CheckpointLoader",
"CheckpointLoaderSimple",
"CheckpointSave",
"ConditioningAverage",
"ConditioningCombine",
"ConditioningConcat",
"ConditioningSetArea",
"ConditioningSetAreaPercentage",
"ConditioningSetAreaStrength",
"ConditioningSetMask",
"ConditioningSetTimestepRange",
"ConditioningZeroOut",
"ControlNetApply",
"ControlNetApplyAdvanced",
"ControlNetLoader",
"CropMask",
"DiffControlNetLoader",
"DiffusersLoader",
"DualCLIPLoader",
"EmptyImage",
"EmptyLatentImage",
"ExponentialScheduler",
"FeatherMask",
"FlipSigmas",
"FreeU",
"FreeU_V2",
"GLIGENLoader",
"GLIGENTextBoxApply",
"GrowMask",
"HyperTile",
"HypernetworkLoader",
"ImageBatch",
"ImageBlend",
"ImageBlur",
"ImageColorToMask",
"ImageCompositeMasked",
"ImageCrop",
"ImageInvert",
"ImageOnlyCheckpointLoader",
"ImageOnlyCheckpointSave",
"ImagePadForOutpaint",
"ImageQuantize",
"ImageScale",
"ImageScaleBy",
"ImageScaleToTotalPixels",
"ImageSharpen",
"ImageToMask",
"ImageUpscaleWithModel",
"InpaintModelConditioning",
"InvertMask",
"JoinImageWithAlpha",
"KSampler",
"KSamplerAdvanced",
"KSamplerSelect",
"KarrasScheduler",
"LatentAdd",
"LatentBatch",
"LatentBatchSeedBehavior",
"LatentBlend",
"LatentComposite",
"LatentCompositeMasked",
"LatentCrop",
"LatentFlip",
"LatentFromBatch",
"LatentInterpolate",
"LatentMultiply",
"LatentRotate",
"LatentSubtract",
"LatentUpscale",
"LatentUpscaleBy",
"LoadImage",
"LoadImageMask",
"LoadLatent",
"LoraLoader",
"LoraLoaderModelOnly",
"MaskComposite",
"MaskToImage",
"ModelMergeAdd",
"ModelMergeBlocks",
"ModelMergeSimple",
"ModelMergeSubtract",
"ModelSamplingContinuousEDM",
"ModelSamplingDiscrete",
"PatchModelAddDownscale",
"PerpNeg",
"PhotoMakerEncode",
"PhotoMakerLoader",
"PolyexponentialScheduler",
"PorterDuffImageComposite",
"PreviewImage",
"RebatchImages",
"RebatchLatents",
"RepeatImageBatch",
"RepeatLatentBatch",
"RescaleCFG",
"SDTurboScheduler",
"SD_4XUpscale_Conditioning",
"SVD_img2vid_Conditioning",
"SamplerCustom",
"SamplerDPMPP_2M_SDE",
"SamplerDPMPP_SDE",
"SaveAnimatedPNG",
"SaveAnimatedWEBP",
"SaveImage",
"SaveLatent",
"SelfAttentionGuidance",
"SetLatentNoiseMask",
"SolidMask",
"SplitImageWithAlpha",
"SplitSigmas",
"StableZero123_Conditioning",
"StableZero123_Conditioning_Batched",
"StyleModelApply",
"StyleModelLoader",
"TomePatchModel",
"UNETLoader",
"UpscaleModelLoader",
"VAEDecode",
"VAEDecodeTiled",
"VAEEncode",
"VAEEncodeForInpaint",
"VAEEncodeTiled",
"VAELoader",
"VAESave",
"VPScheduler",
"VideoLinearCFGGuidance",
"unCLIPCheckpointLoader",
"unCLIPConditioning"
}
def get_git_repo(node_type: str):
if node_type not in NODE_CLASS_MAPPINGS:
return None
if node_type in BUILT_IN_NODE_TYPES:
return None
cls = NODE_CLASS_MAPPINGS[node_type]
source_file = inspect.getfile(cls)
directory = os.path.dirname(source_file)
# Attempt to get the remote repository URL directly
try:
git_repo = subprocess.check_output(["git", "-C", directory, "config", "--get", "remote.origin.url"], text=True).strip()
except subprocess.CalledProcessError:
return None
# Attempt to get the current commit hash
try:
commit_hash = subprocess.check_output(["git", "-C", directory, "rev-parse", "HEAD"], text=True).strip()
except subprocess.CalledProcessError:
return None
if git_repo.endswith(".git"):
git_repo = git_repo[:-4]
username = git_repo.split("/")[-2]
repo_name = git_repo.split("/")[-1]
return {"gitRepo": f"{username}/{repo_name}", "commitHash": commit_hash}
@server.PromptServer.instance.routes.post("/workspace/fetch_node_repos") # Handle POST requests
async def fetch_node_repos(request):
data = await request.json()
nodetypes = data.get("nodes")
if not nodetypes:
return web.Response(status=400, text="NodeTypes parameter is required and should be a list of node types.")
repos_mapping = {}
for nodetype in nodetypes:
try:
repo = get_git_repo(nodetype)
if repo:
repos_mapping[nodetype] = repo
except Exception as e:
print(f"Error fetching repo for {nodetype}: {e}")
return web.Response(text=json.dumps(repos_mapping), content_type='application/json')
+86
View File
@@ -0,0 +1,86 @@
import platform
from aiohttp import web
import asyncio
import json
import os
import glob
from threading import Lock
import server
import uuid
from .db_service import get_my_workflows_dir
@server.PromptServer.instance.routes.post('/workspace/file/scan_my_workflows_folder')
async def scan_my_workflows_files(request):
reqJson = await request.json()
path = reqJson['path']
path = os.path.join(get_my_workflows_dir(), path)
recursive = reqJson.get('recursive', False)
metaInfoOnly = reqJson.get('metaInfoOnly', False)
fileList = await asyncio.to_thread(folder_handle, path, recursive, metaInfoOnly)
return web.Response(text=json.dumps(fileList), content_type='application/json')
def folder_handle(path, recursive, metaInfoOnly, fileList=None):
if fileList is None:
fileList = []
for item in os.listdir(path):
item_path = os.path.join(path, item)
try:
if os.path.isfile(item_path) and item_path.endswith('.json'):
file_handle(item, fileList, item_path, metaInfoOnly)
elif os.path.isdir(item_path):
createTime, updateTime = getFileCreateTime(item_path)
fileList.append({
'name': item,
'type': 'folder',
'createTime': createTime,
'updateTime': updateTime
})
# Recursively scan if recursive is True
if recursive:
folder_handle(item_path, recursive, metaInfoOnly, fileList)
except Exception as e:
print(f"Error scanning item {item}: {e}")
return fileList
def file_handle(name, fileList, file_path, metaInfoOnly):
with open(file_path, 'r', encoding='utf-8') as f:
json_data = json.load(f)
createTime, updateTime = getFileCreateTime(file_path)
workspace_info = json_data.get('extra', {}).get('workspace_info', {})
workflow_id = workspace_info.get('id', str(uuid.uuid4()))
# Update JSON data with new ID if needed and write back to file
if 'id' not in workspace_info:
if 'extra' not in json_data:
json_data['extra'] = {}
if 'workspace_info' not in json_data['extra']:
json_data['extra']['workspace_info'] = {}
json_data['extra']['workspace_info']['id'] = workflow_id
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, ensure_ascii=False, indent=4)
fileInfo = {
'name': name,
'type': "workflow",
'id': workflow_id,
'createTime': createTime,
'updateTime': updateTime
}
if not metaInfoOnly:
fileInfo['json'] = json.dumps(json_data)
fileList.append(fileInfo)
def getFileCreateTime(path):
# Cross-platform compatibility for creation time
file_stats = os.stat(path)
if platform.system() == 'Windows':
createTime = int(file_stats.st_ctime * 1000)
else: # macOS and potentially others
createTime = int(getattr(file_stats, 'st_birthtime', file_stats.st_ctime) * 1000)
updateTime = int(file_stats.st_mtime * 1000)
return createTime, updateTime
+71
View File
@@ -0,0 +1,71 @@
import asyncio
import shutil
from aiohttp import web
import os
from pathlib import Path
import server
from .db_service import get_my_workflows_dir
@server.PromptServer.instance.routes.post('/workspace/folder/create')
async def create_folder(request):
reqJson = await request.json()
data = await asyncio.to_thread(create_folder_sync, reqJson)
return web.json_response(data, content_type='application/json')
def create_folder_sync(reqJson):
folder_path = reqJson.get('path')
folder_path = os.path.join(get_my_workflows_dir(), folder_path)
try:
Path(folder_path).mkdir(parents=True, exist_ok=True)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@server.PromptServer.instance.routes.post('/workspace/folder/delete')
async def delete_folder(request):
reqJson = await request.json()
data = await asyncio.to_thread(delete_folder_sync, reqJson)
return web.json_response(data, content_type='application/json')
def delete_folder_sync(reqJson):
folder_path = reqJson.get('path')
folder_path = os.path.join(get_my_workflows_dir(), folder_path)
try:
shutil.rmtree(folder_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@server.PromptServer.instance.routes.post('/workspace/folder/rename')
async def rename_folder(request):
reqJson = await request.json()
data = await asyncio.to_thread(rename_folder_sync, reqJson)
return web.json_response(data, content_type='application/json')
def rename_folder_sync(reqJson):
current_path = reqJson.get('absPath')
current_path = os.path.join(get_my_workflows_dir(), current_path)
new_name = reqJson.get('newName')
try:
new_path = Path(current_path).parent / new_name
Path(current_path).rename(new_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@server.PromptServer.instance.routes.post('/workspace/folder/move')
async def move_folder(request):
reqJson = await request.json()
data = await asyncio.to_thread(move_folder_sync, reqJson)
return web.json_response(data, content_type='application/json')
def move_folder_sync(reqJson):
current_path = reqJson.get('folder')
current_path = os.path.join(get_my_workflows_dir(), current_path)
new_path = os.path.join(get_my_workflows_dir(), reqJson.get('newParentPath',""))
try:
shutil.move(current_path, new_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
+3 -1
View File
@@ -5,14 +5,16 @@ module.exports = {
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
plugins: ['react-refresh', '@typescript-eslint', 'prettier'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'prettier/prettier': ['error'],
},
}
+15
View File
@@ -0,0 +1,15 @@
{
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"printWidth": 80,
"proseWrap": "always",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"endOfLine": "auto"
}
+218 -32
View File
@@ -15,11 +15,11 @@
"chakra-react-select": "^4.7.6",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^10.16.12",
"fuse.js": "^7.0.0",
"jszip": "^3.10.1",
"nanoid": "^5.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
@@ -29,8 +29,11 @@
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"prettier": "^3.2.4",
"rollup-plugin-watch": "^1.0.4",
"typescript": "^5.2.2",
"vite": "^5.0.0"
@@ -2278,6 +2281,18 @@
"node": ">= 8"
}
},
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3352,6 +3367,48 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
"dev": true,
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.8.6"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": "*",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz",
@@ -3569,6 +3626,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@@ -3698,6 +3761,7 @@
"version": "10.16.12",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.12.tgz",
"integrity": "sha512-w7Yzx0OzQ5Uh6uNkxaX+4TuAPuOKz3haSbjmHpdrqDpGuCJCpq6YP9Dy7JJWdZ6mJjndrg3Ao3vUwDajKNikCA==",
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
},
@@ -3722,6 +3786,7 @@
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"optional": true,
"peer": true,
"dependencies": {
"@emotion/memoize": "0.7.4"
}
@@ -3730,7 +3795,8 @@
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
"optional": true,
"peer": true
},
"node_modules/framesync": {
"version": "6.1.2",
@@ -3773,6 +3839,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4213,10 +4287,9 @@
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==",
"funding": [
{
"type": "github",
@@ -4224,10 +4297,10 @@
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@@ -4425,6 +4498,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4434,6 +4525,33 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",
"integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"dev": true,
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -4946,6 +5064,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
"dev": true,
"dependencies": {
"@pkgr/core": "^0.1.0",
"tslib": "^2.6.2"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -5134,18 +5268,6 @@
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.2.tgz",
@@ -6848,6 +6970,12 @@
"fastq": "^1.6.0"
}
},
"@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"dev": true
},
"@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -7663,6 +7791,23 @@
}
}
},
"eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"requires": {}
},
"eslint-plugin-prettier": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
"dev": true,
"requires": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.8.6"
}
},
"eslint-plugin-react-hooks": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz",
@@ -7746,6 +7891,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true
},
"fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@@ -7853,6 +8004,7 @@
"version": "10.16.12",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.12.tgz",
"integrity": "sha512-w7Yzx0OzQ5Uh6uNkxaX+4TuAPuOKz3haSbjmHpdrqDpGuCJCpq6YP9Dy7JJWdZ6mJjndrg3Ao3vUwDajKNikCA==",
"peer": true,
"requires": {
"@emotion/is-prop-valid": "^0.8.2",
"tslib": "^2.4.0"
@@ -7863,6 +8015,7 @@
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"optional": true,
"peer": true,
"requires": {
"@emotion/memoize": "0.7.4"
}
@@ -7871,7 +8024,8 @@
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
"optional": true,
"peer": true
}
}
},
@@ -7908,6 +8062,11 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"fuse.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q=="
},
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -8255,10 +8414,9 @@
"dev": true
},
"nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA=="
},
"natural-compare": {
"version": "1.4.0",
@@ -8391,6 +8549,14 @@
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"dependencies": {
"nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true
}
}
},
"prelude-ls": {
@@ -8399,6 +8565,21 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true
},
"prettier": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",
"integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==",
"dev": true
},
"prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"dev": true,
"requires": {
"fast-diff": "^1.1.2"
}
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -8743,6 +8924,16 @@
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
},
"synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
"dev": true,
"requires": {
"@pkgr/core": "^0.1.0",
"tslib": "^2.6.2"
}
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -8853,11 +9044,6 @@
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
},
"vite": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.2.tgz",
+8 -4
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"setupGithooks": "node ../scripts/setupGithooks"
},
"dependencies": {
"@chakra-ui/react": "^2.8.2",
@@ -17,11 +18,11 @@
"chakra-react-select": "^4.7.6",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^10.16.12",
"fuse.js": "^7.0.0",
"jszip": "^3.10.1",
"nanoid": "^5.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
@@ -31,8 +32,11 @@
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"prettier": "^3.2.4",
"rollup-plugin-watch": "^1.0.4",
"typescript": "^5.2.2",
"vite": "^5.0.0"
+90 -27
View File
@@ -1,6 +1,8 @@
import { Table } from "./db-tables/WorkspaceDB";
import type { ModelsListRespItem } from "./model-manager/types";
export async function getDB(table: Table): Promise<string | undefined> {
console.warn("[workspace deprecated] getDB is deprecated", table);
try {
const response = await fetch(`/workspace/get_db?table=${table}`);
if (!response.ok) {
@@ -15,8 +17,6 @@ export async function getDB(table: Table): Promise<string | undefined> {
}
export async function saveDB(table: Table, jsonData: string) {
// const tableBackupFile = table + "/" + Date.now() + ".json";
// saveBackup(tableBackupFile, jsonData);
try {
const response = await fetch("/workspace/save_db", {
method: "POST",
@@ -71,25 +71,6 @@ export async function deleteFile(file_path: string, deleteEmptyFolder = false) {
}
}
export async function saveBackup(file_path: string, jsonData: string) {
try {
const response = await fetch("/workspace/save_backup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
file_path: file_path,
json_str: jsonData,
}),
});
const result = await response.text();
return result;
} catch (error) {
console.error("Error saving workspace backup:", error);
}
}
export async function listBackup(dir: string) {
try {
const response = await fetch("/workspace/list_backup", {
@@ -116,7 +97,7 @@ export async function getSystemDir(root?: string) {
"Content-Type": "application/json",
},
body: JSON.stringify({
absolute_dir: root,
absolute_dir: root ?? "",
}),
});
const result = await response.json();
@@ -141,21 +122,103 @@ export async function openWorkflowsFolder() {
}
}
export async function scanLocalNewFiles(path: string, existFlowIds: string[]) {
export async function fetchMyWorkflowsDir() {
const resp = await fetch("/workspace/get_my_workflows_dir");
const res = (await resp.json()) as {
path?: string;
error?: string;
os: "win32" | "darwin" | "linux";
};
if (res.error) {
alert(`Failed to fetch my workflows path: ${res.error}`);
}
return res;
}
export async function getAllModelsList() {
try {
const response = await fetch("/workspace/scan_local_new_files", {
const response = await fetch("/model_manager/get_model_list", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
return result as Array<ModelsListRespItem & { date: number }>;
} catch (error) {
console.error("Error get all models list:", error);
}
}
export async function getAllFoldersList() {
try {
const response = await fetch("/model_manager/get_folder_list", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
return result as Record<string, string[]>;
} catch (error) {
console.error("Error get all models list:", error);
}
}
export async function deleteLocalDiskFolder(folderPath: string) {
try {
const response = await fetch("/workspace/delete_folder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path,
existFlowIds
folder_path: folderPath,
}),
});
const result = await response.text();
return result;
} catch (error) {
console.error("Error move file:", error);
}
}
export async function cancelDownload(savePath: string) {
try {
const response = await fetch("/model_manager/cancel_installation", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
save_path: savePath,
}),
});
const result = await response.text();
return result;
} catch (error) {
console.error("Error move file:", error);
}
}
export async function copyFlowsToNewDirectory(
sourceDir: string,
dstDir: string,
) {
try {
const response = await fetch("/workspace/copy_json_files", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
src: sourceDir,
dst: dstDir,
}),
});
const result = await response.json();
return result;
} catch (error) {
console.error("Error scan local new files:", error);
console.error("Error copy flows to new directory:", error);
}
}
+363 -211
View File
@@ -1,229 +1,354 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import {
Suspense,
useCallback,
useEffect,
useRef,
useState,
DragEvent,
} from "react";
// @ts-ignore
import { app } from "/scripts/app.js";
// @ts-ignore
import { api } from "/scripts/api.js";
import { ComfyExtension, ComfyObjectInfo } from "./types/comfy";
import { Box, Portal } from "@chakra-ui/react";
import { Box, Portal, useToast } from "@chakra-ui/react";
import {
createFlow,
getWorkflow,
loadDBs,
updateFlow,
workspace,
workflowsTable,
userSettingsTable,
changelogsTable,
mediaTable,
listWorkflows,
workflowVersionsTable,
} from "./db-tables/WorkspaceDB";
import { defaultGraph } from "./defaultGraph";
import { WorkspaceContext } from "./WorkspaceContext";
import {
Route,
syncNewFlowOfLocalDisk,
getFileUrl,
matchSaveWorkflowShortcut,
validateOrSaveAllJsonFileMyWorkflows,
getWorkflowIdInUrlHash,
generateUrlHashWithFlowId,
openWorkflowInNewTab,
} from "./utils";
import GalleryModal from "./gallery/GalleryModal";
import { Topbar } from "./topbar/Topbar";
import { authTokenListener, pullAuthTokenCloseIfExist } from "./auth/authUtils";
import { PanelPosition } from "./types/dbTypes";
import { Workflow, WorkflowVersion } from "./types/dbTypes";
import { useDialog } from "./components/AlertDialogProvider";
import React from "react";
const RecentFilesDrawer = React.lazy(
() => import("./RecentFilesDrawer/RecentFilesDrawer")
() => import("./RecentFilesDrawer/RecentFilesDrawer"),
);
import { scanLocalNewFiles } from "./Api";
const GalleryModal = React.lazy(() => import("./gallery/GalleryModal"));
import { IconExternalLink } from "@tabler/icons-react";
import { DRAWER_Z_INDEX, UPGRADE_TO_2WAY_SYNC_KEY } from "./const";
import ServerEventListener from "./model-manager/hooks/ServerEventListener";
import { nanoid } from "nanoid";
import { WorkspaceRoute } from "./types/types";
import { useStateRef } from "./customHooks/useStateRef";
import { indexdb } from "./db-tables/indexdb";
import EnableTwowaySyncConfirm from "./settings/EnableTwowaySyncConfirm";
import { deepJsonDiffCheck } from "./utils/deepJsonDiffCheck";
import {
OPEN_TAB_EVENT,
tabDataManager,
} from "./topbar/multipleTabs/TabDataManager";
export default function App() {
const nodeDefs = useRef<Record<string, ComfyObjectInfo>>({});
const [curFlowName, setCurFlowName] = useState<string | null>(null);
const [route, setRoute] = useState<Route>("root");
const [route, setRoute] = useState<WorkspaceRoute>("root");
const [loadingDB, setLoadingDB] = useState(true);
const [flowID, setFlowID] = useState<string | null>(null);
const curFlowID = useRef<string | null>(null);
const [positionStyle, setPositionStyle] = useState<PanelPosition>();
const [isDirty, setIsDirty] = useState(false);
const workspaceContainerRef = useRef(null);
const { showDialog } = useDialog();
const [loadChild, setLoadChild] = useState(false);
const saveCurWorkflow = useCallback(() => {
if (curFlowID.current) {
const graphJson = JSON.stringify(app.graph.serialize());
updateFlow(curFlowID.current, {
lastSavedJson: graphJson,
json: graphJson,
});
changelogsTable?.create({
workflowID: curFlowID.current,
json: graphJson,
});
const developmentEnvLoadFirst = useRef(false);
const toast = useToast();
const [curVersion, setCurVersion] = useStateRef<WorkflowVersion | null>(null);
const saveCurWorkflow = useCallback(async (newGraphJson?: string) => {
const flowId = workflowsTable?.curWorkflow?.id;
if (flowId) {
if (workflowsTable?.curWorkflow?.saveLock) {
toast({
title: "The workflow is locked and cannot be saved",
status: "warning",
duration: 3000,
});
return;
}
!newGraphJson && setIsDirty(false);
const graphJson = newGraphJson || JSON.stringify(app.graph.serialize());
await Promise.all([
workflowsTable?.updateFlow(flowId, {
json: graphJson,
}),
changelogsTable?.create({
workflowID: flowId,
json: graphJson,
isAutoSave: false,
}),
]);
userSettingsTable?.settings?.autoSave &&
toast({
title: "Saved",
status: "success",
duration: 1000,
isClosable: true,
});
}
}, []);
const discardUnsavedChanges = () => {
const discardUnsavedChanges = async () => {
if (userSettingsTable?.settings?.autoSave) {
alert("You cannot discard unsaved changes when auto save is enabled");
return;
}
const userInput = confirm(
"Are you sure you want to discard unsaved changes? This will revert current workflow to your last saved version. You will lose all changes made since your last save."
"Are you sure you want to discard unsaved changes? This will revert current workflow to your last saved version. You will lose all changes made since your last save.",
);
if (userInput) {
// User clicked OK
if (curFlowID.current) {
const flow = getWorkflow(curFlowID.current);
if (flow && flow.lastSavedJson) {
app.loadGraphData(JSON.parse(flow.lastSavedJson));
}
const curID = workflowsTable?.curWorkflow?.id;
if (curID == null) return;
const lastSavedJson = (await workflowsTable?.get(curID))?.json;
if (lastSavedJson) {
workflowsTable?.updateCurWorkflow({
...workflowsTable.curWorkflow!,
json: lastSavedJson,
});
await app.loadGraphData(JSON.parse(lastSavedJson));
setIsDirty(false);
} else {
alert("Error: No last saved version found");
}
}
};
const setCurFlowID = (id: string) => {
curFlowID.current = id;
const setCurFlowIDAndName = (workflow: Workflow | null) => {
// curID null is when you deleted current workflow
const id = workflow?.id ?? null;
workflowsTable?.updateCurWorkflow(workflow);
setFlowID(id);
const workflow = getWorkflow(id);
if (workflow) {
if (id == null) {
document.title = "ComfyUI";
window.location.hash = "";
localStorage.removeItem("curFlowID");
return;
}
if (getWorkflowIdInUrlHash()) {
const newUrlHash = generateUrlHashWithFlowId(id);
window.location.hash = newUrlHash;
document.title = workflow!.name + " - ComfyUI";
} else {
localStorage.setItem("curFlowID", id);
// localStorage.setItem("comfy_workspace_workflow", workflow.json);
document.title = "ComfyUI - " + workflow!.name;
}
if (workflow) {
workflowsTable?.updateLastOpenedTime(workflow.id);
}
};
const graphAppSetup = async () => {
const ext: ComfyExtension = {
// Unique name for the extension
name: "WorkspaceManager",
async setup(app) {
// clean up legacy localStorage
localStorage.removeItem("workspace");
localStorage.removeItem("comfyspace");
},
async addCustomNodeDefs(defs) {
nodeDefs.current = defs;
},
// @ts-ignore
async afterConfigureGraph() {
// to avoid the bug after switching workflow using comfyspace,
// immediately refresh browser, resulting in latest workflow not updated
localStorage.setItem("workflow", JSON.stringify(app.graph.serialize()));
},
};
app.registerExtension(ext);
localStorage.removeItem("workspace");
localStorage.removeItem("comfyspace");
try {
await loadDBs();
updatePanelPosition(userSettingsTable?.getSetting("topBarStyle"), false);
} catch (error) {
console.error("error loading db", error);
}
setLoadingDB(false);
const latest = localStorage.getItem("curFlowID");
const latestWf = latest != null ? getWorkflow(latest) : null;
if (latestWf) {
latestWf && localStorage.setItem("workflow", latestWf.json);
setCurFlowID(latestWf.id ?? null);
setCurFlowName(latestWf.name ?? null);
let latestWfID = null;
latestWfID = getWorkflowIdInUrlHash();
if (latestWfID == null) {
latestWfID = localStorage.getItem("curFlowID");
}
await validateOrSaveAllJsonFileMyWorkflows();
if (userSettingsTable?.getSetting("twoWaySync")) {
// Scan all files and subfolders in the local storage directory, compare and find the data that needs to be added in the DB, and perform the new operation
const myWorkflowsDir = userSettingsTable?.getSetting("myWorkflowsDir");
const existFlowIds = listWorkflows().map((flow) => flow.id);
const { fileList, folderList } = await scanLocalNewFiles(
myWorkflowsDir!,
existFlowIds
);
await syncNewFlowOfLocalDisk(fileList, folderList);
if (latestWfID) {
loadWorkflowIDImpl(latestWfID!, null, true);
}
fetch("/workspace/deduplicate_workflow_ids");
const twoway = await userSettingsTable?.getSetting("twoWaySync");
!twoway &&
indexdb.cache.get(UPGRADE_TO_2WAY_SYNC_KEY).then(async (value) => {
if (value?.value !== "true") {
const workflowsCount = await indexdb.workflows.count();
if (workflowsCount == 0) {
// empty workflows, enable 2way sync
await userSettingsTable?.upsert({ twoWaySync: true });
await indexdb.cache.put({
id: UPGRADE_TO_2WAY_SYNC_KEY,
value: "true",
});
return;
}
const myWorkflowsDir =
await userSettingsTable?.getSetting("myWorkflowsDir");
showDialog(
<EnableTwowaySyncConfirm
myWorkflowsDir={myWorkflowsDir ?? "undefined"}
/>,
[
{
label: "I have downloaded all my workflows and ready to enable",
onClick: async () => {
await userSettingsTable?.upsert({ twoWaySync: true });
if (await userSettingsTable?.getSetting("twoWaySync")) {
indexdb.cache.put({
id: UPGRADE_TO_2WAY_SYNC_KEY,
value: "true",
});
location.reload();
}
},
colorScheme: "red",
},
],
{
closeOnOverlayClick: false,
},
);
}
});
};
useEffect(() => {
graphAppSetup();
setLoadChild(true);
setInterval(() => {
const autoSaveEnabled = userSettingsTable?.getSetting("autoSave") ?? true;
if (curFlowID.current != null && autoSaveEnabled) {
// autosave workflow if enabled
const graphJson = JSON.stringify(app.graph.serialize());
graphJson != null &&
updateFlow(curFlowID.current, {
json: graphJson,
});
}
setIsDirty(checkIsDirty());
}, 1000);
pullAuthTokenCloseIfExist();
}, []);
const checkIsDirty = () => {
if (curFlowID.current != null) {
const graphJson = app.graph.serialize() ?? {};
const curWorkflow = curFlowID.current
? getWorkflow(curFlowID.current)
: null;
let lastSaved = {};
try {
lastSaved = curWorkflow?.lastSavedJson
? JSON.parse(curWorkflow?.lastSavedJson)
: {};
} catch (e) {
console.error("error parsing json", e);
}
const equal = JSON.stringify(graphJson) === JSON.stringify(lastSaved);
return !equal;
if (workflowsTable?.curWorkflow?.id == null) return false;
const curflow = workflowsTable?.curWorkflow;
if (curflow == null) return false;
if (curflow.json == null) return true;
const graphJson = app.graph.serialize() ?? {};
let lastSaved = {};
try {
lastSaved = curflow?.json ? JSON.parse(curflow?.json) : {};
} catch (e) {
console.error("error parsing json", e);
}
return false;
const equal = deepJsonDiffCheck(graphJson, lastSaved);
return !equal;
};
const loadWorkflowIDImpl = (id: string) => {
const loadWorkflowIDImpl = async (
id: string,
versionID?: string | null,
isInit?: boolean,
) => {
if (app.graph == null) {
console.error("app.graph is null cannot load workflow");
return;
}
setCurFlowID(id);
const flow = getWorkflow(id);
const flow = await workflowsTable?.get(id);
// If the currently loaded flow does not exist, you need to clear the URL hash and localStorage to avoid popping up another prompt that the flow does not exist when refreshing the page.
if (flow == null) {
alert("Error: Workflow not found! id: " + id);
setCurFlowIDAndName(null);
return;
}
const version = versionID
? await workflowVersionsTable?.get(versionID)
: null;
setCurVersion(version ?? null);
const tabIndex = tabDataManager.tabs.findIndex((tab) => tab.id === id);
if (tabIndex !== -1) {
tabDataManager.changeActiveTab(tabIndex);
if (version) {
tabDataManager.updateTabData(tabIndex, { json: version.json });
}
return;
}
app.ui.dialog.close();
app.loadGraphData(JSON.parse(flow.json));
setCurFlowName(flow.name);
setRoute("root");
isDirty && setIsDirty(false);
const newTabInfo = {
id: flow.id,
name: flow.name,
json: version ? version.json : flow.json,
isDirty: false,
};
/**
* During initialization,
* because the event listener of OPEN_TAB_EVENT has not been registered yet,
* the newly added tab will be invalid,
* so the tab is added manually during initialization.
*/
if (isInit) {
tabDataManager.addTabData(newTabInfo);
app.loadGraphData(JSON.parse(newTabInfo.json));
setCurFlowIDAndName({
...flow,
json: newTabInfo.json,
});
} else {
document.dispatchEvent(
new CustomEvent(OPEN_TAB_EVENT, {
detail: {
tabInfo: newTabInfo,
},
}),
);
}
};
const loadWorkflowID = (id: string) => {
const autoSaveEnabled = userSettingsTable?.getSetting("autoSave") ?? true;
if (autoSaveEnabled || !isDirty) {
loadWorkflowIDImpl(id);
const loadWorkflowID = async (
id: string | null,
versionID?: string | null,
) => {
// No current workflow, id is null when you deleted current workflow
if (id === null) {
setCurFlowIDAndName(null);
app.graph.clear();
return;
}
showDialog(`Do you want to save the current workflow, ${curFlowName}?`, [
loadWorkflowIDImpl(id, versionID);
};
const showSaveOrDiscardCurWorkflowDialog = async (newIDToLoad?: string) => {
const buttons = [
newIDToLoad
? {
label: "Open in new tab",
icon: <IconExternalLink />,
onClick: () => {
openWorkflowInNewTab(newIDToLoad);
},
}
: null,
{
label: "Save",
colorScheme: "teal",
onClick: () => {
saveCurWorkflow();
loadWorkflowIDImpl(id);
onClick: async () => {
await saveCurWorkflow();
newIDToLoad && loadWorkflowIDImpl(newIDToLoad);
},
},
{
label: "Discard",
colorScheme: "red",
onClick: () => {
loadWorkflowIDImpl(id);
},
];
buttons.push({
label: "Discard",
colorScheme: "red",
onClick: async () => {
newIDToLoad && loadWorkflowIDImpl(newIDToLoad);
},
]);
});
showDialog(
`Please save or discard your changes to "${workflowsTable?.curWorkflow?.name}" before leaving, or your changes will be lost.`,
buttons,
);
};
const loadNewWorkflow = (input?: { json?: string; name?: string }) => {
const loadNewWorkflow = async (input?: { json?: string; name?: string }) => {
const jsonStr = input?.json ?? JSON.stringify(defaultGraph);
const flow = createFlow({ json: jsonStr, name: input?.name });
loadWorkflowID(flow.id);
const flow = await workflowsTable?.createFlow({
json: jsonStr,
name: input?.name,
});
flow && (await loadWorkflowID(flow.id));
setRoute("root");
};
const loadFilePath = async (
relativePath: string,
overwriteCurrent: boolean = false
overwriteCurrent: boolean = false,
) => {
const fileName = relativePath.split("/").pop() ?? relativePath;
if (!overwriteCurrent) {
@@ -240,60 +365,31 @@ export default function App() {
setRoute("root");
};
const onDuplicateWorkflow = (workflowID: string, newFlowName?: string) => {
if (workspace == null) {
return;
}
const workflow = workspace[workflowID];
const onDuplicateWorkflow = async (
workflowID: string,
newFlowName?: string,
) => {
const workflow = await workflowsTable?.get(workflowID);
if (workflow == null) {
return;
}
const flow = createFlow({
const flow = await workflowsTable?.createFlow({
json: workflow.json,
name: newFlowName || workflow.name,
parentFolderID: workflow.parentFolderID,
tags: workflow.tags,
});
loadWorkflowID(flow.id);
flow && (await loadWorkflowID(flow.id));
};
const updatePanelPosition = useCallback(
(position?: PanelPosition, needUpdateDB: boolean = false) => {
const { top: curTop = 0, left: curLeft = 0 } = positionStyle || {};
let { top = 0, left = 0 } = position ?? {};
top += curTop;
left += curLeft;
const clientWidth = document.documentElement.clientWidth;
const clientHeight = document.documentElement.clientHeight;
const panelElement = document.getElementById("workspaceManagerPanel");
const offsetWidth = panelElement?.offsetWidth || 392;
if (top + 36 > clientHeight) top = clientHeight - 36;
if (left + offsetWidth >= clientWidth) left = clientWidth - offsetWidth;
setPositionStyle({ top: Math.max(0, top), left: Math.max(0, left) });
needUpdateDB &&
userSettingsTable?.upsert({
topBarStyle: { top, left },
});
},
[positionStyle]
);
const shortcutListener = (event: KeyboardEvent) => {
if (matchSaveWorkflowShortcut(event)) {
saveCurWorkflow();
}
};
const onExecutedCreateMedia = useCallback((image: any) => {
if (curFlowID.current == null) return;
if (workflowsTable?.curWorkflow?.id == null) return;
let path = image.filename;
if (image.subfolder != null && image.subfolder !== "") {
path = image.subfolder + "/" + path;
}
mediaTable?.create({
workflowID: curFlowID.current,
workflowID: workflowsTable?.curWorkflow?.id,
localPath: path,
});
}, []);
@@ -303,63 +399,117 @@ export default function App() {
}, []);
useEffect(() => {
window.addEventListener("keydown", shortcutListener);
window.addEventListener("message", authTokenListener);
/**
* because we have turned on strict mode, useEffect will be executed twice in strict mode in the development environment.
* and graphAppSetup contains DB related operations, repeated execution will bring some bad results.
* so in development environment mode, the first execution is skipped.
*/
if (
// @ts-ignore
process.env.NODE_ENV === "development" &&
!developmentEnvLoadFirst.current
) {
developmentEnvLoadFirst.current = true;
return;
}
graphAppSetup();
setLoadChild(true);
const fileInput = document.getElementById(
"comfy-file-input"
"comfy-file-input",
) as HTMLInputElement;
const fileInputListener = () => {
const fileInputListener = async () => {
if (fileInput && fileInput.files && fileInput.files.length > 0) {
const flow = createFlow({
// @ts-ignore
const newFlow: Workflow = {
id: nanoid(),
name: fileInput.files[0].name,
json: JSON.stringify(defaultGraph),
});
setCurFlowID(flow.id);
setCurFlowName(flow.name ?? "Unknown name");
updateTime: Date.now(),
createTime: Date.now(),
};
setCurFlowIDAndName(newFlow);
const res = await workflowsTable?.createFlow(newFlow);
res &&
tabDataManager.updateTabData(tabDataManager.activeIndex, {
id: res.id,
name: res.name,
});
}
};
fileInput?.addEventListener("change", fileInputListener);
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const autoSaveEnabled = userSettingsTable?.getSetting("autoSave") ?? true;
const autoSaveEnabled = userSettingsTable?.settings?.autoSave ?? true;
if (workflowsTable?.curWorkflow?.saveLock) return;
const isDirty = checkIsDirty();
if (!autoSaveEnabled && checkIsDirty()) {
if (!autoSaveEnabled && isDirty) {
e.preventDefault(); // For modern browsers
e.returnValue = "You have unsaved changes"; // For older browsers
e.returnValue = "You have unsaved changes!"; // For older browsers
showSaveOrDiscardCurWorkflowDialog();
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
api.addEventListener("executed", (e: any) => {
const handleExecuted = (e: any) => {
e.detail?.output?.images?.forEach(
(im: { filename: string; subfolder: string; type: string }) => {
if (im.type === "output") {
onExecutedCreateMedia(im);
}
}
},
);
e.detail?.output?.gifs?.forEach(
(im: { filename: string; subfolder: string; type: string }) => {
if (im.type === "output") {
onExecutedCreateMedia(im);
}
}
},
);
});
};
api.addEventListener("executed", handleExecuted);
const handleDrop = async (event: DragEvent) => {
const fileName = event.dataTransfer?.files[0]?.name;
const n = app.dragOverNode;
if (n && n.onDragDrop) {
return;
}
const overwriteFlow =
(await userSettingsTable?.getSetting(
"overwriteCurWorkflowWhenDroppingFileToCanvas",
)) ?? false;
if (!overwriteFlow && fileName) {
const newFlow: Workflow = {
id: nanoid(),
name: fileName,
json: JSON.stringify(defaultGraph),
updateTime: Date.now(),
createTime: Date.now(),
};
setCurFlowIDAndName(newFlow);
const res = await workflowsTable?.createFlow(newFlow);
res &&
tabDataManager.updateTabData(tabDataManager.activeIndex, {
id: res.id,
name: res.name,
});
}
};
app.canvasEl.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("message", authTokenListener);
window.removeEventListener("keydown", shortcutListener);
window.removeEventListener("change", fileInputListener);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("executed", handleExecuted);
app.canvasEl.removeEventListener("drop", handleDrop);
};
}, []);
if (loadingDB || !positionStyle) {
if (loadingDB) {
return null;
}
return (
<WorkspaceContext.Provider
value={{
@@ -369,30 +519,29 @@ export default function App() {
discardUnsavedChanges: discardUnsavedChanges,
saveCurWorkflow: saveCurWorkflow,
isDirty: isDirty,
setIsDirty: setIsDirty,
loadNewWorkflow: loadNewWorkflow,
loadFilePath: loadFilePath,
setRoute: setRoute,
route: route,
curVersion: curVersion,
setCurVersion: setCurVersion,
setCurFlowIDAndName: setCurFlowIDAndName,
}}
>
<div ref={workspaceContainerRef} className="workspace_manager">
<Portal containerRef={workspaceContainerRef}>
<Box
style={{
width: "100vh",
position: "absolute",
top: 0,
right: 0,
left: 0,
lineHeight: "24px",
}}
zIndex={1000}
zIndex={DRAWER_Z_INDEX}
draggable={false}
>
<Topbar
curFlowName={curFlowName}
setCurFlowName={setCurFlowName}
updatePanelPosition={updatePanelPosition}
positionStyle={positionStyle}
/>
<Topbar />
{loadChild && route === "recentFlows" && (
<Suspense>
<RecentFilesDrawer
@@ -405,9 +554,12 @@ export default function App() {
</Suspense>
)}
{route === "gallery" && (
<GalleryModal onclose={() => setRoute("root")} />
<Suspense>
<GalleryModal onclose={() => setRoute("root")} />
</Suspense>
)}
</Box>
<ServerEventListener />
</Portal>
</div>
</WorkspaceContext.Provider>
+1
View File
@@ -6,6 +6,7 @@ const vhPolyfill = css`
:root,
:host {
--chakra-vh: 100vh;
--toast-z-index: 17000000;
}
@supports (height: -webkit-fill-available) {
@@ -9,13 +9,14 @@ import {
PopoverHeader,
Input,
HStack,
IconButton,
MenuItem,
} from "@chakra-ui/react";
import { useEffect, useState, useContext } from "react";
import { Tag, Workflow, tagsTable, updateFlow } from "../db-tables/WorkspaceDB";
import { useState, useContext } from "react";
import { tagsTable, workflowsTable } from "../db-tables/WorkspaceDB";
import { IconPlus, IconTag } from "@tabler/icons-react";
import { MultiValue, Select } from "chakra-react-select";
import { RecentFilesContext } from "../WorkspaceContext";
import { Tag, Workflow } from "../types/dbTypes";
type Props = {
workflow: Workflow;
@@ -29,19 +30,25 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
value: t,
label: t,
})) ?? [];
const [selectedTags, setSelectedTags] =
useState<MultiValue<{ value: string; label: string }>>(initialTags);
useEffect(() => {
tagsTable && setAllTags(tagsTable.listAll() ?? []);
}, []);
useEffect(() => {
setSelectedTags(
workflow.tags?.map((t) => ({
value: t,
label: t,
})) ?? []
);
}, [workflow.tags]);
const addTag = async () => {
await tagsTable?.put({
name: newTagName,
workflowIDs: [],
updateTime: Date.now(),
});
await tagsTable?.listAll().then((tags) => setAllTags(tags ?? []));
setNewTagName("");
};
const onOpen = async () => {
const tags = await tagsTable?.listAll();
setAllTags(tags ?? []);
};
if (tagsTable == null) {
alert("Error: TagsTable is not loaded");
return null;
@@ -53,14 +60,9 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
const maxTagMenuHeight = 37 * 5;
return (
<Popover isLazy={true}>
<Popover isLazy={true} onOpen={onOpen} closeOnBlur={false} placement="auto">
<PopoverTrigger>
<IconButton
aria-label="Delete confirm"
size={"sm"}
variant="ghost"
icon={<IconTag color={"#718096"} />}
/>
<MenuItem icon={<IconTag />}>Add Tag</MenuItem>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
@@ -75,20 +77,20 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
options={tagOptions}
menuIsOpen={true}
value={selectedTags}
onChange={(selected) => {
onChange={async (selected) => {
setSelectedTags(selected);
updateFlow(workflow.id, {
await workflowsTable?.updateMetaInfo(workflow.id, {
tags: selected.map((s) => s.value),
});
onRefreshFilesList && onRefreshFilesList();
await onRefreshFilesList?.();
}}
chakraStyles={{
dropdownIndicator: (provided, state) => ({
dropdownIndicator: (provided) => ({
...provided,
p: 0,
w: "30px",
}),
menuList: (provided, state) => ({
menuList: (provided) => ({
...provided,
shadow: "none",
pt: 0,
@@ -110,7 +112,7 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
variant={"flushed"}
value={newTagName}
onChange={(e) => {
setNewTagName(e.target.value);
setNewTagName(e.target.value.trim());
}}
/>
<Button
@@ -121,11 +123,7 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
size={"xs"}
px={5}
isDisabled={newTagName.length === 0}
onClick={() => {
tagsTable?.upsert(newTagName);
setAllTags(tagsTable?.listAll() ?? []);
setNewTagName("");
}}
onClick={addTag}
>
New Tag
</Button>
@@ -1,57 +1,94 @@
import { HStack, useColorMode, Text, Stack } from "@chakra-ui/react";
import {
Workflow,
foldersTable,
isFolder,
listFolderContent,
updateFlow,
workflowsTable,
} from "../db-tables/WorkspaceDB";
import { useState, memo, useContext, useEffect, MouseEvent } from "react";
import {
useState,
memo,
useContext,
useEffect,
MouseEvent,
DragEvent,
} from "react";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { RecentFilesContext } from "../WorkspaceContext";
import WorkflowListItem from "./WorkflowListItem";
import FilesListFolderItemRightClickMenu from "./FilesListFolderItemRightClickMenu";
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
import { Folder } from "../types/dbTypes";
import { Folder, Workflow } from "../types/dbTypes";
import ItemsList from "./ItemsList";
type Props = {
folder: Folder;
};
export default memo(function FilesListFolderItem({ folder }: Props) {
const [isActive, setIsActive] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(folder.isCollapse ?? false);
const [children, setChildren] = useState<Array<Folder | Workflow>>(() =>
listFolderContent(folder.id)
);
const [isCollapsed, setIsCollapsed] = useState(folder.isCollapse ?? true);
const [children, setChildren] = useState<Array<Folder | Workflow>>([]);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { colorMode } = useColorMode();
const { draggingFile, refreshFolderStamp, onRefreshFilesList } =
useContext(RecentFilesContext);
const {
draggingFile,
setDraggingFile,
refreshFolderStamp,
onRefreshFilesList,
} = useContext(RecentFilesContext);
const activeStyle =
colorMode === "light"
? { backgroundColor: "#E2E8F0" }
: { backgroundColor: "#4A5568" };
useEffect(() => {
setChildren(
listFolderContent(
if (isCollapsed) return;
workflowsTable
?.listFolderContent(
folder.id,
window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes
window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes,
)
);
}, [folder.id, refreshFolderStamp]);
.then((child) => {
setChildren(child);
});
}, [folder.id, refreshFolderStamp, isCollapsed]);
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
setMenuPosition({ x: event.clientX, y: event.clientY });
setIsMenuOpen(true);
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!draggingFile) return setIsActive(false);
if (isFolder(draggingFile)) {
if (draggingFile.id === folder.id) return setIsActive(false);
await foldersTable?.update(draggingFile.id, {
parentFolderID: folder.id,
});
} else if (!isFolder(draggingFile)) {
await workflowsTable?.updateFolder(draggingFile.id, {
parentFolderID: folder.id,
});
}
await onRefreshFilesList?.();
setIsActive(false);
};
useEffect(() => {
if (folder.isCollapse === isCollapsed) return;
foldersTable?.update({
id: folder.id,
if (!!folder.isCollapse === isCollapsed) return;
foldersTable?.update(folder.id, {
isCollapse: isCollapsed,
});
}, [isCollapsed]);
useEffect(() => {
const cur = workflowsTable?.curWorkflow;
const curWorkflowPath = cur?.parentFolderID + "/";
const folderPath = folder.id + "/";
const childreHasCurworkflow = curWorkflowPath.startsWith(folderPath);
if (childreHasCurworkflow) {
setIsCollapsed(false);
}
}, []);
return (
<Stack>
<HStack
@@ -62,24 +99,22 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
setIsCollapsed(!isCollapsed);
}}
onContextMenu={handleContextMenu}
draggable="true"
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setIsActive(true);
}}
onDragLeave={() => {
setIsActive(false);
}}
onDrop={() => {
if (draggingFile && !isFolder(draggingFile)) {
updateFlow(draggingFile.id, {
parentFolderID: folder.id,
});
onRefreshFilesList && onRefreshFilesList();
}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setIsActive(false);
}}
onDragStart={() => setDraggingFile?.(folder)}
onDrop={handleDrop}
_hover={activeStyle}
style={isActive || isMenuOpen ? activeStyle : undefined}
className="droppable"
>
<HStack gap={1}>
{isCollapsed ? (
@@ -97,23 +132,16 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
</svg>
</HStack>
<Text>{folder.name}</Text>
<FilesListFolderItemRightClickMenu
isopen={isMenuOpen}
menuPosition={menuPosition}
onClose={() => setIsMenuOpen(false)}
folder={folder}
/>
</HStack>
<FilesListFolderItemRightClickMenu
isopen={isMenuOpen}
menuPosition={menuPosition}
onClose={() => setIsMenuOpen(false)}
folder={folder}
/>
{!isCollapsed && (
<Stack ml={4} gap={0}>
{children.map((file) => {
if (isFolder(file)) {
return <FilesListFolderItem folder={file} />;
} else {
return <WorkflowListItem workflow={file} />;
}
})}
<ItemsList items={children} />
</Stack>
)}
</Stack>
@@ -1,29 +1,19 @@
import {
Box,
Menu,
MenuList,
MenuItem,
Popover,
PopoverTrigger,
PopoverContent,
PopoverCloseButton,
PopoverBody,
Button,
PopoverArrow,
Text,
} from "@chakra-ui/react";
import { MouseEvent, useContext, useRef, useState } from "react";
import { Box, Menu, MenuList, MenuItem } from "@chakra-ui/react";
import { MouseEvent, useContext, useState } from "react";
import { RecentFilesContext } from "../WorkspaceContext";
import { foldersTable } from "../db-tables/WorkspaceDB";
import { workflowsTable, foldersTable } from "../db-tables/WorkspaceDB";
import EditFolderNameModal from "../components/EditFolderName";
import {
IconFileImport,
IconFolderPlus,
IconPencil,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { Folder } from "../types/dbTypes";
import { EFlowOperationType, Folder } from "../types/dbTypes";
import ImportFlowsFileInput from "./ImportFlowsFileInput";
import { defaultGraph } from "../defaultGraph";
import { useDialog } from "../components/AlertDialogProvider";
import { getFileCountInFolder } from "../db-tables/DiskFileUtils";
type Props = {
menuPosition: { x: number; y: number };
@@ -38,22 +28,65 @@ export default function FilesListFolderItemRightClickMenu({
onClose,
}: Props) {
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const { onRefreshFilesList } = useContext(RecentFilesContext);
const onClickNewFolder = (e: MouseEvent<HTMLButtonElement>) => {
const { showDialog } = useDialog();
const onClickNewFolder = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
foldersTable?.create({
name: "New folder",
await foldersTable?.create({
name: "New Folder",
parentFolderID: folder.id,
});
onRefreshFilesList && onRefreshFilesList();
};
const fileInputRef = useRef<HTMLInputElement>(null);
const onClickNewFile = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
await workflowsTable?.createFlow({
json: JSON.stringify(defaultGraph),
parentFolderID: folder.id,
});
onRefreshFilesList && onRefreshFilesList();
};
const openDeleteConfirm = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const fileCount = await getFileCountInFolder(folder.id);
showDialog(
`Do you want to delete the ${fileCount} files inside the folder too?`,
[
// {
// label: "Keep and move to root directory",
// colorScheme: "teal",
// onClick: () => {
// onDelete(EFlowOperationType.MOVE_TO_ROOT_FOLDER);
// },
// },
{
label: "Delete all files",
colorScheme: "red",
onClick: () => {
onDelete(EFlowOperationType.DELETE);
},
},
],
);
};
const onDelete = async (operationType: EFlowOperationType) => {
await foldersTable?.deleteFolder(folder.id, operationType);
onClose();
onRefreshFilesList && onRefreshFilesList();
};
return (
<>
<Box position="absolute" top={menuPosition.y} left={menuPosition.x}>
<Menu isOpen={isopen} onClose={onClose} isLazy>
<Menu
isOpen={isopen}
onClose={onClose}
isLazy
lazyBehavior="keepMounted"
>
<MenuList>
<MenuItem
icon={<IconPencil size={19} />}
@@ -67,7 +100,7 @@ export default function FilesListFolderItemRightClickMenu({
</MenuItem>
<MenuItem
icon={<IconTrash size={19} />}
onClick={() => setIsDeleteOpen(true)}
onClick={openDeleteConfirm}
>
Delete
</MenuItem>
@@ -77,67 +110,23 @@ export default function FilesListFolderItemRightClickMenu({
>
New folder
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
fileInputRef.current?.click();
}}
icon={<IconFileImport size={19} />}
>
Import workflows
<MenuItem onClick={onClickNewFile} icon={<IconPlus size={19} />}>
New file
</MenuItem>
<ImportFlowsFileInput
parentFolderID={folder.id}
onlyImportFile={true}
/>
</MenuList>
</Menu>
</Box>
<ImportFlowsFileInput
parentFolderID={folder.id}
fileInputRef={fileInputRef}
/>
{isRenameOpen && (
<EditFolderNameModal
folder={folder}
onclose={() => setIsRenameOpen(false)}
/>
)}
{isDeleteOpen && (
<Popover
returnFocusOnClose={false}
isOpen={true}
onClose={() => setIsDeleteOpen(false)}
// placement="right"
closeOnBlur={false}
>
<PopoverTrigger>
<div></div>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody textAlign={"left"}>
<Text mb={2}>
Are you sure you want to delete this folder,
<b> {folder.name}</b>?
</Text>
<Text color={"GrayText"} mb={5}>
This will NOT delete any files in the folder. The files will be
moved to the root folder.
</Text>
<Button
colorScheme="red"
size={"sm"}
onClick={() => {
setIsDeleteOpen(false);
onClose();
foldersTable?.delete(folder.id);
onRefreshFilesList && onRefreshFilesList();
}}
>
Yes, delete
</Button>
</PopoverBody>
</PopoverContent>
</Popover>
)}
</>
);
}
@@ -1,27 +0,0 @@
import { Button } from "@chakra-ui/react";
import { IconFileImport } from "@tabler/icons-react";
import { useRef } from "react";
import ImportFlowsFileInput from "./ImportFlowsFileInput";
interface Props {
parentFolderID?: string;
}
export default function ImportFileButton({}: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<Button
leftIcon={<IconFileImport size={18} />}
variant="outline"
iconSpacing={1}
size={"sm"}
colorScheme="teal"
onClick={() => {
fileInputRef.current?.click();
}}
>
Import
<ImportFlowsFileInput fileInputRef={fileInputRef} />
</Button>
);
}
+228 -59
View File
@@ -1,78 +1,247 @@
import { ChangeEvent, useContext, useRef } from "react";
import { batchCreateFlows } from "../db-tables/WorkspaceDB";
import { ChangeEvent, useContext, useRef, useState } from "react";
import { Button, Menu, MenuButton, MenuList, MenuItem } from "@chakra-ui/react";
import { IconFile, IconFileImport, IconFolder } from "@tabler/icons-react";
import { workflowsTable, foldersTable } from "../db-tables/WorkspaceDB";
import { RecentFilesContext } from "../WorkspaceContext";
import { getPngMetadata } from "../utils";
import { ImportWorkflow } from "./types";
import { getPngMetadata } from "../utils/mediaMetadataUtils";
import { ImportWorkflow, importMenuItemList } from "./types";
interface WorkflowFile {
name: string;
json: string;
}
interface TreeNode {
name: string;
isFile: boolean;
file?: WorkflowFile;
children?: TreeNode[];
parentFolderID?: string;
}
interface HTMLInputDirectoryElement extends HTMLInputElement {
webkitdirectory: boolean;
}
interface Props {
fileInputRef: React.RefObject<HTMLInputElement>;
onlyImportFile?: boolean;
parentFolderID?: string;
}
export default function ImportFlowsFileInput({
parentFolderID,
fileInputRef,
onlyImportFile = false,
}: Props) {
const { onRefreshFilesList } = useContext(RecentFilesContext);
const [importType, setImportType] = useState("file"); // file/folder
const fileInputRef = useRef<HTMLInputDirectoryElement>(null);
const onClick = (type: string) => {
setImportType(type);
if (type === "folder") {
fileInputRef.current!.setAttribute("webkitdirectory", "");
} else {
fileInputRef.current!.removeAttribute("webkitdirectory");
}
fileInputRef.current?.click();
};
const parseFile = async (file: File): Promise<WorkflowFile | null> => {
if (file.type === "image/png") {
const pngInfo = await getPngMetadata(file);
// @ts-expect-error
const flowJson = pngInfo?.workflow;
if (flowJson) {
return {
json: flowJson,
name: file.name.replace(".png", ""),
};
}
} else if (
file.type === "application/json" ||
file.name?.endsWith(".json")
) {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const result = e.target?.result;
if (typeof result === "string") {
resolve({
json: result,
name: file.name.replace(".json", ""),
});
} else {
reject(new Error("File content is not a string"));
}
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsText(file);
});
}
return null;
};
const createFileAndFolderFormTree = async (treeNode: TreeNode) => {
// Traverse the folders in the tree through queue breadth first, creating folders and files under the current folder in sequence;
const queue = [treeNode];
while (queue.length > 0) {
const currentNode = queue.shift();
if (currentNode && !currentNode.isFile) {
const folder = await foldersTable?.create({
name: currentNode.name,
parentFolderID: currentNode.parentFolderID,
});
if (currentNode.children && currentNode.children.length) {
const fileList: WorkflowFile[] = [];
currentNode.children.forEach((child) => {
if (child.isFile && child.file) {
fileList.push(child.file);
} else {
child.parentFolderID = folder?.id;
queue.push(child);
}
});
if (fileList.length) {
await workflowsTable?.batchCreateFlows(
fileList,
undefined,
folder?.id,
);
}
}
}
}
onRefreshFilesList?.();
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
const parsedFileList: ImportWorkflow[] = [];
for (const file of files) {
if (file.type === "image/png") {
const pngInfo = await getPngMetadata(file);
// @ts-expect-error
const flowJson = pngInfo?.workflow;
if (flowJson) {
parsedFileList.push({
json: flowJson,
name: file.name.replace(".png", ""),
});
}
} else if (
file.type === "application/json" ||
file.name?.endsWith(".json")
) {
await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const result = e.target?.result;
if (typeof result === "string") {
parsedFileList.push({
json: result,
name: file.name.replace(".json", ""),
});
resolve("");
} else {
reject(new Error("File content is not a string"));
}
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsText(file);
});
}
}
if (parsedFileList.length) {
await batchCreateFlows(parsedFileList, undefined, parentFolderID);
onRefreshFilesList && onRefreshFilesList();
if (importType === "folder") {
// Create a root node based on the selected directory
const rootNode: TreeNode = {
name: files[0].webkitRelativePath.split("/")[0],
isFile: false,
children: [],
};
// Create nested tree file structure from file path
for (const file of files) {
if (file.name !== ".DS_Store") {
const splitPath = file.webkitRelativePath.split("/");
let currentLevel = rootNode.children ?? [];
// Start traversing the second part of the path
for (let index = 1; index < splitPath.length; index++) {
const part = splitPath[index];
let existingNode = currentLevel.find(
(child) => child.name === part,
);
if (!existingNode) {
existingNode = {
name: part,
isFile: index === splitPath.length - 1,
children: index === splitPath.length - 1 ? undefined : [],
};
if (existingNode.isFile) {
const parseFileResult = await parseFile(file);
if (parseFileResult) {
existingNode.file = parseFileResult;
}
}
currentLevel.push(existingNode);
}
if (existingNode && !existingNode.isFile) {
currentLevel = existingNode.children!;
}
}
}
}
createFileAndFolderFormTree(rootNode);
} else {
const parsedFileList: ImportWorkflow[] = [];
for (const file of files) {
const parseFileResult = await parseFile(file);
if (parseFileResult) {
parsedFileList.push(parseFileResult);
}
}
if (parsedFileList.length) {
await workflowsTable?.batchCreateFlows(
parsedFileList,
undefined,
parentFolderID,
);
onRefreshFilesList && onRefreshFilesList();
}
}
};
return (
<input
style={{ display: "none" }}
ref={fileInputRef}
type="file"
accept=".json,image/png"
multiple
onChange={handleFileChange}
onClick={(e) => {
e.stopPropagation();
}}
/>
<>
{onlyImportFile ? (
<MenuItem
onClick={(e) => {
e.stopPropagation();
onClick("file");
}}
icon={<IconFileImport size={19} />}
>
Import workflows
</MenuItem>
) : (
<Menu closeOnSelect={true}>
<MenuButton
as={Button}
leftIcon={<IconFileImport size={18} />}
variant="outline"
iconSpacing={1}
size={"sm"}
colorScheme="teal"
>
Import
</MenuButton>
<MenuList>
{importMenuItemList.map((item, index) => (
<MenuItem
key={index}
onClick={() => {
onClick(item.type);
}}
iconSpacing={2}
icon={
item.type === "folder" ? (
<IconFolder size={18} />
) : (
<IconFile size={18} />
)
}
>
{item.title}
</MenuItem>
))}
</MenuList>
</Menu>
)}
<input
style={{ display: "none" }}
ref={fileInputRef}
type="file"
accept=".json,image/png"
multiple
onChange={handleFileChange}
onClick={(e) => {
e.stopPropagation();
}}
/>
</>
);
}
@@ -1,97 +1,18 @@
// @ts-nocheck
// these code are extracted and combined from LGraph.prototype.configure and LGCanvas.pasteFromClipboard
export function insertWorkflowToCanvas2(json: string, insertPos?: number[]) {
const clipboard_info = JSON.parse(json);
const nodes = clipboard_info.nodes;
// LiteGraph.use_uuids = true;
// calculate top-left node, could work without this processing but using diff with last node pos :: clipboard_info.nodes[clipboard_info.nodes.length-1].pos
var posMin = false;
var posMinIndexes = false;
for (var i = 0; i < clipboard_info.nodes.length; ++i) {
if (posMin) {
if (posMin[0] > clipboard_info.nodes[i].pos[0]) {
posMin[0] = clipboard_info.nodes[i].pos[0];
posMinIndexes[0] = i;
}
if (posMin[1] > clipboard_info.nodes[i].pos[1]) {
posMin[1] = clipboard_info.nodes[i].pos[1];
posMinIndexes[1] = i;
}
} else {
posMin = [clipboard_info.nodes[i].pos[0], clipboard_info.nodes[i].pos[1]];
posMinIndexes = [i, i];
}
}
var copy_nodes = {};
for (var i = 0; i < nodes.length; ++i) {
var node_data = nodes[i];
var newnode = LiteGraph.createNode(node_data.type);
if (!newnode) {
// missing custom node type
if (LiteGraph.debug) {
console.log("Node not found or has errors: " + node_data.type);
}
//in case of error we create a replacement node to avoid losing info
newnode = new LGraphNode();
newnode.last_serialization = node_data;
newnode.has_errors = true;
}
if (newnode) {
newnode.configure(node_data);
//paste in last known mouse position
let canvasPos = insertPos
? [insertPos[0] - 11, insertPos[1] - 11]
: app.canvas.graph_mouse;
newnode.pos[0] += canvasPos[0] - posMin[0]; //+= 5;
newnode.pos[1] += canvasPos[1] - posMin[1]; //+= 5;
app.graph.add(newnode, { doProcessChange: false });
copy_nodes[node_data.id] = newnode;
}
}
//create links
for (var i = 0; i < clipboard_info.links.length; ++i) {
var link_info = clipboard_info.links[i];
const origin_node_slot = link_info[2];
const target_node_slot = link_info[4];
let target_node = copy_nodes[link_info[3]];
// this is avoid disconnect in LGraphNode.prototype.connect (line 4295), causing insert same workflow again and it disconnects prior links:
// if there is something already plugged there, disconnect
// .... if (target_node.inputs[target_slot] && target_node.inputs[target_slot].link != null) { ....
target_node.inputs[target_node_slot] &&
target_node.inputs[target_node_slot].link != null;
let origin_node = copy_nodes[link_info[1]];
if (origin_node && target_node)
origin_node.connect(origin_node_slot, target_node, target_node_slot);
else {
console.error(
"Nodes missing on pasting",
"origin node",
origin_node?.serialize(),
"target node",
target_node.serialize()
);
}
}
}
export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
const clipboard_info = JSON.parse(json);
const old_nodes = clipboard_info.nodes;
const old_links = clipboard_info.links;
const LLink = LiteGraph.LLink;
// LiteGraph.use_uuids = true;
let old_new_node_ids_map: Record<string, string> = {};
const old_new_node_ids_map: Record<string, string> = {};
let last_node_id = app.graph.last_node_id;
for (let i = 0; i < old_nodes.length; i++) {
last_node_id++;
old_new_node_ids_map[old_nodes[i].id] = last_node_id;
}
let old_new_link_ids_map: Record<string, string> = {};
const old_new_link_ids_map: Record<string, string> = {};
let last_link_id = app.graph.last_link_id;
for (let i = 0; i < clipboard_info.links.length; i++) {
last_link_id++;
@@ -99,15 +20,15 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
}
// decode links
if (old_links && old_links.constructor === Array) {
var links = [];
const links = [];
for (var i = 0; i < old_links.length; ++i) {
const old_link = old_links[i];
let new_link = [...old_link];
const new_link = [...old_link];
new_link[0] = old_new_link_ids_map[old_link[0]];
new_link[1] = old_new_node_ids_map[old_link[1]];
new_link[3] = old_new_node_ids_map[old_link[3]];
// new_links.push(new_link);
var llink = new LLink();
const llink = new LLink();
llink.configure(new_link);
links[llink.id] = llink;
app.graph.links[llink.id] = llink;
@@ -115,8 +36,8 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
app.graph.last_link_id = last_link_id;
}
// calculate top-left node, could work without this processing but using diff with last node pos :: clipboard_info.nodes[clipboard_info.nodes.length-1].pos
var posMin = false;
var posMinIndexes = false;
let posMin = false;
let posMinIndexes = false;
for (var i = 0; i < clipboard_info.nodes.length; ++i) {
if (posMin) {
if (posMin[0] > clipboard_info.nodes[i].pos[0]) {
@@ -132,11 +53,11 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
posMinIndexes = [i, i];
}
}
var newNodes = [];
var newNodesInfo = {};
const newNodes = [];
const newNodesInfo = {};
for (var i = 0; i < old_nodes.length; ++i) {
var node_data = old_nodes[i];
var new_node_data = { ...node_data };
const node_data = old_nodes[i];
const new_node_data = { ...node_data };
new_node_data.id = old_new_node_ids_map[node_data.id];
new_node_data.inputs?.forEach((input) => {
input.link = old_new_link_ids_map[input.link];
@@ -146,13 +67,13 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
});
//paste in last known mouse position
let canvasPos = insertPos
const canvasPos = insertPos
? [insertPos[0] - 11, insertPos[1] - 11]
: app.canvas.graph_mouse;
new_node_data.pos[0] += canvasPos[0] - posMin[0]; //+= 5;
new_node_data.pos[1] += canvasPos[1] - posMin[1]; //+= 5;
var newnode = LiteGraph.createNode(new_node_data.type);
let newnode = LiteGraph.createNode(new_node_data.type);
if (!newnode) {
// missing custom node type
if (LiteGraph.debug) {
+88
View File
@@ -0,0 +1,88 @@
import { DragEvent, useContext, useEffect, useState } from "react";
import { RecentFilesContext } from "../WorkspaceContext";
import {
foldersTable,
isFolder,
workflowsTable,
} from "../db-tables/WorkspaceDB";
import { Folder, Workflow } from "../types/dbTypes";
import FilesListFolderItem from "./FilesListFolderItem";
import WorkflowListItem from "./WorkflowListItem";
import { Box } from "@chakra-ui/react";
import { userSettingsTable } from "../db-tables/WorkspaceDB";
export default function ItemsList({
items,
}: {
items: Array<Folder | Workflow>;
}) {
const [folderOnTop, setFolderOnTop] = useState(false);
const folders = items.filter(isFolder);
const workflows = items.filter((item): item is Workflow => !isFolder(item));
const parentFolderID = workflows[0]?.parentFolderID;
const [isDraggingOver, setIsDraggingOver] = useState(false);
const { onRefreshFilesList, draggingFile, refreshFolderStamp } =
useContext(RecentFilesContext);
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!draggingFile) return setIsDraggingOver(false);
if (isFolder(draggingFile)) {
if (draggingFile.id === parentFolderID) return setIsDraggingOver(false);
await foldersTable?.update(draggingFile.id, {
parentFolderID: parentFolderID,
});
} else {
await workflowsTable?.updateFolder(draggingFile.id, {
parentFolderID: parentFolderID,
});
}
await onRefreshFilesList?.();
setIsDraggingOver(false);
};
useEffect(() => {
userSettingsTable?.getSetting("foldersOnTop").then((res) => {
setFolderOnTop(res ?? false);
});
}, [refreshFolderStamp]);
return (
<>
{folderOnTop &&
folders.map((folder) => (
<FilesListFolderItem key={folder.id} folder={folder} />
))}
<Box
border={
isDraggingOver ? "2px dashed #718096" : "2px dashed transparent"
}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
}}
onDrop={handleDrop}
className="droppable"
>
{folderOnTop
? workflows.map((workflow) => (
<WorkflowListItem key={workflow.id} workflow={workflow} />
))
: items.map((n) => {
if (isFolder(n)) {
return <FilesListFolderItem folder={n} key={n.id} />;
}
return <WorkflowListItem key={n.id} workflow={n} />;
})}
</Box>
</>
);
}
+14 -8
View File
@@ -1,7 +1,5 @@
import {
Button,
HStack,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
@@ -12,11 +10,19 @@ import {
IconButton,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { Tag, Workflow, tagsTable, updateFlow } from "../db-tables/WorkspaceDB";
import { IconSettings, IconTrash } from "@tabler/icons-react";
import { tagsTable } from "../db-tables/WorkspaceDB";
import { IconTrash } from "@tabler/icons-react";
import { Tag } from "../types/dbTypes";
export default function ManageTagsModal({ onclose }: { onclose: () => void }) {
const [allTags, setAllTags] = useState<Tag[]>(tagsTable?.listAll() ?? []);
const [allTags, setAllTags] = useState<Tag[]>([]);
const loadTags = async () => {
const tags = await tagsTable?.listAll();
setAllTags(tags ?? []);
};
useEffect(() => {
loadTags();
}, []);
return (
<Modal isOpen={true} onClose={onclose}>
<ModalOverlay />
@@ -28,9 +34,9 @@ export default function ManageTagsModal({ onclose }: { onclose: () => void }) {
<HStack>
<ChakraTag>{tag.name}</ChakraTag>
<IconButton
onClick={() => {
tagsTable?.delete(tag.name);
setAllTags(tagsTable?.listAll() ?? []);
onClick={async () => {
await tagsTable?.delete(tag.name);
loadTags();
}}
aria-label="delete-tag"
colorScheme="red"
+140
View File
@@ -0,0 +1,140 @@
import { ChangeEvent, useContext, useRef, useState } from "react";
import {
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Tooltip,
useToast,
} from "@chakra-ui/react";
import {
IconCopy,
IconDotsVertical,
IconExternalLink,
IconLock,
IconLockOpen,
IconUpload,
} from "@tabler/icons-react";
import AddTagToWorkflowPopover from "./AddTagToWorkflowPopover";
import { Workflow } from "../types/dbTypes";
import { mediaTable, workflowsTable } from "../db-tables/WorkspaceDB";
import { WorkspaceContext } from "../WorkspaceContext";
import { openWorkflowInNewTab } from "../utils";
type Props = {
workflow: Workflow;
};
export default function MoreActionMenu({ workflow }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { onDuplicateWorkflow } = useContext(WorkspaceContext);
const [isLocked, setIsLocked] = useState(workflow.saveLock ?? false);
const onLockChange = async () => {
const newLocked = !isLocked!;
setIsLocked(newLocked);
await workflowsTable?.updateMetaInfo(workflow.id, {
saveLock: newLocked,
});
toast({
title: `${newLocked ? "Locked" : "Unlocked"} successfully`,
status: "success",
duration: 2000,
});
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
const body = new FormData();
Array.from(files).forEach((file, index) => {
body.append(`images[${index}]`, file, file.name);
});
body.append("subfolder", "workspace_manager");
await fetch("/workspace/images/save", {
method: "POST",
body,
});
Array.from(files).forEach(async (file) => {
const localPath = `workspace_manager/${file.name}`;
mediaTable?.create({
workflowID: workflow.id,
localPath: localPath,
});
workflowsTable?.updateMetaInfo(workflow.id, {
coverMediaPath: localPath,
});
});
};
return (
<>
<Menu isLazy closeOnSelect={false} placement="right" gutter={0}>
<MenuButton
border={0}
as={IconButton}
aria-label="Options"
icon={<IconDotsVertical size={20} />}
size={"sm"}
variant="outline"
/>
<MenuList zIndex={101}>
<MenuItem
icon={<IconCopy />}
onClick={() =>
onDuplicateWorkflow && onDuplicateWorkflow(workflow.id)
}
>
Duplicate
</MenuItem>
<MenuItem
icon={<IconExternalLink size={20} />}
onClick={() => openWorkflowInNewTab(workflow.id)}
>
Open in new tab
</MenuItem>
{/* <AddTagToWorkflowPopover workflow={workflow} /> */}
<Tooltip
hasArrow
label={
isLocked
? "Unlock workflow, allow modifying"
: "Lock workflow, prevent modifying"
}
placement="bottom"
>
<MenuItem
icon={isLocked ? <IconLock /> : <IconLockOpen />}
onClick={onLockChange}
>
{isLocked ? "Unlock" : "Lock"}
</MenuItem>
</Tooltip>
<MenuItem
icon={<IconUpload />}
onClick={() => {
fileInputRef.current?.click();
}}
>
Upload Thumbnail Image
</MenuItem>
</MenuList>
</Menu>
<input
style={{ display: "none" }}
ref={fileInputRef}
type="file"
accept=".json,image/png"
multiple
onChange={handleFileChange}
onClick={(e) => {
e.stopPropagation();
}}
/>
</>
);
}
@@ -1,15 +1,9 @@
import { Checkbox, HStack, IconButton, Text, Tooltip } from "@chakra-ui/react";
import {
IconListCheck,
IconX,
IconFileExport,
IconDownload,
} from "@tabler/icons-react";
import { IconListCheck, IconX, IconDownload } from "@tabler/icons-react";
import DeleteConfirm from "../components/DeleteConfirm";
import { ChangeEvent } from "react";
import { batchDeleteFlow, listWorkflows } from "../db-tables/WorkspaceDB";
import JSZip from "JSZip";
import { formatTimestamp } from "../utils";
import { workflowsTable } from "../db-tables/WorkspaceDB";
import { downloadWorkflowsZip } from "../utils/downloadWorkflowsZip";
type Props = {
multipleState: boolean;
@@ -28,24 +22,9 @@ export default function MultipleSelectionOperation(props: Props) {
batchOperationCallback,
} = props;
const batchExport = () => {
const selectedList = listWorkflows().filter((flow) =>
selectedKeys.includes(flow.id)
);
const exportName = `ComfyUI workflow ${formatTimestamp(Date.now())}`;
const zip = new JSZip();
const folder = zip.folder(exportName);
selectedList.forEach((flow) => {
folder && folder.file(`${flow.name}.json`, flow.json);
});
zip.generateAsync({ type: "blob" }).then(function (content) {
const a = document.createElement("a");
a.href = window.URL.createObjectURL(content);
a.download = `${exportName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
const batchExport = async () => {
const selectedList = (await workflowsTable?.batchQuery(selectedKeys)) ?? [];
downloadWorkflowsZip(selectedList);
};
const notChecked = selectedKeys.length === 0;
@@ -75,8 +54,8 @@ export default function MultipleSelectionOperation(props: Props) {
variant="solid"
promptMessage={`Are you sure you want to delete these ${selectedKeys.length} checked workflows?`}
tooltipText="Delete selected"
onDelete={() => {
batchDeleteFlow(selectedKeys);
onDelete={async () => {
await workflowsTable?.batchDeleteFlow(selectedKeys);
batchOperationCallback("batchDelete");
}}
/>
+60
View File
@@ -0,0 +1,60 @@
import { Button, HStack, IconButton } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { Tag } from "../types/dbTypes";
import { tagsTable } from "../db-tables/WorkspaceDB";
import { IconChevronDown, IconChevronUp, IconX } from "@tabler/icons-react";
const MAX_TAGS_TO_SHOW = 6;
interface Props {
selectedTag?: string;
setSelectedTag: (tag?: string) => void;
}
export default function MyTagsRow({ setSelectedTag, selectedTag }: Props) {
const [showAllTags, setShowAllTags] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
tagsTable?.listAll().then((tags) => {
setTags(tags);
});
}, []);
return (
<HStack spacing={2} wrap={"wrap"} mb={0}>
{selectedTag != null && (
<IconButton
aria-label="Close"
size={"sm"}
icon={<IconX />}
onClick={() => {
setSelectedTag(undefined);
}}
/>
)}
{tags.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW).map((tag) => (
<Button
key={tag.name}
variant="solid"
width={"auto"}
flexShrink={0}
size={"sm"}
borderRadius={15}
py={4}
onClick={() => {
setSelectedTag(tag.name);
}}
isActive={selectedTag === tag.name}
>
{tag.name}
</Button>
))}
{(tags.length ?? 0) > MAX_TAGS_TO_SHOW && (
<IconButton
aria-label="Show-all-tags"
size={"sm"}
icon={showAllTags ? <IconChevronUp /> : <IconChevronDown />}
onClick={() => setShowAllTags(!showAllTags)}
/>
)}
</HStack>
);
}
+79 -119
View File
@@ -13,42 +13,34 @@ import {
Flex,
Tooltip,
} from "@chakra-ui/react";
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, useState, useRef, useCallback, useContext } from "react";
import {
Workflow,
deleteFlow,
workflowsTable,
isFolder,
listWorkflows,
listFolderContent,
tagsTable,
workspace,
foldersTable,
} from "../db-tables/WorkspaceDB";
import {
IconChevronDown,
IconChevronUp,
IconFolderPlus,
IconPlus,
IconX,
IconFolder,
} from "@tabler/icons-react";
import { RecentFilesContext } from "../WorkspaceContext";
import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext";
import RecentFilesDrawerMenu from "./RecentFilesDrawerMenu";
import { sortFileItem } from "../utils";
import WorkflowListItem from "./WorkflowListItem";
import { insertWorkflowToCanvas, sortFileItem } from "../utils";
import MultipleSelectionOperation from "./MultipleSelectionOperation";
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
// @ts-ignore
// @ts-expect-error ComfyUI import
import { app } from "/scripts/app.js";
import { insertWorkflowToCanvas3 } from "./InsertWorkflowToCanvas";
import FilesListFolderItem from "./FilesListFolderItem";
import { useDebounce } from "../customHooks/useDebounce";
import SearchInput from "../components/SearchInput";
import { openWorkflowsFolder } from "../Api";
import { Folder } from "../types/dbTypes";
import ImportFileButton from "./ImportFileButton";
import { Folder, Workflow } from "../types/dbTypes";
import MyTagsRow from "./MyTagsRow";
import ImportFlowsFileInput from "./ImportFlowsFileInput";
import ItemsList from "./ItemsList";
import { DRAWER_Z_INDEX } from "../const";
const MAX_TAGS_TO_SHOW = 6;
type Props = {
onClose: () => void;
onClickNewFlow: () => void;
@@ -58,11 +50,8 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
const [currentRenderingData, setCurrentRenderingData] = useState<
Array<Folder | Workflow>
>([]);
const aloneFlowsAndFoldersRef = useRef<Array<Folder | Workflow>>([]);
const allFlowsRef = useRef<Array<Workflow>>([]);
const [selectedTag, setSelectedTag] = useState<string>();
const [showAllTags, setShowAllTags] = useState(false);
const [multipleState, setMultipleState] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [refreshFolderStamp, setRefreshFolderStamp] = useState(0);
@@ -70,45 +59,39 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
const debounceSearchValue = useDebounce(searchValue, 400);
const draggingWorkflowID = useRef<string | null>(null);
const [draggingFile, setDraggingFile] = useState<Workflow | Folder | null>(
null
null,
);
const sortTypeRef = useRef<ESortTypes>(
(window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes) ??
ESortTypes.RECENTLY_MODIFIED
ESortTypes.RECENTLY_MODIFIED,
);
const isFilter = !!selectedTag || !!debounceSearchValue.length;
const loadLatestWorkflows = () => {
const all = listFolderContent();
aloneFlowsAndFoldersRef.current = all;
allFlowsRef.current = listWorkflows();
filterFlows();
const loadLatestWorkflows = async () => {
const curFolderContent = (await workflowsTable?.listFolderContent()) ?? [];
setCurrentRenderingData(
sortFileItem(curFolderContent, sortTypeRef.current),
);
setRefreshFolderStamp(Date.now());
allFlowsRef.current = (await workflowsTable?.listAll()) ?? [];
};
const isFilter = !!selectedTag || !!debounceSearchValue;
const filterFlows = () => {
if (isFilter) {
const filterResult = allFlowsRef.current.filter((flow) => {
let tagFlag = true;
let keywordFlag = true;
if (selectedTag) {
tagFlag = !!flow.tags?.includes(selectedTag);
}
if (debounceSearchValue) {
keywordFlag = !!flow.name
.toLocaleLowerCase()
.includes(debounceSearchValue.toLocaleLowerCase());
}
const filterResult = allFlowsRef.current.filter((flow) => {
let tagFlag = true;
let keywordFlag = true;
if (selectedTag) {
tagFlag = !!flow.tags?.includes(selectedTag);
}
if (debounceSearchValue) {
keywordFlag = !!flow.name
.toLocaleLowerCase()
.includes(debounceSearchValue.toLocaleLowerCase());
}
return tagFlag && keywordFlag;
});
setCurrentRenderingData(sortFileItem(filterResult, sortTypeRef.current));
} else {
setCurrentRenderingData(
sortFileItem(aloneFlowsAndFoldersRef.current, sortTypeRef.current)
);
}
return tagFlag && keywordFlag;
});
setCurrentRenderingData(sortFileItem(filterResult, sortTypeRef.current));
};
const onSort = (type: ESortTypes) => {
@@ -119,26 +102,29 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
};
useEffect(() => {
filterFlows();
if (isFilter) {
filterFlows();
} else {
loadLatestWorkflows();
}
}, [debounceSearchValue, selectedTag]);
const onDelete = useCallback(
(id: string) => {
deleteFlow(id);
loadLatestWorkflows();
async (id: string) => {
await workflowsTable?.deleteFlow(id);
await loadLatestWorkflows();
},
[selectedTag, debounceSearchValue]
[selectedTag, debounceSearchValue],
);
useEffect(() => {
loadLatestWorkflows();
const handleDrop = (e: any) => {
workspace &&
draggingWorkflowID.current &&
insertWorkflowToCanvas3(workspace[draggingWorkflowID.current].json, [
e.canvasX,
e.canvasY,
]);
const handleDrop = async (e: { canvasX: number; canvasY: number }) => {
if (draggingWorkflowID.current) {
const flow = await workflowsTable?.get(draggingWorkflowID.current);
flow &&
flow.json &&
insertWorkflowToCanvas(flow.json, [e.canvasX, e.canvasY]);
}
};
app.canvasEl.addEventListener("drop", handleDrop);
@@ -171,7 +157,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
return;
case "selectAll":
setSelectedKeys(
value ? allFlowsRef.current.map((flow) => flow.id) : []
value ? allFlowsRef.current.map((flow) => flow.id) : [],
);
return;
}
@@ -188,7 +174,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
setSearchValue(newValue);
};
const DRAWER_WIDTH = 440;
const DRAWER_WIDTH = 450;
return (
<RecentFilesContext.Provider
value={{
@@ -207,28 +193,28 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
<Card
width={DRAWER_WIDTH}
height={"100vh"}
pl={4}
pr={5}
pl={2}
pr={2}
pt={4}
gap={4}
position={"absolute"}
top={0}
left={0}
shadow={"xl"}
zIndex={1000}
zIndex={DRAWER_Z_INDEX}
// boxShadow={"rgba(200, 200, 200, 0.4) 1px 4px 8px 1px"}
>
<Flex alignItems={"center"} justifyContent={"space-between"}>
<HStack gap={4}>
<HStack gap={2}>
<Text fontSize={19} fontWeight={600}>
☕️Workspace
🦄Workspace
</Text>
{/* <Tooltip label="Login to share and sync your workflows to cloud">
<Link onClick={openCognitoPopup}>Login</Link>
</Tooltip> */}
</HStack>
<ImportFileButton />
<ImportFlowsFileInput />
<Tooltip label="Open workspace save directory">
<IconButton
aria-label="Open workspace save directory"
@@ -256,45 +242,10 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
</HStack>
</Flex>
<Flex direction="column" h="calc(100% - 64px)">
<HStack spacing={2} wrap={"wrap"} mb={0}>
{selectedTag != null && (
<IconButton
aria-label="Close"
size={"sm"}
icon={<IconX />}
onClick={() => {
setSelectedTag(undefined);
}}
/>
)}
{tagsTable
?.listAll()
.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW)
.map((tag) => (
<Button
variant="solid"
width={"auto"}
flexShrink={0}
size={"sm"}
borderRadius={15}
py={4}
onClick={() => {
setSelectedTag(tag.name);
}}
isActive={selectedTag === tag.name}
>
{tag.name}
</Button>
))}
{(tagsTable?.listAll().length ?? 0) > MAX_TAGS_TO_SHOW && (
<IconButton
aria-label="Show-all-tags"
size={"sm"}
icon={showAllTags ? <IconChevronUp /> : <IconChevronDown />}
onClick={() => setShowAllTags(!showAllTags)}
/>
)}
</HStack>
<MyTagsRow
selectedTag={selectedTag}
setSelectedTag={setSelectedTag}
/>
<HStack mb={2} mt={2} p={0} justifyContent="space-between">
<HStack>
{allFlowsRef.current.length > 0 ? (
@@ -319,8 +270,8 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
size={"sm"}
variant={"outline"}
icon={<IconFolderPlus size={21} />}
onClick={() => {
foldersTable?.create({
onClick={async () => {
await foldersTable?.create({
name: "New Folder",
});
loadLatestWorkflows();
@@ -349,7 +300,12 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
type="radio"
onChange={(type) => onSort(type as ESortTypes)}
>
{Object.values(ESortTypes).map((sortType, index) => (
{[
ESortTypes.RECENTLY_MODIFIED,
ESortTypes.OLDEST_MODIFIED,
ESortTypes.AZ,
ESortTypes.ZA,
].map((sortType, index) => (
<MenuItemOption key={index} value={sortType}>
{sortType}
</MenuItemOption>
@@ -362,13 +318,17 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
searchValue={searchValue}
onUpdateSearchValue={onUpdateSearchValue}
/>
<Flex overflowY={"auto"} overflowX={"hidden"} direction="column">
{currentRenderingData.map((n) => {
if (isFolder(n)) {
return <FilesListFolderItem folder={n} key={n.id} />;
}
return <WorkflowListItem key={n.id} workflow={n} />;
})}
<Flex
overflowY={"auto"}
overflowX={"hidden"}
direction="column"
style={{
overflowY: "scroll",
scrollbarWidth: "none", // For Firefox
msOverflowStyle: "none", // For Internet Explorer and Edge
}}
>
<ItemsList items={currentRenderingData} />
</Flex>
</Flex>
</Card>
+52 -95
View File
@@ -5,33 +5,26 @@ import {
Text,
Checkbox,
Flex,
Image,
Stack,
} from "@chakra-ui/react";
import { Workflow, isFolder, updateFlow } from "../db-tables/WorkspaceDB";
import { formatTimestamp, isImageFormat } from "../utils";
import AddTagToWorkflowPopover from "./AddTagToWorkflowPopover";
import { useState, memo, ChangeEvent, useContext } from "react";
import WorkflowListItemRightClickMenu from "./WorkflowListItemRightClickMenu";
import DeleteConfirm from "../components/DeleteConfirm";
import { IconLock } from "@tabler/icons-react";
import { formatTimestamp } from "../utils";
import { memo, ChangeEvent, useContext, useState } from "react";
import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext";
import { Workflow } from "../types/dbTypes";
import MediaPreview from "../components/MediaPreview";
import WorkflowListItemActionButtons from "./WorkflowListItemActionButtons";
import { userSettingsTable } from "../db-tables/WorkspaceDB";
type Props = {
workflow: Workflow;
};
export default function WorkflowListItem({ workflow }: Props) {
export default memo(function WorkflowListItem({ workflow }: Props) {
const { colorMode } = useColorMode();
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const [isMenuOpen, setIsMenuOpen] = useState(false);
const {
setDraggingFile,
isMultiSelecting,
onMultiSelectFlow,
onDeleteFlow,
multiSelectedFlowsID,
onRefreshFilesList,
draggingFile,
} = useContext(RecentFilesContext);
const isChecked =
multiSelectedFlowsID &&
@@ -39,48 +32,19 @@ export default function WorkflowListItem({ workflow }: Props) {
multiSelectedFlowsID.includes(workflow.id);
const { curFlowID, loadWorkflowID } = useContext(WorkspaceContext);
const isSelected = curFlowID === workflow.id;
const handleContextMenu = (event: any) => {
event.preventDefault();
setMenuPosition({ x: event.clientX, y: event.clientY });
setIsMenuOpen(true);
};
const handleClose = () => {
setIsMenuOpen(false);
};
const hoverBgColor = colorMode === "light" ? "gray.200" : "#4A5568";
const [isHovering, setIsHovering] = useState(false);
const basicInfoComp = (
<Box
flexShrink={1}
flexGrow={1}
onDragOver={(e) => {
e.preventDefault();
setIsDraggingOver(true);
}}
onDragLeave={() => {
setIsDraggingOver(false);
}}
onDrop={() => {
if (draggingFile && !isFolder(draggingFile)) {
updateFlow(draggingFile.id, {
parentFolderID: workflow.parentFolderID,
});
onRefreshFilesList && onRefreshFilesList();
}
setIsDraggingOver(false);
}}
backgroundColor={
isSelected ? "teal.200" : isMenuOpen ? hoverBgColor : undefined
}
backgroundColor={isSelected ? "teal.200" : undefined}
color={isSelected && !isMultiSelecting ? "#333" : undefined}
draggable={!isMultiSelecting}
onDragStart={(e) => {
setDraggingFile && setDraggingFile(workflow);
}}
onDragStart={() => setDraggingFile?.(workflow)}
borderRadius={6}
px={1}
py={1}
py={"5px"}
onClick={() => {
!isMultiSelecting && loadWorkflowID(workflow.id);
}}
@@ -88,46 +52,46 @@ export default function WorkflowListItem({ workflow }: Props) {
bg: hoverBgColor,
}}
>
<HStack>
{workflow.coverMediaPath != null &&
(isImageFormat(workflow.coverMediaPath) ? (
<Image
borderRadius={3}
boxSize="60px"
objectFit="cover"
src={`/workspace/view_media?filename=${workflow.coverMediaPath}`}
alt={workflow.name ?? "workflow cover image"}
<HStack flexGrow={1}>
{!userSettingsTable?.settings?.hideCoverImage &&
(workflow.coverMediaPath?.length || workflow.latestImage?.length) && (
<MediaPreview
mediaLocalPath={
workflow.coverMediaPath ?? workflow.latestImage ?? ""
}
size={60}
hideBrokenImage
isPreview
/>
) : (
<video
width={60}
height={60}
src={`/workspace/view_media?filename=${workflow.coverMediaPath}`}
muted={true}
>
<track kind="captions" />
</video>
))}
)}
<Stack textAlign={"left"} gap={0}>
<Text fontWeight={"500"}>{workflow.name ?? "untitled"}</Text>
<Text color={"GrayText"} ml={2} fontSize={"sm"}>
Updated: {formatTimestamp(workflow.updateTime)}
</Text>
</Stack>
<Box
textAlign={"left"}
gap={0}
justifyContent={"space-between"}
flexGrow={1}
>
<Flex alignItems={"center"}>
<Text fontWeight={"500"} noOfLines={1} wordBreak={"break-all"}>
{workflow.name ?? "untitled"}
</Text>
{workflow.saveLock && (
<IconLock size={18} style={{ display: "inline-block" }} />
)}
</Flex>
</Box>
</HStack>
{isDraggingOver && (
<Box width={"100%"} mt={2} height={"2px"} backgroundColor={"#4299E1"} />
)}
</Box>
);
return (
<HStack
w={"100%"}
mb={1}
justify={"space-between"}
onContextMenu={handleContextMenu}
borderBottom={"1px solid"}
borderColor={colorMode == "dark" ? "gray.900" : "gray.200"}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{isMultiSelecting ? (
<Checkbox
@@ -143,24 +107,17 @@ export default function WorkflowListItem({ workflow }: Props) {
) : (
<>
{basicInfoComp}
<Flex width={"60px"}>
<AddTagToWorkflowPopover workflow={workflow} />
<DeleteConfirm
promptMessage="Are you sure you want to delete this workflow?"
onDelete={() => {
onDeleteFlow && onDeleteFlow(workflow.id);
}}
/>
</Flex>
<Box width={"fit-content"}>
{isHovering ? (
<WorkflowListItemActionButtons workflow={workflow} />
) : (
<Text color={"GrayText"} ml={"2px"} fontSize={"sm"}>
{formatTimestamp(workflow.updateTime, false, false, "/")}
</Text>
)}
</Box>
</>
)}
{isMenuOpen && (
<WorkflowListItemRightClickMenu
menuPosition={menuPosition}
onClose={handleClose}
workflowID={workflow.id}
/>
)}
</HStack>
);
}
});
@@ -0,0 +1,25 @@
import { Flex, IconButton, Tooltip } from "@chakra-ui/react";
import DeleteConfirm from "../components/DeleteConfirm";
import MoreActionMenu from "./MoreActionMenu";
import { useContext } from "react";
import { RecentFilesContext } from "../WorkspaceContext";
import { Workflow } from "../types/dbTypes";
export default function WorkflowListItemActionButtons({
workflow,
}: {
workflow: Workflow;
}) {
const { onDeleteFlow } = useContext(RecentFilesContext);
return (
<Flex justifyContent={"flex-end"}>
<DeleteConfirm
promptMessage="Are you sure you want to delete this workflow?"
onDelete={() => {
onDeleteFlow && onDeleteFlow(workflow.id);
}}
/>
<MoreActionMenu workflow={workflow} />
</Flex>
);
}
@@ -1,33 +0,0 @@
import { Box, Menu, MenuList, MenuItem } from "@chakra-ui/react";
import { useContext } from "react";
import { WorkspaceContext } from "../WorkspaceContext";
type Props = {
menuPosition: { x: number; y: number };
onClose: () => void;
workflowID: string;
};
export default function WorkflowListItemRightClickMenu({
menuPosition,
workflowID,
onClose,
}: Props) {
const { onDuplicateWorkflow } = useContext(WorkspaceContext);
return (
<>
<Box position="absolute" top={menuPosition.y} left={menuPosition.x}>
<Menu isOpen={true} onClose={onClose}>
<MenuList>
<MenuItem
onClick={() =>
onDuplicateWorkflow && onDuplicateWorkflow(workflowID)
}
>
Duplicate
</MenuItem>
</MenuList>
</Menu>
</Box>
</>
);
}
@@ -6,156 +6,22 @@ import {
ModalHeader,
ModalBody,
ModalCloseButton,
Text,
StackDivider,
VStack,
Box,
Tag,
IconButton,
Button,
Flex,
Input,
Stack,
useToast,
SimpleGrid,
} from "@chakra-ui/react";
import { IconEdit, IconFolder } from "@tabler/icons-react";
import { useRef, useState } from "react";
import { getSystemDir } from "../Api";
import { userSettingsTable } from "../db-tables/WorkspaceDB";
import { ShortcutSettings } from "../settings/ShortcutSettings";
import { validateOrSaveAllJsonFileMyWorkflows } from "../utils";
import AutoSaveSettings from "../settings/AutoSaveSettings";
import FolderOnTopSettings from "../settings/FolderOnTopSettings";
import TwoWaySyncSettings from "../settings/TwoWaySyncSettings";
import CommonCheckboxSettings from "../settings/CommonCheckboxSettings";
import SelectMyWorkflowsDir from "../settings/SelectMyWorkflowsDir";
import { CommonNumberSetting } from "../settings/CommonNumberSetting";
import AutosaveSetting from "../settings/AutosaveSetting";
export default function WorkspaceSettingsModal({
onClose,
}: {
onClose: () => void;
}) {
const [isManualEntry, setIsManualEntry] = useState(false);
const [isEditDirectory, setIsEditDirectory] = useState(false);
const [currentDirectory, setCurrentDirectory] = useState(
userSettingsTable?.records.myWorkflowsDir
);
const [dirPathList, setDirPathList] = useState<string[]>([]);
const [subdirectoryList, setSubdirectoryList] = useState<string[]>([]);
const [noPermission, setNoPermission] = useState(false);
const manualEntryRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const getDir = async (root?: string) => {
const {
dir_path = "",
dir_contents = [],
error = "",
} = await getSystemDir(root ?? "");
if (error.includes("Operation not permitted")) {
setNoPermission(true);
onCloseEditDirectory();
return {
isError: true,
};
} else {
return {
dirList: dir_contents,
dirPath: dir_path,
};
}
};
const onOpenEditDirectory = async () => {
const { isError = false, dirList = [] } = await getDir(currentDirectory);
if (!isError) {
setSubdirectoryList(dirList);
setIsEditDirectory(true);
setDirPathList(
(currentDirectory && currentDirectory.split("/").filter((p) => !!p)) ||
[]
);
}
};
const onCloseEditDirectory = () => {
setIsEditDirectory(false);
setIsManualEntry(false);
setDirPathList([]);
};
const onSaveDirectory = async (manualEntry?: string) => {
if (manualEntry) {
const { error = "" } = await getSystemDir(manualEntry);
if (error.includes("Not a directory")) {
toast({
title:
"The directory address you entered is invalid, please re-enter it.",
status: "warning",
duration: 5000,
position: "top",
isClosable: true,
});
return;
}
}
const newPath = manualEntry ?? `/${dirPathList.join("/")}`;
userSettingsTable?.upsert({
myWorkflowsDir: newPath,
});
setCurrentDirectory(userSettingsTable?.getSetting("myWorkflowsDir"));
manualEntry && setNoPermission(false);
onCloseEditDirectory();
// to update /my_workflows files in disk to new location
validateOrSaveAllJsonFileMyWorkflows();
};
const onReset = async () => {
const { dirPath, dirList } = await getDir("");
setDirPathList(dirPath.split("/").filter((p: string) => !!p));
setSubdirectoryList(dirList);
};
const onDirJump = async (index: number, nextDir?: string) => {
let nextDirPath = "";
if (index === -1 && nextDir) {
nextDirPath = `/${dirPathList.concat([nextDir]).join("/")}`;
} else {
nextDirPath = `/${dirPathList.slice(0, index + 1).join("/")}`;
}
const { isError = false, dirList = [] } = await getDir(nextDirPath);
if (!isError) {
setSubdirectoryList(dirList);
setDirPathList(nextDirPath.slice(1)?.split("/"));
}
};
const onOpenEnter = () => {
setIsEditDirectory(true);
setIsManualEntry(true);
};
const enterFolderAddressComp = (
<Box textAlign="end" mb={4}>
<Input
placeholder="Please enter the absolute path directory address"
ref={manualEntryRef}
mt={2}
mb={2}
/>
<Button
colorScheme="teal"
mr={3}
size="sm"
onClick={() => {
manualEntryRef?.current?.value &&
onSaveDirectory(manualEntryRef?.current?.value);
}}
>
Save
</Button>
</Box>
);
return (
<>
<Modal isOpen={true} onClose={onClose} size={"xl"}>
@@ -171,150 +37,30 @@ export default function WorkspaceSettingsModal({
align="stretch"
w="100%"
>
<Box mb={4}>
<Text mb={2} fontWeight={500}>
Workspace Save Directory
</Text>
{!isEditDirectory ? (
<Flex>
<Tag overflowWrap="anywhere">{currentDirectory}</Tag>
<IconButton
aria-label="Enter folder address"
size={"sm"}
ml={2}
icon={<IconEdit />}
onClick={onOpenEnter}
/>
<IconButton
aria-label="Select folder"
size={"sm"}
ml={2}
icon={<IconFolder />}
onClick={onOpenEditDirectory}
/>
</Flex>
) : isManualEntry ? (
enterFolderAddressComp
) : (
<VStack spacing={2} alignItems="start">
<Stack spacing="2px" direction="row" wrap="wrap">
{dirPathList.map((dir, index) => (
<>
/
<Button
colorScheme="teal"
variant="link"
minW={1}
key={`${dir}-${index}`}
onClick={() => {
onDirJump(index);
}}
>
{dir}
</Button>
</>
))}
</Stack>
<SimpleGrid
columns={2}
spacing={3}
maxHeight={500}
width="100%"
overflowY="auto"
>
{subdirectoryList.map((sub, index) => (
<Button
key={`${sub}-${index}`}
size="sm"
leftIcon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#EFC54D"
style={{ width: "1.2rem", height: "1.2rem" }}
>
<path d="M19.5 21a3 3 0 0 0 3-3v-4.5a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3V18a3 3 0 0 0 3 3h15ZM1.5 10.146V6a3 3 0 0 1 3-3h5.379a2.25 2.25 0 0 1 1.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 0 1 3 3v1.146A4.483 4.483 0 0 0 19.5 9h-15a4.483 4.483 0 0 0-3 1.146Z" />
</svg>
}
justifyContent="start"
onClick={() => {
onDirJump(-1, sub);
}}
>
{sub}
</Button>
))}
</SimpleGrid>
<Box w="100%" textAlign="end">
<Button
colorScheme="teal"
size="sm"
onClick={() => {
onSaveDirectory();
}}
>
Save
</Button>
<Button size="sm" onClick={onReset} mr={2} ml={2}>
Reset
</Button>
<Button size="sm" onClick={onCloseEditDirectory}>
Cancel
</Button>
</Box>
</VStack>
)}
</Box>
<SelectMyWorkflowsDir />
<AutosaveSetting />
<ShortcutSettings />
<AutoSaveSettings />
<TwoWaySyncSettings />
<FolderOnTopSettings />
<CommonCheckboxSettings
settingKey="overwriteCurWorkflowWhenDroppingFileToCanvas"
text="Overwrite current workflow when drag and drop workflow file to canvas"
/>
<CommonNumberSetting
label={`Maximum number of save change history to store. This does not include
versions that you created by "Create Version", which are always stored
and has no limit.`}
settingKey="maximumChangelogNumber"
/>
<CommonCheckboxSettings
settingKey="hideCoverImage"
text="Hide thumbnail image in workflow list"
/>
</VStack>
</HStack>
</ModalBody>
</ModalContent>
</Modal>
<Modal
isOpen={noPermission}
onClose={() => setNoPermission(false)}
size={"xl"}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Kind tips</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Box>
{navigator.userAgent.indexOf("Mac OS X") !== -1 ? (
<Text fontWeight={500}>
There is no permission to access the folder. You need to grant
full disk access permission to the terminal that starts the
ComfyUI program. For details, refer to the
<Button
colorScheme="teal"
variant="link"
as="a"
ml={2}
target="_block"
href="https://stackoverflow.com/questions/58479686/permissionerror-errno-1-operation-not-permitted-after-macos
-catalina-update"
>
Document
</Button>
, or you can manually enter the absolute path of the folder in
the input box below to save.
</Text>
) : (
<Text fontWeight={500}>
You do not have permission to access the folder. It is
recommended that you manually enter the absolute path address
of the required folder.
</Text>
)}
{enterFolderAddressComp}
</Box>
</ModalBody>
</ModalContent>
</Modal>
</>
);
}
+22
View File
@@ -4,6 +4,10 @@
export const sortTypeLocalStorageKey = "CWM_WORKFLOWS_SORT_TYPE";
export enum ESortTypes {
/**
* Sort by last opened time, most recently modified first
*/
RECENTLY_OPENED = "recentlyOpened",
/**
* Sort by update time, most recently modified first
*/
@@ -26,3 +30,21 @@ export type ImportWorkflow = {
json: string;
name?: string;
};
export const importMenuItemList = [
{
title: "Import Workflows",
type: "file",
},
{
title: "Import Folder",
type: "folder",
},
];
// Solve TS errors caused by webkitdirectory
declare module "react" {
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
webkitdirectory?: string;
}
}
+22 -9
View File
@@ -1,27 +1,40 @@
import { createContext } from "react";
import { Workflow } from "./db-tables/WorkspaceDB";
import { Route } from "./utils";
import { Folder } from "./types/dbTypes";
import { Folder, Workflow, WorkflowVersion } from "./types/dbTypes";
import { WorkspaceRoute } from "./types/types";
export type JsonDiff = {
old: Object;
new: Object;
} | null;
export const WorkspaceContext = createContext<{
curFlowID: string | null;
onDuplicateWorkflow?: (flowID: string, newFlowName?: string) => void;
loadWorkflowID: (id: string) => void;
saveCurWorkflow: () => void;
discardUnsavedChanges: () => void;
loadWorkflowID: (id: string | null, versionID?: string | null) => void;
setIsDirty: (dirty: boolean) => void;
saveCurWorkflow: (saveCurWorkflow?: string) => Promise<void>;
discardUnsavedChanges: () => Promise<void>;
isDirty: boolean;
loadNewWorkflow: (input?: { json: string; name?: string }) => void;
loadFilePath: (path: string, overwriteCurrent?: boolean) => void;
setRoute: (route: Route) => void;
setRoute: (route: WorkspaceRoute) => void;
route: WorkspaceRoute;
curVersion: WorkflowVersion | null;
setCurVersion: (version: WorkflowVersion | null) => void;
setCurFlowIDAndName: (workflow: Workflow) => void;
}>({
curFlowID: null,
loadWorkflowID: () => {},
saveCurWorkflow: () => {},
discardUnsavedChanges: () => {},
saveCurWorkflow: async () => {},
discardUnsavedChanges: async () => {},
isDirty: false,
loadNewWorkflow: () => {},
loadFilePath: () => {},
setRoute: () => {},
route: "root",
curVersion: null,
setIsDirty: () => {},
setCurVersion: () => {},
setCurFlowIDAndName: () => {},
});
export const RecentFilesContext = createContext<{
+271
View File
@@ -0,0 +1,271 @@
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import { indexdb } from "../db-tables/indexdb";
import { Workflow } from "../types/dbTypes";
import {
getWorkflowRelPath,
joinRelPath,
sanitizeRelPath,
} from "../utils/OsPathUtils";
export namespace TwowaySyncAPI {
export async function moveWorkflow(
workflow: Workflow,
newParentFolderRelPath: string,
) {
try {
const response = await fetch("/workspace/file/move", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: getWorkflowRelPath(workflow),
newParentPath: newParentFolderRelPath,
}),
});
const result = await response.json();
if (result.error) {
alert("Error moving file: " + result.error);
return false;
}
return true;
} catch (error) {
console.error("Error deleting file:", error);
}
}
export async function genFileUniqueName(
fileName: string,
parentFolderID: string | null,
): Promise<string | null> {
try {
const response = await fetch("/workspace/file/gen_unique_name", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: joinRelPath(parentFolderID ?? "", fileName),
}),
});
const result = await response.json();
if (result.error) {
console.error("Error getting unique name:", result.error);
return null;
}
return result.uniqueName;
} catch (error) {
console.error("Error deleting file:", error);
}
return null;
}
export async function renameWorkflow(
{ parentFolderID, name }: Pick<Workflow, "name" | "parentFolderID">,
newName: string,
) {
try {
const response = await fetch("/workspace/file/rename", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: joinRelPath(parentFolderID ?? "", name + ".json"),
newName: newName,
}),
});
const result = await response.json();
if (result.error) {
alert("Error rename file: " + result.error);
return false;
}
return true;
} catch (error) {
console.error("Error deleting file:", error);
}
}
interface DuplicatesResponse {
duplicates: {
[id: string]: Array<{
path: string;
createTime: number;
}>;
};
}
export async function saveWorkflow(workflow: Workflow) {
const json = workflow.json;
const flow = JSON.parse(json);
if (flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] == null) {
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
}
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = workflow.id;
const response = await fetch("/workspace/file/save", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: getWorkflowRelPath(workflow),
json: JSON.stringify(flow),
}),
});
const result = await response.json();
if (result.error) {
console.error("Error saving file:", result.error);
alert(`"Error saving file: ${result.error}
👉Please try "Save as" from "Files" dropdown menu
`);
throw new Error(result.error);
}
return result;
}
export async function deleteWorkflow(workflow: Workflow) {
try {
const response = await fetch("/workspace/delete_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
file_path: getWorkflowRelPath(workflow),
deleteEmptyFolder: false,
}),
});
const result = await response.text();
return result;
} catch (error) {
alert("Error deleting workflow .json file: " + error);
console.error("Error deleting file:", error);
}
}
export async function createWorkflow({
parentFolderID,
name,
json,
id,
}: Workflow) {
let jsonObj: any = JSON.parse(json);
if (jsonObj.extra == null) {
jsonObj.extra = {};
}
if (jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME] == null) {
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
}
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = id; // to avoid overwriting deps
const input: { parentFolderPath: string; name: string; json: string } = {
parentFolderPath: sanitizeRelPath(parentFolderID ?? ""),
name: name,
json: JSON.stringify(jsonObj),
};
try {
const response = await fetch("/workspace/create_workflow_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
const result: { name?: string } = await response.json();
result.name &&
(await indexdb.workflows?.update(id, {
name: result.name,
}));
return result;
} catch (error) {
console.error(
`Error creating file <${id}> at "${parentFolderID}"`,
error,
);
alert(`Error creating file <${id}> at "${parentFolderID}"`);
return {};
}
}
export async function getFile({
parentFolderID,
name,
id,
}: {
parentFolderID: string | null;
id: string;
name: string;
}): Promise<{
json?: Object;
error?: string;
}> {
const relPath = joinRelPath(parentFolderID ?? "", name + ".json");
try {
const response = await fetch("/workspace/get_workflow_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: relPath,
id: id,
}),
});
const result = await response.json();
if (result.error) {
console.error(
`Error getting file <${id}> at "${relPath}"`,
result.error,
);
alert({
message: `Error getting file <${id}> at "${relPath}"`,
level: "error",
});
}
return result;
} catch (error) {
console.error(`Erro finding file <${id}> at "${relPath}"`, error);
return {};
}
}
}
export type ScanLocalFile = {
type: "workflow";
name: string;
id: string;
json: string;
createTime: number;
updateTime: number;
lastOpenedTime?: number;
};
export type ScanLocalFolder = {
id: string;
type: "folder";
name: string;
parentFolderID: string;
};
export async function scanLocalFiles(
path: string,
recursive = false,
metaInfoOnly = false,
): Promise<Array<ScanLocalFile | ScanLocalFolder>> {
try {
const response = await fetch("/workspace/file/scan_my_workflows_folder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: sanitizeRelPath(path),
recursive,
metaInfoOnly,
}),
});
const result = await response.json();
return result;
} catch (error) {
console.error("Error scan local new files:", error);
return [];
}
}
+111
View File
@@ -0,0 +1,111 @@
import { Folder } from "../types/dbTypes";
import { sanitizeRelPath } from "../utils/OsPathUtils";
export namespace TwowayFolderSyncAPI {
export async function genFolderRelPath({
parentFolderID,
name,
}: {
parentFolderID: string | null;
name: string;
}): Promise<string> {
return sanitizeRelPath(`${parentFolderID ?? ""}/${name}`);
}
export async function createFolder(folder: Folder) {
await fetch("/workspace/folder/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: sanitizeRelPath(folder.id),
}),
});
}
export async function moveFolder(
folderToBeMoved: string,
newParentPath: string,
) {
const resp = await fetch("/workspace/folder/move", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
folder: folderToBeMoved,
newParentPath: newParentPath,
}),
});
const result = await resp.json();
if (result.error) {
alert("Failed to move folder: " + result.error);
}
return;
}
export async function deleteFolder(relPath: string) {
try {
const response = await fetch("/workspace/folder/delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: relPath,
}),
});
const result = await response.text();
return result;
} catch (error) {
console.error("Error deleting file:", error);
}
}
export async function genFilesCountInFolder(
folderRelPath: string,
): Promise<number | null> {
try {
const response = await fetch("/workspace/file/count_files", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: folderRelPath,
}),
});
const result = await response.json();
if (result.error) {
console.error("Error counting files:", result.error);
}
return result.count;
} catch (error) {
console.error("Error renaming file:", error);
}
return null;
}
export async function renameFolder(
folderRelPath: string,
newName: string,
): Promise<void> {
try {
const response = await fetch("/workspace/folder/rename", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
absPath: folderRelPath,
newName: newName,
}),
});
const result = await response.json();
if (result.error) {
alert("Failed to rename folder: " + result.error);
}
} catch (error) {
console.error("Error renaming file:", error);
}
}
}
+1 -34
View File
@@ -12,41 +12,8 @@ export function openCognitoPopup() {
const popupWindow = window.open(
popupUrl,
"ComfyspaceLogin",
"width=800,height=600"
"width=800,height=600",
);
popupWindow?.focus();
}
export function pullAuthTokenCloseIfExist() {
if (window.location.href.includes(COMFYSPACE_AUTH_REDIRECT_URL + "#")) {
const paramsString = window.location.href.split("#")[1];
let params = new URLSearchParams(paramsString);
let accessToken = params.get("access_token");
window.opener.postMessage(
{ comfyspace_authToken: accessToken },
"http://127.0.0.1:8188/"
);
window.close();
}
}
export function authTokenListener(event: MessageEvent) {
const { comfyspace_authToken } = event.data;
if (comfyspace_authToken) {
fetch(`${COGNITO_DOMAIN}/oauth2/userInfo`, {
method: "GET",
headers: {
Authorization: `Bearer ${comfyspace_authToken}`,
},
})
.then((response) => response.json())
.then((data) => {
console.log("User Info:", data);
})
.catch((error) => {
console.error("Error fetching user info:", error);
});
}
}
+47 -24
View File
@@ -21,10 +21,18 @@ interface DialogButton {
label: string;
onClick: () => void;
colorScheme?: string;
icon?: React.ReactElement;
}
interface DialogContextType {
showDialog: (message: string, buttons: DialogButton[]) => void;
showDialog: (
message: string | React.ReactNode,
buttons?: (DialogButton | null)[],
options?: {
hideCloseIcon?: boolean;
closeOnOverlayClick?: boolean;
},
) => void;
}
const DialogContext = createContext<DialogContextType | undefined>(undefined);
@@ -41,15 +49,22 @@ export const AlertDialogProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState("");
const [buttons, setButtons] = useState<DialogButton[]>([]);
const [message, setMessage] = useState<string | React.ReactNode>("");
const [buttons, setButtons] = useState<(DialogButton | null)[]>([]);
const [hideCloseIcon, setHideCloseIcon] = useState(false);
const [closeOnOverlayClick, setCloseOnOverlayClick] = useState(true);
const cancelRef = React.useRef<HTMLButtonElement>(null);
const showDialog = useCallback((message: string, buttons: DialogButton[]) => {
setMessage(message);
setButtons(buttons);
setIsOpen(true);
}, []);
const showDialog: DialogContextType["showDialog"] = useCallback(
(message, buttons, options) => {
setMessage(message);
setButtons(buttons ?? []);
setIsOpen(true);
setHideCloseIcon(options?.hideCloseIcon ?? false);
setCloseOnOverlayClick(options?.closeOnOverlayClick ?? true);
},
[],
);
const handleClose = useCallback(() => {
setIsOpen(false);
@@ -63,7 +78,8 @@ export const AlertDialogProvider: React.FC<{ children: ReactNode }> = ({
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={handleClose}
size={"lg"}
size={"xl"}
closeOnOverlayClick={closeOnOverlayClick}
>
<DarkMode>
<AlertDialogOverlay>
@@ -72,24 +88,31 @@ export const AlertDialogProvider: React.FC<{ children: ReactNode }> = ({
fontSize="lg"
fontWeight="bold"
></AlertDialogHeader>
<AlertDialogCloseButton />
{!hideCloseIcon ? <AlertDialogCloseButton /> : null}
<AlertDialogBody>{message}</AlertDialogBody>
<AlertDialogBody whiteSpace="pre-wrap">
{message}
</AlertDialogBody>
<AlertDialogFooter>
{buttons.map((button, index) => (
<Button
colorScheme={button.colorScheme ?? "gray"}
onClick={() => {
button.onClick();
handleClose();
}}
ml={3}
key={index}
>
{button.label}
</Button>
))}
{buttons.map(
(button, index) =>
button && (
<Button
colorScheme={button.colorScheme ?? "gray"}
onClick={() => {
button.onClick();
handleClose();
}}
iconSpacing={1}
leftIcon={button.icon}
ml={3}
key={index}
>
{button.label}
</Button>
),
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
+108
View File
@@ -0,0 +1,108 @@
import React from "react";
import { Flex, IconButton, Image } from "@chakra-ui/react";
import { isImageFormat } from "../../utils.tsx";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
type Media = {
id: string;
imageUrl: string;
};
interface CarouselProps {
media: Media[];
currentNum: number;
setMediaAct: (media: Media) => void;
}
const Carousel: React.FC<CarouselProps> = ({
setMediaAct,
media,
currentNum,
}) => {
const paginate = (newDirection: number) => {
const cur = (currentNum + newDirection + media.length) % media.length;
if (setMediaAct) {
setMediaAct(media[cur]);
}
};
const imageSize = {
width: "100%", // Adjust the width as needed
height: "100%", // Adjust the height as needed
};
if (media.length === 0)
return <p>No images found, let's start generating 🪄</p>;
const item = media.at(currentNum);
if (!item) return null;
return (
<Flex
justifyContent="center"
alignItems="center"
position="relative"
width={imageSize.width}
height={imageSize.height}
overflow="hidden"
>
<div
key={item.id}
style={{
position: "absolute",
cursor: "pointer",
width: "100%",
height: "100%",
}}
onClick={() => {
window.open(item.imageUrl);
}}
>
{isImageFormat(item.imageUrl) ? (
<Image
src={item.imageUrl}
alt={`image-${item.id}`}
width={"100%"}
height={"100%"}
objectFit="contain"
/>
) : (
<video
style={{ objectFit: "contain" }}
width={"100%"}
height={"100%"}
src={item.imageUrl}
loop={true}
autoPlay={true}
muted={true}
>
<track kind="captions" />
</video>
)}
</div>
<IconButton
size={"sm"}
color={"white"}
bgColor="whiteAlpha.400"
aria-label="Previous image"
icon={<IconChevronLeft />}
onClick={() => paginate(-1)}
position="absolute"
left="0"
zIndex="2"
/>
<IconButton
size={"sm"}
color={"white"}
bgColor="whiteAlpha.400"
aria-label="Next image"
icon={<IconChevronRight />}
onClick={() => paginate(1)}
position="absolute"
right="0"
zIndex="2"
/>
</Flex>
);
};
export default Carousel;
+91
View File
@@ -0,0 +1,91 @@
import {
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
FormErrorMessage,
Input,
} from "@chakra-ui/react";
import { workflowVersionsTable } from "../db-tables/WorkspaceDB";
import { useState, ChangeEvent } from "react";
// @ts-ignore
import { app } from "/scripts/app.js";
interface Props {
workflowId: string;
onClose: () => void;
}
export default function CreateVersionDialog({ workflowId, onClose }: Props) {
const [newVersionName, setNewVersionName] = useState("");
const [submitError, setSubmitError] = useState("");
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setNewVersionName(event.target.value);
submitError && setSubmitError("");
};
const onSubmit = async () => {
const trimName = newVersionName.trim();
setNewVersionName(trimName);
const versionList =
(await workflowVersionsTable?.listByWorkflowID(workflowId)) ?? [];
if (versionList.some((version) => version.name === trimName)) {
setSubmitError(
"The name is duplicated, please modify it and submit again.",
);
} else {
const graphJson = JSON.stringify(app.graph.serialize());
await workflowVersionsTable?.add({
name: newVersionName,
workflowID: workflowId,
createTime: Date.now(),
json: graphJson,
});
onClose();
}
};
return (
<Modal isOpen={true} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Create Version</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl isInvalid={!!submitError}>
<FormLabel>Name</FormLabel>
<Input
value={newVersionName}
onChange={handleChange}
autoFocus
onKeyUp={(e) => {
e.code === "Enter" &&
!submitError &&
newVersionName &&
onSubmit();
}}
/>
{submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
</FormControl>
</ModalBody>
<ModalFooter>
<Button
colorScheme="teal"
mr={3}
onClick={onSubmit}
isDisabled={!newVersionName || !!submitError}
>
Create
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
+39
View File
@@ -0,0 +1,39 @@
import React, { useRef } from "react";
import { Box, useOutsideClick } from "@chakra-ui/react";
type Props = {
menuButton: React.ReactElement;
options: React.ReactElement;
isOpen: boolean;
onClose: () => void;
};
export default function CustomMenu({
options,
menuButton,
isOpen,
onClose,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
useOutsideClick({
ref: ref,
handler: () => onClose(),
});
return (
<Box position="relative">
<Box>{menuButton}</Box>
{isOpen && (
<Box
ref={ref}
mt="8px"
shadow="md"
p="2"
position="absolute"
zIndex="dropdown"
>
{options}
</Box>
)}
</Box>
);
}
+78
View File
@@ -0,0 +1,78 @@
import React, { useState, useRef, useEffect } from "react";
import { Box, Button, Card } from "@chakra-ui/react";
import { IconChevronDown } from "@tabler/icons-react";
export interface CustomSelectorOption<T> {
label: string;
value: T;
icon?: React.ReactElement;
}
type Props<T> = {
options: CustomSelectorOption<T>[];
value: T;
onChange: (value: T) => void;
};
export default function CustomSelector<T>({
options,
value,
onChange,
}: Props<T>) {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selectedOption = options.find((option) => option.value === value);
useEffect(() => {
const handleClickOutside = (event: any) => {
if (ref.current && !ref.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
const toggleDropdown = () => setIsOpen(!isOpen);
return (
<Box position="relative">
<Button
onClick={toggleDropdown}
rightIcon={<IconChevronDown />}
leftIcon={selectedOption?.icon}
>
{selectedOption?.label}
</Button>
{isOpen && (
<Card
gap={4}
ref={ref}
mt="2"
shadow="md"
borderWidth="1px"
p="2"
position="absolute"
zIndex={100}
>
{options.map((option) => (
<Button
key={option.label}
onClick={() => {
setIsOpen(false);
onChange(option.value);
}}
leftIcon={option.icon}
justifyContent="flex-start"
variant="ghost"
width="full"
>
{option.label}
</Button>
))}
</Card>
)}
</Box>
);
}
+2 -3
View File
@@ -31,7 +31,7 @@ export default function DeleteConfirm(props: Props) {
} = props;
return (
<Popover isLazy={true} placement="auto">
<Popover isLazy={true} placement="auto" gutter={0}>
{({ onClose }) => (
<>
<PopoverTrigger>
@@ -40,7 +40,7 @@ export default function DeleteConfirm(props: Props) {
<IconButton
aria-label="Delete confirm"
size={"sm"}
icon={<IconTrash color="#F56565" />}
icon={<IconTrash color="#F56565" size={"20px"} />}
isDisabled={isDisabled}
variant={variant}
/>
@@ -48,7 +48,6 @@ export default function DeleteConfirm(props: Props) {
</Box>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton onClick={onClose} />
<PopoverBody>
<Text mb={4} pr={4} fontWeight={600}>
+28 -5
View File
@@ -1,14 +1,35 @@
import { useState, useCallback, MouseEvent, useRef } from "react";
import {
useState,
useCallback,
MouseEvent,
useRef,
PropsWithChildren,
} from "react";
const POSITION = { x: 0, y: 0 };
export default function Draggable({ children, onDragEnd }) {
interface Props {
onDragEnd: (position: { x: number; y: number }) => void;
dragIconId: string;
}
export default function Draggable({
children,
onDragEnd,
dragIconId,
}: PropsWithChildren<Props>) {
const originRef = useRef(POSITION);
const isDraggingRef = useRef(false);
const [translation, setTranslation] = useState(POSITION);
const handleMouseDown = (e: MouseEvent) => {
if ([e.target?.id, e.target?.parentNode?.id].includes("dragPanelIcon")) {
if (
!(e.target instanceof Element) ||
!(e.target.parentNode instanceof Element)
) {
return;
}
if ([e.target?.id, e.target?.parentNode?.id].includes(dragIconId)) {
originRef.current = {
x: e.clientX,
y: e.clientY,
@@ -21,7 +42,7 @@ export default function Draggable({ children, onDragEnd }) {
}
};
const handleMouseMove = useCallback((e: MouseEvent) => {
const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
const translation = {
x: e.clientX - originRef.current.x,
y: e.clientY - originRef.current.y,
@@ -32,7 +53,9 @@ export default function Draggable({ children, onDragEnd }) {
const handleMouseUp = useCallback(() => {
setTranslation((state) => {
onDragEnd(state);
setTimeout(() => {
onDragEnd(state);
}, 0);
return POSITION;
});
isDraggingRef.current = false;
+146 -97
View File
@@ -1,17 +1,15 @@
import {
ChangeEvent,
lazy,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Button,
Modal,
ModalOverlay,
@@ -24,8 +22,10 @@ import {
FormLabel,
FormErrorMessage,
Input,
Portal,
Tooltip,
DarkMode,
Tag,
HStack,
} from "@chakra-ui/react";
import {
IconArrowBackUpDouble,
@@ -33,146 +33,186 @@ import {
IconDeviceFloppy,
IconDownload,
IconHistory,
IconShare2,
IconVersions,
} from "@tabler/icons-react";
import {
Workflow,
getWorkflow,
userSettingsTable,
workspace,
} from "../db-tables/WorkspaceDB";
import { workflowsTable, userSettingsTable } from "../db-tables/WorkspaceDB";
import { WorkspaceContext } from "../WorkspaceContext";
import { Overlay } from "./Overlay";
import { VersionHistoryDrawer } from "./VersionHistoryDrawer";
import { Workflow } from "../types/dbTypes";
import CreateVersionDialog from "./CreateVersionDialog";
import HoverMenu from "./HoverMenu";
const ShareDialog = lazy(() => import("../share/ShareDialog"));
// @ts-ignore
import { app } from "/scripts/app.js";
import { TOPBAR_BUTTON_HEIGHT } from "../const";
import DownloadSpaceJsonDialog from "../spacejson/DownloadSpaceJsonDialog";
import { downloadJsonFile } from "../utils/downloadJsonFile";
export default function DropdownTitle({ onClick }: { onClick?: () => void }) {
export default function DropdownTitle() {
const {
curFlowID,
onDuplicateWorkflow,
loadWorkflowID,
discardUnsavedChanges,
saveCurWorkflow,
setRoute,
route,
} = useContext(WorkspaceContext);
const [isOpenNewName, setIsOpenNewName] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [isOpenNewVersion, setIsOpenNewVersion] = useState(false);
const [newFlowName, setNewFlowName] = useState("");
const [submitError, setSubmitError] = useState("");
const [workflow, setWorkflow] = useState<Workflow>();
const [saveShortcut, setSaveShortcut] = useState({
save: "",
saveAs: "",
});
useEffect(() => {
const workflow = curFlowID ? getWorkflow(curFlowID) : undefined;
setNewFlowName(workflow?.name ?? "");
setWorkflow(workflow);
if (curFlowID) {
workflowsTable?.get(curFlowID).then((workflow) => {
setNewFlowName(workflow?.name ?? "");
setWorkflow(workflow);
});
}
userSettingsTable?.getSetting("shortcuts").then((res) => {
setSaveShortcut(res);
});
}, [curFlowID]);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setNewFlowName(event.target.value);
submitError && setSubmitError("");
};
const onSubmit = () => {
const onSubmit = async () => {
if (!curFlowID) {
alert("Flow ID is required");
return;
}
onDuplicateWorkflow?.(curFlowID, newFlowName);
const graph = JSON.stringify(app.graph.serialize());
const flow = await workflowsTable?.createFlow({
json: graph,
name: newFlowName,
parentFolderID: workflow?.parentFolderID,
});
flow && (await loadWorkflowID(flow.id, null));
handleOnCloseModal();
};
const handleOnCloseModal = () => {
setIsOpenNewName(false);
setRoute("root");
};
const handleDownload = useCallback(() => {
if (!workspace || !curFlowID) {
alert("You don't have any workspaces");
const handleDownload = useCallback(async () => {
const curWorkflow = workflowsTable?.curWorkflow;
if (!curWorkflow) {
alert("No current workflow!");
return;
}
const graph = app.graph.serialize();
const json = JSON.stringify(graph, null, 2);
const json_data = workspace[curFlowID];
if (!json_data) {
alert("Workspace does not exist");
return;
}
const blob = new Blob([json_data.json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${json_data.name}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadJsonFile(json, curWorkflow.name);
}, [curFlowID]);
const saveShortcut = userSettingsTable?.getSetting("shortcuts")?.save;
return (
<>
<Menu isLazy={true}>
{({ isOpen }) => (
<>
<MenuButton onClick={onClick}>
<IconButton
icon={<IconChevronDown size={20} />}
aria-label="menu"
size={"xs"}
backgroundColor={"#323232"}
color={"white"}
/>
</MenuButton>
<Portal>
<MenuList minWidth={150} zIndex={1000}>
<MenuItem
onClick={saveCurWorkflow}
icon={<IconDeviceFloppy size={20} />}
iconSpacing={1}
command={saveShortcut}
>
Save
</MenuItem>
<Tooltip label="This will revert current workflow to your last saved version. You will lose all changes made since your last save.">
<HoverMenu
menuButton={
<DarkMode>
<Button
px={1}
height={TOPBAR_BUTTON_HEIGHT + "px"}
aria-label="menu"
size={"sm"}
backgroundColor={"#434554"}
>
File
<IconChevronDown size={20} />
</Button>
</DarkMode>
}
menuContent={
<Menu isOpen={true}>
<MenuList minWidth={150} zIndex={1000}>
<MenuItem
onClick={() => saveCurWorkflow()}
icon={<IconDeviceFloppy size={20} />}
iconSpacing={1}
command={saveShortcut.save}
>
Save
</MenuItem>
{!userSettingsTable?.settings?.autoSave && (
<Tooltip label="Revert workflow to your last saved version. You will lose all changes made since your last save.">
<MenuItem
onClick={discardUnsavedChanges}
icon={<IconArrowBackUpDouble size={20} />}
iconSpacing={1}
isDisabled={workflow?.lastSavedJson == null}
>
Discard unsaved changes
</MenuItem>
</Tooltip>
<MenuItem
onClick={handleDownload}
icon={<IconDownload size={20} />}
iconSpacing={1}
>
Download
</MenuItem>
<MenuItem
onClick={() => setIsOpenNewName(true)}
icon={<IconDeviceFloppy size={20} />}
iconSpacing={1}
>
Save As
</MenuItem>
<MenuItem
onClick={() => setIsVersionHistoryOpen(true)}
icon={<IconHistory size={20} />}
iconSpacing={1}
>
Versions History
</MenuItem>
</MenuList>
{isOpen && <Overlay backgroundColor={null} />}
</Portal>
</>
)}
</Menu>
{isVersionHistoryOpen && (
<VersionHistoryDrawer onClose={() => setIsVersionHistoryOpen(false)} />
)}
<MenuItem
onClick={handleDownload}
icon={<IconDownload size={20} />}
iconSpacing={1}
>
Download
</MenuItem>
<MenuItem
onClick={() => setRoute("saveAsModal")}
icon={<IconDeviceFloppy size={20} />}
iconSpacing={1}
command={saveShortcut.saveAs}
>
Save As
</MenuItem>
<MenuItem
onClick={() => setIsOpenNewVersion(true)}
icon={<IconVersions size={20} />}
iconSpacing={1}
>
Create Version
</MenuItem>
<MenuItem
onClick={() => setRoute("versionHistory")}
icon={<IconHistory size={20} />}
iconSpacing={1}
>
Versions History
</MenuItem>
{/* <MenuItem
onClick={() => setRoute("downloadSpaceJson")}
icon={<IconDownload size={20} />}
iconSpacing={1}
>
Save runnable workflow🧪
</MenuItem> */}
<MenuItem
onClick={() => setRoute("downloadSpaceJson")}
icon={<IconShare2 size={20} />}
iconSpacing={1}
alignItems={"center"}
>
<HStack>
<p>Share</p> <Tag size={"sm"}>🧪beta</Tag>
</HStack>
</MenuItem>
</MenuList>
</Menu>
}
/>
{route === "share" && <ShareDialog onClose={() => setRoute("root")} />}
{route == "versionHistory" && (
<VersionHistoryDrawer onClose={() => setRoute("root")} />
)}
{isOpenNewName && (
{route == "downloadSpaceJson" && <DownloadSpaceJsonDialog />}
{route === "saveAsModal" && (
<Modal isOpen={true} onClose={handleOnCloseModal}>
<ModalOverlay />
<ModalContent>
@@ -186,7 +226,10 @@ export default function DropdownTitle({ onClick }: { onClick?: () => void }) {
onChange={handleChange}
autoFocus
onKeyUp={(e) => {
e.code === "Enter" && !submitError && onSubmit();
e.code === "Enter" &&
!submitError &&
newFlowName &&
onSubmit();
}}
/>
{submitError && (
@@ -208,6 +251,12 @@ export default function DropdownTitle({ onClick }: { onClick?: () => void }) {
</ModalContent>
</Modal>
)}
{isOpenNewVersion && (
<CreateVersionDialog
workflowId={curFlowID!}
onClose={() => setIsOpenNewVersion(false)}
/>
)}
</>
);
}
+18 -20
View File
@@ -17,20 +17,15 @@ import {
Tooltip,
} from "@chakra-ui/react";
import { useContext, ChangeEvent, useState } from "react";
import { listWorkflows, updateFlow } from "../db-tables/WorkspaceDB";
import { workflowsTable } from "../db-tables/WorkspaceDB";
import { WorkspaceContext } from "../WorkspaceContext";
type Props = {
displayName: string;
updateFlowName: (newName: string) => void;
isDirty: boolean;
isActive: boolean;
};
export default function EditFlowName({
displayName,
updateFlowName,
isDirty,
}: Props) {
export default function EditFlowName({ displayName, isActive }: Props) {
const { isOpen, onOpen, onClose } = useDisclosure();
const { curFlowID } = useContext(WorkspaceContext);
const [editName, setEditName] = useState(displayName);
@@ -46,21 +41,23 @@ export default function EditFlowName({
submitError && setSubmitError("");
};
const onSubmit = () => {
const onSubmit = async () => {
if (curFlowID) {
const trimEditName = editName.trim();
setEditName(trimEditName);
if (displayName === trimEditName) return onCloseModal();
const flowNameList = listWorkflows()?.map((flow) => flow.name);
if (flowNameList.includes(trimEditName)) {
const uniqueName = await workflowsTable?.generateUniqueName(
trimEditName,
workflowsTable.curWorkflow?.parentFolderID ?? "",
);
if (uniqueName !== trimEditName) {
setSubmitError(
"The name is duplicated, please modify it and submit again."
"The name is duplicated, please modify it and submit again.",
);
} else {
updateFlow(curFlowID, {
await workflowsTable?.updateName(curFlowID, {
name: trimEditName,
});
updateFlowName(trimEditName);
onCloseModal();
}
}
@@ -75,15 +72,16 @@ export default function EditFlowName({
<HStack>
<Tooltip label={displayName} placement="bottom">
<Text
color="white"
onClick={startEdit}
maxW={240}
fontWeight={500}
as="div"
color={isActive ? "white" : "#9d9d9d"}
onDoubleClick={startEdit}
maxW="114px"
marginRight="4px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
userSelect="none"
>
{isDirty && "* "}
{displayName}
</Text>
</Tooltip>
@@ -101,7 +99,7 @@ export default function EditFlowName({
onChange={handleChange}
autoFocus
onKeyUp={(e) => {
e.code === "Enter" && !submitError && onSubmit();
e.code === "Enter" && !submitError && editName && onSubmit();
}}
/>
{submitError && (
+9 -7
View File
@@ -32,18 +32,20 @@ export default function EditFolderNameModal({ folder, onclose }: Props) {
submitError && setSubmitError("");
};
const onSubmit = () => {
const onSubmit = async () => {
const trimEditName = editName.trim();
setEditName(trimEditName);
if (trimEditName === folder.name) return onclose();
const folderNameList = foldersTable?.listAll()?.map((f) => f.name) ?? [];
if (folderNameList.includes(trimEditName)) {
const uniqName = await foldersTable?.generateUniqueName(
trimEditName,
folder.parentFolderID ?? "",
);
if (uniqName !== trimEditName) {
setSubmitError(
"The name is duplicated, please modify it and submit again."
"The name is duplicated, please modify it and submit again.",
);
} else {
foldersTable?.update({
id: folder.id,
await foldersTable?.update(folder.id, {
name: trimEditName,
});
onRefreshFilesList && onRefreshFilesList();
@@ -65,7 +67,7 @@ export default function EditFolderNameModal({ folder, onclose }: Props) {
onChange={handleChange}
autoFocus
onKeyUp={(e) => {
e.code === "Enter" && !submitError && onSubmit();
e.code === "Enter" && !submitError && editName && onSubmit();
}}
/>
{submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
+49
View File
@@ -0,0 +1,49 @@
import { useRef, useState } from "react";
import CustomMenu from "./CustomMenu";
import { Box, Menu, MenuList } from "@chakra-ui/react";
type Props = {
menuButton: React.ReactElement;
menuContent: React.ReactElement;
onClose?: () => void;
};
export default function HoverMenu({ menuButton, menuContent, onClose }: Props) {
const closeTimeoutId = useRef<number>();
const [isOpen, setIsOpen] = useState(false);
const delayedClose = () => {
closeTimeoutId.current = setTimeout(() => setIsOpen(false), 400); // delay of 300ms
};
const onOpen = () => {
setIsOpen(true);
clearTimeout(closeTimeoutId.current);
closeTimeoutId.current = undefined;
};
return (
<CustomMenu
isOpen={isOpen}
onClose={delayedClose}
menuButton={
<Box
aria-label="menu"
onClick={onOpen}
onMouseEnter={onOpen}
onMouseLeave={delayedClose}
>
{menuButton}
</Box>
}
options={
<Menu isOpen={true}>
<MenuList
minWidth={150}
zIndex={1000}
onMouseEnter={onOpen}
onMouseLeave={delayedClose}
>
{menuContent}
</MenuList>
</Menu>
}
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Image } from "@chakra-ui/react";
import { isImageFormat } from "../utils";
import { useEffect, useState } from "react";
type Props = {
mediaLocalPath: string;
size: number;
autoPlay?: boolean;
onBrokenLink?: () => void;
hideBrokenImage?: boolean;
isPreview?: boolean;
objectFit?: "cover" | "contain";
};
export default function MediaPreview({
mediaLocalPath,
size,
isPreview,
autoPlay,
hideBrokenImage,
objectFit,
onBrokenLink,
}: Props) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const checkMediaExists = async () => {
try {
const response = await fetch(
isPreview
? `/workspace/preview_media?filename=${mediaLocalPath}`
: `/workspace/view_media?filename=${mediaLocalPath}`,
);
if (response.status == 404) {
setIsVisible(false);
onBrokenLink?.();
return;
}
} catch (error) {
console.error("Error checking media exists", error);
}
};
hideBrokenImage && checkMediaExists();
}, []);
if (!isVisible) return null;
return isImageFormat(mediaLocalPath) ? (
<Image
borderRadius={3}
boxSize={`${size}px`}
objectFit={objectFit ?? "cover"}
src={`/workspace/view_media?filename=${mediaLocalPath}`}
alt="workflow image renamed or moved from output folder"
/>
) : (
<div
style={{
width: size + "px",
height: size + `px`,
overflow: "hidden",
position: "relative",
}}
>
<video
width={size}
height={size}
src={`/workspace/view_media?filename=${mediaLocalPath}`}
loop={true}
autoPlay={autoPlay ?? false}
muted={true}
style={{
// for video to crop to center
objectFit: "cover",
width: "100%",
height: "100%",
// position: "absolute",
// top: "50%", // Align the center of the video with the center of the container vertically
// left: "50%", // Align the center of the video with the center of the container horizontally
// transform: "translate(-50%, -50%)", // Offset the video to ensure it is centered
}}
>
<track kind="captions" />
</video>
</div>
);
}
+5 -3
View File
@@ -5,6 +5,8 @@ import { CSSProperties } from "react";
type Props = {
searchValue: string;
onUpdateSearchValue: (value: string) => void;
placeholder?: string;
style?: CSSProperties;
};
const IconSearchStyle: CSSProperties = {
@@ -18,11 +20,11 @@ const IconSearchStyle: CSSProperties = {
};
export default function SearchInput(props: Props) {
const { searchValue, onUpdateSearchValue } = props;
const { searchValue, onUpdateSearchValue, style } = props;
const isSearchValueNotEmpty = searchValue !== "";
return (
<Box style={{ position: "relative" }} mb={2}>
<Box style={{ position: "relative", ...style }} mb={2}>
<IconSearch style={IconSearchStyle} />
{isSearchValueNotEmpty && (
<IconButton
@@ -40,7 +42,7 @@ export default function SearchInput(props: Props) {
/>
)}
<Input
placeholder="Search"
placeholder={props.placeholder ?? "Search"}
size={"sm"}
paddingLeft={"35px"}
paddingBlock={"5px"}
+278
View File
@@ -0,0 +1,278 @@
import {
Modal,
ModalContent,
Input,
InputGroup,
InputRightElement,
VStack,
Button,
HStack,
Text,
Spinner,
useColorMode,
} from "@chakra-ui/react";
import { IconSearch } from "@tabler/icons-react";
import { useState, ChangeEvent, useEffect, useContext, useRef } from "react";
import { SHORTCUT_TRIGGER_EVENT } from "../const";
import {
EOtherKeys,
EShortcutKeys,
RecentlyOpenedFile,
Workflow,
} from "../types/dbTypes";
import { formatTimestamp } from "../utils";
import { WorkspaceContext } from "../WorkspaceContext";
import { userSettingsTable, workflowsTable } from "../db-tables/WorkspaceDB";
import useDebounceFn from "../customHooks/useDebounceFn";
import { useStateRef } from "../customHooks/useStateRef";
import { ESortTypes } from "../RecentFilesDrawer/types";
export default function SpotlightSearch() {
const { colorMode } = useColorMode();
const { loadWorkflowID, setRoute } = useContext(WorkspaceContext);
const [allFileList, setAllFileList] = useState<RecentlyOpenedFile[]>([]);
const [searchKey, setSearchKey, searchKeyRef] = useStateRef("");
const recentlyOpenedFileListRef = useRef<RecentlyOpenedFile[]>([]);
const [renderDataSource, setRenderDataSource, renderDataSourceRef] =
useStateRef<RecentlyOpenedFile[]>([]);
const [loading, setLoading] = useState(false);
const [selectIndex, setSelectIndex, selectIndexRef] = useStateRef(0);
const keyRef = useRef({
lastPressedKey: "",
spotlightSearchShortcutFirstKey: "",
spotlightSearchShortcutLastKey: "",
});
const onClose = () => {
setRoute("root");
};
const onOpen = (fileInfo: RecentlyOpenedFile) => {
loadWorkflowID(fileInfo.id);
onClose();
};
const handlerSearch = () => {
const searchResult = allFileList.filter(
(file) =>
!!file.name
.toLocaleLowerCase()
.includes(searchKeyRef.current.toLocaleLowerCase()),
);
setRenderDataSource(searchResult);
setSelectIndex(0);
setLoading(false);
};
const [debounceSearch, cancelDebounceSearch] = useDebounceFn(
handlerSearch,
300,
);
const onSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
setSearchKey(event.target.value);
if (event.target.value) {
debounceSearch();
!loading && setLoading(true);
} else {
setLoading(false);
cancelDebounceSearch();
setRenderDataSource(recentlyOpenedFileListRef.current);
}
};
const renderSearchResult = () => {
if (loading) return null;
if (searchKey && renderDataSource.length === 0) {
return <Text ml={2}>No matching results</Text>;
}
return (
<>
{renderDataSource.map((result, index) => (
<Button
colorScheme="teal"
variant="ghost"
key={result.id}
size="sm"
justifyContent="start"
px={2}
color={
colorMode === "light" ? "#1A202C" : "rgba(255, 255, 255, 0.92)"
}
onClick={() => {
onOpen(result);
}}
minH="26px"
isActive={index === selectIndex}
>
{result.name}
{result.updateTime && (
<Text
color={
colorMode === "light" ? "GrayText" : "rgb(255, 255, 255, 0.6)"
}
ml={2}
fontSize={"sm"}
>
Updated: {formatTimestamp(result.updateTime)}
</Text>
)}
</Button>
))}
</>
);
};
const init = async () => {
const getData = (list: Workflow[]) =>
list.map(
(flow) =>
({
type: "workflow",
updateTime: flow?.updateTime ?? Date.now(),
id: flow.id,
name: flow.name.replace(/\.json$/, ""),
}) as RecentlyOpenedFile,
) ?? [];
const allFiles =
(await workflowsTable?.listAll(ESortTypes.RECENTLY_OPENED)) ?? [];
setAllFileList(getData(allFiles));
const recentlyOpenedFileList = getData(allFiles.slice(0, 20));
recentlyOpenedFileListRef.current = recentlyOpenedFileList;
setRenderDataSource(recentlyOpenedFileList);
};
const changeSelectIndex = (num: number) => {
setSelectIndex((preIndex) => {
const nextIndex = preIndex + num;
if (nextIndex < 0) {
return renderDataSourceRef.current.length - 1;
} else if (nextIndex > renderDataSourceRef.current.length - 1) {
return 0;
} else {
return nextIndex;
}
});
};
useEffect(() => {
init();
userSettingsTable?.getSetting("shortcuts").then((res) => {
const openSpotlightSearch =
res.openSpotlightSearch ??
userSettingsTable?.defaultSettings.shortcuts.openSpotlightSearch;
const keys = openSpotlightSearch.split("+");
keyRef.current.spotlightSearchShortcutFirstKey =
keys[0] === "Command" ? "Meta" : keys[0];
keyRef.current.spotlightSearchShortcutLastKey = keys[keys.length - 1];
});
const handleKeyUp = (event: KeyboardEvent) => {
/**
* Determine whether to open the selected file when the button is lifted. Conditions are met:
* 1. The raised button is the first button among the shortcut keys that trigger focus search;
* 2. The last pressed key is the last key to trigger the focus search shortcut key;
* 3. The selected file exists;
*/
if (
event.key === keyRef.current.spotlightSearchShortcutFirstKey &&
keyRef.current.lastPressedKey ===
keyRef.current.spotlightSearchShortcutLastKey &&
selectIndexRef.current > 0 &&
renderDataSourceRef.current[selectIndexRef.current]
) {
onOpen(renderDataSourceRef.current[selectIndexRef.current]);
}
};
const keydownListener = (event: KeyboardEvent) => {
const key = event.key.toUpperCase();
keyRef.current.lastPressedKey = key;
switch (key) {
case "ARROWDOWN":
changeSelectIndex(1);
break;
case "ARROWUP":
changeSelectIndex(-1);
break;
case "ENTER":
onOpen(renderDataSourceRef.current[selectIndexRef.current]);
break;
}
};
const shortcutTrigger = (event: CustomEvent) => {
switch (event.detail.shortcutType) {
case EShortcutKeys.openSpotlightSearch:
changeSelectIndex(1);
break;
}
};
document.addEventListener("keyup", handleKeyUp);
document.addEventListener("keydown", keydownListener);
document.addEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
return () => {
document.removeEventListener("keyup", handleKeyUp);
document.removeEventListener("keydown", keydownListener);
document.removeEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
};
}, []);
return (
<Modal isOpen={true} onClose={onClose} size="xl">
<ModalContent
bg={
colorMode === "light"
? "rgba(255, 255, 255, 0.8)"
: "rgb(45, 55, 72, 0.8)"
}
backdropFilter="auto"
backdropBlur="8px"
>
<HStack
spacing={1}
px={4}
py={2}
borderBottom="1px"
borderColor="gray.400"
>
<IconSearch size={20} />
<InputGroup>
<Input
value={searchKey}
onChange={onSearchChange}
autoFocus
variant="unstyled"
fontSize={18}
/>
{loading && (
<InputRightElement h="27px">
<Spinner size="sm" />
</InputRightElement>
)}
</InputGroup>
</HStack>
<VStack
spacing={1}
align="stretch"
px={2}
py={2}
maxHeight="calc(100vh - 200px)"
overflowY="auto"
>
{renderSearchResult()}
</VStack>
</ModalContent>
</Modal>
);
}
+158 -49
View File
@@ -1,5 +1,4 @@
import {
Box,
Button,
Card,
CardBody,
@@ -10,49 +9,83 @@ import {
Stack,
StackDivider,
Text,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
useToast,
HStack,
Tag,
} from "@chakra-ui/react";
import { IconX } from "@tabler/icons-react";
import {
changelogsTable,
getWorkflow,
updateFlow,
workflowsTable,
workflowVersionsTable,
} from "../db-tables/WorkspaceDB";
import { useContext, useEffect, useState } from "react";
import { WorkspaceContext } from "../WorkspaceContext";
import { formatTimestamp } from "../utils";
// @ts-ignore
import { app } from "/scripts/app.js";
import { Changelog } from "../types/dbTypes";
import { Changelog, WorkflowVersion } from "../types/dbTypes";
import DeleteConfirm from "./DeleteConfirm";
export function VersionHistoryDrawer({ onClose }: { onClose: () => void }) {
const { curFlowID, isDirty } = useContext(WorkspaceContext);
const toast = useToast();
const { curFlowID, isDirty, loadWorkflowID, curVersion } =
useContext(WorkspaceContext);
const [active, setActive] = useState(0); // 0: version、1: changelog
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const curWorkflow = curFlowID != null ? getWorkflow(curFlowID) : null;
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
if (curFlowID == null || curWorkflow == null) {
alert("No current workflow found!");
return null;
}
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const loadChangeLogs = async (flowId: string) => {
const workflow = await workflowsTable?.get(flowId);
const changelogs = await changelogsTable?.listByWorkflowID(flowId);
setChangelogs(changelogs ?? []);
const selectedChangelog = changelogs?.filter(
(c) => c.json === workflow?.json,
);
const selectedChangelogID = selectedChangelog?.[0]?.id;
selectedChangelogID && setSelectedVersion(selectedChangelogID);
};
const loadVersions = async () => {
const vers =
(await workflowVersionsTable?.listByWorkflowID(curFlowID!)) ?? [];
setVersions(vers);
};
const onDelete = (id: string) => {
workflowVersionsTable?.delete(id).then(() => {
loadVersions();
});
};
useEffect(() => {
const loadData = async () => {
const changelogs = await changelogsTable?.listByWorkflowID(curFlowID);
setChangelogs(changelogs ?? []);
const selectedChangelog = changelogs?.filter(
(c) => c.json === curWorkflow.lastSavedJson
);
const selectedChangelogID = selectedChangelog?.[0]?.id;
selectedChangelogID && setSelectedVersion(selectedChangelogID);
switch (active) {
case 0:
loadVersions();
break;
case 1:
loadChangeLogs(curFlowID!);
break;
}
}, [active]);
useEffect(() => {
app.canvasEl.addEventListener("click", onClose);
return () => {
app.canvasEl.removeEventListener("click", onClose);
};
loadData();
}, []);
}, [active]);
return (
<Card
width={400}
width={430}
height={"100vh"}
pl={4}
pr={5}
pt={4}
gap={0}
position={"fixed"}
top={0}
@@ -72,31 +105,107 @@ export function VersionHistoryDrawer({ onClose }: { onClose: () => void }) {
</Flex>
</CardHeader>
<CardBody overflowY={"auto"} gap={0}>
<Text mb={4}>All the saved changes of this workflow</Text>
<Stack divider={<StackDivider />} spacing={2}>
{changelogs?.map((c) => {
return (
<Button
size={"sm"}
variant={"ghost"}
onClick={() => {
if (isDirty) {
alert(
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes."
);
return;
}
app.loadGraphData(JSON.parse(c.json));
updateFlow(curFlowID, { lastSavedJson: c.json });
onClose();
}}
isActive={c.id === selectedVersion}
>
Saved at {formatTimestamp(c.createTime, true)}
</Button>
);
})}
</Stack>
<Tabs
isFitted
variant="enclosed"
onChange={setActive}
colorScheme="teal"
>
<TabList mb="1em">
<Tab>Versions</Tab>
<Tab>Change History</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Stack divider={<StackDivider />} spacing={2}>
{versions?.map((version) => {
return (
<Flex
key={version.id}
w={"100%"}
mb={1}
justify={"space-between"}
backgroundColor={
version.id === curVersion?.id ? "teal.300" : undefined
}
borderRadius={4}
>
<Stack textAlign={"left"} gap={0}>
<Text fontWeight={"500"}>
{version.name ?? "untitled"}
</Text>
<Text color={"GrayText"} fontSize={"sm"}>
Created: {formatTimestamp(version.createTime)}
</Text>
</Stack>
<Flex alignItems="center" gap={2}>
<Button
size={"sm"}
onClick={() => {
if (isDirty) {
alert(
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
);
return;
}
app.loadGraphData(JSON.parse(version.json));
loadWorkflowID(curFlowID!, version.id);
onClose();
}}
>
Load
</Button>
<DeleteConfirm
promptMessage="Are you sure you want to delete this version?"
onDelete={() => {
onDelete(version.id);
}}
/>
</Flex>
</Flex>
);
})}
</Stack>
</TabPanel>
<TabPanel>
<Text mb={4}>All the saved changes of this workflow</Text>
<Stack divider={<StackDivider />} spacing={2}>
{changelogs?.map((c) => {
return (
<HStack key={c.id} justifyContent={"space-between"}>
<Button
key={c.id}
size={"sm"}
variant={"ghost"}
onClick={() => {
if (isDirty) {
alert(
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
);
return;
}
app.loadGraphData(JSON.parse(c.json));
workflowsTable?.updateFlow(curFlowID!, {
json: c.json,
});
onClose();
}}
isActive={c.id === selectedVersion}
>
Saved at {new Date(c.createTime).toLocaleString()}
</Button>
{c.isAutoSave ? (
<Tag colorScheme="teal" size={"sm"}>
Auto save
</Tag>
) : null}
</HStack>
);
})}
</Stack>
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
+4
View File
@@ -2,3 +2,7 @@ export const LEGACY_COMFYSPACE_TRACKING_FIELD_NAME = "comfyspace_tracking";
export const COMFYSPACE_TRACKING_FIELD_NAME = "workspace_info";
export const COMFYSPACE_AUTH_ENDPOINT = "comfyspace_auth";
export const COMFYSPACE_AUTH_REDIRECT_URL = "http://localhost:8188/";
export const DRAWER_Z_INDEX = 9999999;
export const UPGRADE_TO_2WAY_SYNC_KEY = "upgrade_to_2way_sync";
export const TOPBAR_BUTTON_HEIGHT = 28;
export const SHORTCUT_TRIGGER_EVENT = "SHORTCUT_TRIGGER_EVENT";
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
export const useDebounce = <T>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
@@ -14,4 +14,4 @@ export const useDebounce = <T>(value: T, delay: number): T => {
}, [value, delay]);
return debouncedValue;
};
};
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef } from "react";
export default function useDebounceFn<T extends (...args: any[]) => void>(
fn: T,
delay: number,
): [T, () => void] {
const timeoutRef = useRef(0);
const cancel = useCallback(() => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = 0;
}
}, []);
const debouncedFn = useCallback(
(...args: Parameters<T>) => {
cancel();
timeoutRef.current = setTimeout(() => fn(...args), delay);
},
[fn, delay, cancel],
) as T;
useEffect(() => {
return () => {
cancel();
};
}, []);
return [debouncedFn, cancel];
}
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useRef, useState, SetStateAction, Dispatch } from "react";
const isFunction = <S>(
setStateAction: SetStateAction<S>,
): setStateAction is (prevState: S) => S =>
typeof setStateAction === "function";
type ReadOnlyRefObject<T> = {
readonly current: T;
};
type UseStateRef = {
<S>(
initialState: S | (() => S),
): [S, Dispatch<SetStateAction<S>>, ReadOnlyRefObject<S>];
<S = undefined>(): [
S | undefined,
Dispatch<SetStateAction<S | undefined>>,
ReadOnlyRefObject<S | undefined>,
];
};
export const useStateRef: UseStateRef = <S>(initialState?: S | (() => S)) => {
const [state, setState] = useState(initialState);
const ref = useRef(state);
const dispatch: typeof setState = useCallback((setStateAction: any) => {
ref.current = isFunction(setStateAction)
? setStateAction(ref.current)
: setStateAction;
setState(ref.current);
}, []);
return [state, dispatch, ref];
};
+36 -40
View File
@@ -1,68 +1,64 @@
import { getDB, saveDB } from "../Api";
import { v4 as uuidv4 } from "uuid";
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
import { Table } from "./WorkspaceDB";
import { nanoid } from "nanoid";
import { Table, userSettingsTable } from "./WorkspaceDB";
import { Changelog } from "../types/dbTypes";
type ChangelogRecords = {
[id: string]: Changelog;
};
export class ChangelogsTable {
import { TableBase } from "./TableBase";
import { indexdb } from "./indexdb";
const LIMIT = 80;
export class ChangelogsTable extends TableBase<Changelog> {
static readonly TABLE_NAME: Table = "changelogs";
private constructor() {}
constructor() {
super("changelogs");
}
static async load(): Promise<ChangelogsTable> {
const instance = new ChangelogsTable();
return instance;
}
public async listByWorkflowID(workflowID: string): Promise<Changelog[]> {
const records = await this.getRecords();
return Object.values(records)
.filter((c) => c.workflowID === workflowID)
.sort((a, b) => b.createTime - a.createTime);
}
public async getRecords(): Promise<ChangelogRecords> {
let jsonStr = await getDB(ChangelogsTable.TABLE_NAME);
let json = jsonStr != null ? JSON.parse(jsonStr) : null;
if (json == null) {
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
const comfyspaceData = JSON.parse(comfyspace);
json = comfyspaceData[ChangelogsTable.TABLE_NAME];
}
return json ?? {};
}
public async get(id: string): Promise<Changelog | undefined> {
const records = await this.getRecords();
return records[id];
const objects = await indexdb["changelogs"]
.where("workflowID")
.equals(workflowID)
.reverse()
.sortBy("createTime");
return objects;
}
public async getLastestByWorkflowID(workflowID: string): Promise<Changelog> {
const records = await this.getRecords();
const all = Object.values(records)
.filter((c) => c.workflowID === workflowID)
.sort((a, b) => b.createTime - a.createTime);
return all[0];
const objects = await this.listByWorkflowID(workflowID);
return objects[0];
}
public async create(input: {
json: string;
workflowID: string;
isAutoSave: boolean;
}): Promise<Changelog | null> {
const latest = await this.getLastestByWorkflowID(input.workflowID);
// only create when there is a change
if (latest != null && latest.json === input.json) {
return null;
}
const change: Changelog = {
id: uuidv4(),
id: nanoid(),
json: input.json,
workflowID: input.workflowID,
createTime: Date.now(),
isAutoSave: input.isAutoSave,
};
const records = await this.getRecords();
records[change.id] = change;
saveDB(ChangelogsTable.TABLE_NAME, JSON.stringify(records));
updateWorkspaceIndexDB();
await this.deleteLogsExceedLimit(input.workflowID);
await indexdb.changelogs.add(change);
await this.saveDiskDB();
return change;
}
async deleteLogsExceedLimit(workflowID: string) {
const all = await indexdb.changelogs
.where("workflowID")
.equals(workflowID)
.reverse()
.sortBy("createTime");
const limit = userSettingsTable?.settings?.maximumChangelogNumber ?? LIMIT;
if (all.length > limit) {
const toDelete = all.slice(limit);
await indexdb.changelogs.bulkDelete(toDelete.map((c) => c.id));
}
}
}
+102 -21
View File
@@ -1,45 +1,48 @@
import { deleteFile, saveDB, updateFile } from "../Api";
import { Workflow, foldersTable, userSettingsTable } from "./WorkspaceDB";
import { deleteFile, updateFile } from "../Api";
import { foldersTable, workflowsTable, userSettingsTable } from "./WorkspaceDB";
import {
COMFYSPACE_TRACKING_FIELD_NAME,
LEGACY_COMFYSPACE_TRACKING_FIELD_NAME,
} from "../const";
import { toFileNameFriendly } from "../utils";
import { Folder, Workflow } from "../types/dbTypes";
import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi";
export function saveJsonFileMyWorkflows(
workflow: Workflow,
onFileSaved?: (fullPath: string) => void
) {
const file_path = generateFilePath(workflow);
export async function saveJsonFileMyWorkflows(workflow: Workflow) {
const file_path = await generateFilePath(workflow);
if (file_path == null) {
return;
}
const fullPath = generateFilePathAbsolute(workflow);
fullPath && onFileSaved && onFileSaved(fullPath);
const fullPath = await generateFilePathAbsolute(workflow);
await workflowsTable?.updateMetaInfo(workflow.id, {
filePath: fullPath ?? undefined,
});
const json = workflow.json;
const flow = JSON.parse(json);
delete flow.extra[LEGACY_COMFYSPACE_TRACKING_FIELD_NAME];
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {
id: workflow.id,
name: workflow.name,
};
updateFile(file_path, JSON.stringify(flow));
delete flow.extra[LEGACY_COMFYSPACE_TRACKING_FIELD_NAME];
await updateFile(file_path, JSON.stringify(flow));
}
export function deleteJsonFileMyWorkflows(workflow: Workflow) {
export async function deleteJsonFileMyWorkflows(workflow: Workflow) {
if (workflow.name == null) {
return;
}
const file_path = generateFilePath(workflow);
const file_path = await generateFilePath(workflow);
file_path != null && deleteFile(file_path);
}
export function generateFilePath(workflow: Workflow): string | null {
export async function generateFilePath(
workflow: Workflow,
): Promise<string | null> {
let filePath = toFileNameFriendly(workflow.name) + ".json";
let curFolderID = workflow.parentFolderID;
while (curFolderID != null) {
const folder = foldersTable?.get(curFolderID);
const folder = await foldersTable?.get(curFolderID);
if (folder == null) {
break;
}
@@ -51,10 +54,29 @@ export function generateFilePath(workflow: Workflow): string | null {
return filePath ?? null;
}
export function generateFilePathAbsolute(workflow: Workflow): string | null {
const subPath = generateFilePath(workflow);
let myWorkflowsDir = userSettingsTable?.getSetting("myWorkflowsDir");
if (myWorkflowsDir == null) {
export async function genFolderRelPath(
folderId: string | null,
): Promise<string> {
let filePath = "";
let curFolderID = folderId;
while (curFolderID != null) {
const folder = await foldersTable?.get(curFolderID);
if (folder == null) {
break;
}
const folderName = folder.name;
filePath = `${folderName}/${filePath}`;
curFolderID = folder.parentFolderID ?? null;
}
return filePath;
}
export async function generateFilePathAbsolute(
workflow: Workflow,
): Promise<string | null> {
const subPath = await generateFilePath(workflow);
let myWorkflowsDir = await userSettingsTable?.getSetting("myWorkflowsDir");
if (!myWorkflowsDir) {
console.error("myWorkflowsDir is not set");
return null;
}
@@ -63,3 +85,62 @@ export function generateFilePathAbsolute(workflow: Workflow): string | null {
}
return myWorkflowsDir + subPath;
}
export async function generateFolderPath(id: string): Promise<string | null> {
const folder = await foldersTable?.get(id);
let parentFolderID = folder?.parentFolderID;
let folderPath = folder?.name;
while (parentFolderID) {
const folder = await foldersTable?.get(parentFolderID);
if (folder == null) {
break;
}
folderPath = `${folder.name}/${folderPath}`;
parentFolderID = folder.parentFolderID ?? undefined;
}
let myWorkflowsDir = await userSettingsTable?.getSetting("myWorkflowsDir");
if (!myWorkflowsDir) {
console.error("myWorkflowsDir is not set");
return null;
}
if (!myWorkflowsDir.endsWith("/")) {
myWorkflowsDir = myWorkflowsDir + "/";
}
return myWorkflowsDir + folderPath;
}
export async function getFileCountInFolder(folderId: string): Promise<number> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
return (await TwowayFolderSyncAPI.genFilesCountInFolder(folderId)) ?? 0;
}
const allFlows = (await workflowsTable?.listAll()) ?? [];
const allFolders = (await foldersTable?.listAll()) ?? [];
const nestedFolderIdStack = [folderId];
let count = 0;
while (nestedFolderIdStack.length > 0) {
const curFolderId = nestedFolderIdStack.shift();
if (curFolderId) {
for (const flow of allFlows) {
if (flow.parentFolderID === curFolderId) {
count++;
}
}
const curNestedFolderIds = allFolders
.filter((f) => f.parentFolderID === curFolderId)
.map((f) => f.id);
if (curNestedFolderIds.length) {
nestedFolderIdStack.push(...curNestedFolderIds);
}
}
}
return count;
}
+131 -62
View File
@@ -1,67 +1,74 @@
import { getDB, saveDB } from "../Api";
import { listWorkflows, updateFlow } from "./WorkspaceDB";
import { Folder } from "../types/dbTypes";
import { userSettingsTable, workflowsTable } from "./WorkspaceDB";
import { EFlowOperationType, Folder } from "../types/dbTypes";
import { validateOrSaveAllJsonFileMyWorkflows } from "../utils";
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
import { v4 as uuidv4 } from "uuid";
import { nanoid } from "nanoid";
import { TableBase } from "./TableBase";
import { indexdb } from "./indexdb";
import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi";
import { TwowaySyncAPI, scanLocalFiles } from "../apis/TwowaySyncApi";
export class FoldersTable {
export class FoldersTable extends TableBase<Folder> {
static readonly TABLE_NAME = "folders";
private records: {
[id: string]: Folder;
};
private constructor() {
this.records = {};
constructor() {
super("folders");
}
static async load(): Promise<FoldersTable> {
const instance = new FoldersTable();
let jsonStr = await getDB(FoldersTable.TABLE_NAME);
let json = jsonStr != null ? JSON.parse(jsonStr) : null;
if (json == null) {
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
const comfyspaceData = JSON.parse(comfyspace);
json = comfyspaceData[FoldersTable.TABLE_NAME];
}
if (json != null) {
instance.records = json;
}
return instance;
}
public listAll(): Folder[] {
return Object.values(this.records);
}
public getRecords() {
return this.records;
}
public get(id: string): Folder | undefined {
return this.records[id];
}
public create(input: { name: string; parentFolderID?: string }): Folder {
const uniqueName = this.generateUniqueName(input.name);
public async create(input: {
name: string;
parentFolderID?: string;
}): Promise<Folder> {
const uniqueName = await this.generateUniqueName(
input.name,
input.parentFolderID,
);
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
const rel = await TwowayFolderSyncAPI.genFolderRelPath({
name: uniqueName,
parentFolderID: input.parentFolderID ?? "",
});
const folder: Folder = {
id: uuidv4(),
id: twoWaySyncEnabled ? rel : nanoid(),
name: uniqueName,
parentFolderID: input.parentFolderID ?? null,
updateTime: Date.now(),
createTime: Date.now(),
type: "folder",
};
this.records[folder.id] = folder;
saveDB("folders", JSON.stringify(this.records));
updateWorkspaceIndexDB();
if (twoWaySyncEnabled) {
await TwowayFolderSyncAPI.createFolder(folder);
} else {
await indexdb.folders.add(folder);
this.saveDiskDB();
}
return folder;
}
public update(
input: {
id: string;
} & Partial<Folder>
) {
const folder = this.records[input.id];
if (folder == null) {
return;
public async update(id: string, input: Partial<Folder>) {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
if ("parentFolderID" in input) {
await TwowayFolderSyncAPI.moveFolder(id, input["parentFolderID"] ?? "");
}
if ("name" in input) {
await TwowayFolderSyncAPI.renameFolder(id, input["name"] ?? "");
}
return null;
}
const folder = await this.get(id);
if (folder == null) {
return null;
}
const nameChanged = "name" in input && input.name != folder.name;
const parentFolderChanged =
"parentFolderID" in input &&
input.parentFolderID != folder.parentFolderID;
const newRecord = {
...folder,
...input,
@@ -69,29 +76,91 @@ export class FoldersTable {
if (input.name != null) {
newRecord.updateTime = Date.now();
}
this.records[input.id] = newRecord;
saveDB("folders", JSON.stringify(this.records));
updateWorkspaceIndexDB();
// move or rename all workflows to the right directory(not required when folded state changes)
if (input.name != null || input.parentFolderID != null) {
if (parentFolderChanged) {
input.name = await this.generateUniqueName(
newRecord.name,
newRecord.parentFolderID ?? undefined,
);
}
await indexdb.folders.update(id, input);
this.saveDiskDB();
// folder moved or renamed - move all workflows to the right directory(not required when folded state changes)
if (nameChanged || parentFolderChanged) {
validateOrSaveAllJsonFileMyWorkflows(true);
}
return newRecord;
}
public delete(id: string) {
delete this.records[id];
const childrenFlows = listWorkflows().filter(
(flow) => flow.parentFolderID == id
);
childrenFlows.forEach((flow) =>
updateFlow(flow.id, { parentFolderID: undefined })
);
saveDB("folders", JSON.stringify(this.records));
updateWorkspaceIndexDB();
validateOrSaveAllJsonFileMyWorkflows();
public async deleteFolder(
id: string,
flowOperationType: EFlowOperationType = EFlowOperationType.DELETE,
) {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await scanLocalFiles(id, true, true).then((files) => {
files.forEach((file) => {
if (file.type === "workflow") {
workflowsTable?.deleteFlow(file.id);
}
});
});
await TwowayFolderSyncAPI.deleteFolder(id);
return;
}
/**
* When deleting a folder, if there are files in the folder
* Breadth traverse all nested folders, find all files, move to root directory or delete as needed.
*/
const allFlows = (await workflowsTable?.listAll()) ?? [];
const allFolders = await this.listAll();
const nestedFolderIdStack = [id];
while (nestedFolderIdStack.length > 0) {
const curFolderId = nestedFolderIdStack.shift();
if (curFolderId) {
for (const flow of allFlows) {
if (flow.parentFolderID === curFolderId) {
switch (flowOperationType) {
case EFlowOperationType.DELETE:
await workflowsTable?.deleteFlow(flow.id);
break;
case EFlowOperationType.MOVE_TO_ROOT_FOLDER:
await workflowsTable?.updateFolder(flow.id, {
parentFolderID: undefined,
});
break;
}
}
}
await indexdb.folders.delete(curFolderId);
const curNestedFolderIds = allFolders
.filter((f) => f.parentFolderID === curFolderId)
.map((f) => f.id);
if (curNestedFolderIds.length) {
nestedFolderIdStack.push(...curNestedFolderIds);
}
}
}
this.saveDiskDB();
}
public generateUniqueName(name?: string) {
public async generateUniqueName(name?: string, parentFolderID?: string) {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
const fileName = await TwowaySyncAPI.genFileUniqueName(
name ?? "New folder",
parentFolderID ?? "",
);
return fileName ?? "New folder";
}
let newFlowName = name ?? "New folder";
const folderNameList = this.listAll()?.map((f) => f.name);
const folderNameList = await this.listAll().then((list) =>
list.filter((f) => f.parentFolderID == parentFolderID).map((f) => f.name),
);
if (folderNameList.includes(newFlowName)) {
let num = 2;
let flag = true;
+2 -11
View File
@@ -1,4 +1,5 @@
import { curComfyspaceJson } from "./WorkspaceDB";
// Legacy indexdb backup, to be deleted after indexdb migration is done
const WORKSPACE_KEY = "backup";
const WORKSPACE_TABLE = "workspace";
let indexDB: IDBDatabase | null = null;
@@ -44,15 +45,6 @@ async function writeWorkspaceTable(data: string): Promise<void> {
}
}
export async function updateWorkspaceIndexDB() {
try {
const comfyspaceData = await curComfyspaceJson();
await writeWorkspaceTable(comfyspaceData);
} catch (error) {
console.error("Error writing to IndexedDB:", error);
}
}
// Function to read data from the database
async function readDataFromDatabase(): Promise<string | undefined> {
try {
@@ -79,7 +71,6 @@ async function readDataFromDatabase(): Promise<string | undefined> {
}
}
// Usage example
export async function getWorkspaceIndexDB() {
try {
const comfyspaceData = await readDataFromDatabase();
+36 -29
View File
@@ -1,10 +1,8 @@
import { saveDB } from "../Api";
import { v4 as uuidv4 } from "uuid";
import { Table, getWorkflow, updateFlow } from "./WorkspaceDB";
import { updateWorkspaceIndexDB } from "./IndexDBUtils";
import { Table, workflowsTable } from "./WorkspaceDB";
import { TableBase } from "./TableBase";
import { Media } from "../types/dbTypes";
import { indexdb } from "./indexdb";
import { getMetadataFromUrl } from "../gallery/utils.ts";
export class MediaTable extends TableBase<Media> {
static readonly TABLE_NAME: Table = "media";
@@ -12,18 +10,12 @@ export class MediaTable extends TableBase<Media> {
super("media");
}
public async listByWorkflowID(workflowID: string): Promise<Media[]> {
const records = await this.getRecords();
return Object.values(records)
.filter((c) => c.workflowID === workflowID)
.sort((a, b) => b.createTime - a.createTime);
}
public async getLastestByWorkflowID(workflowID: string): Promise<Media> {
const records = await this.getRecords();
const all = Object.values(records)
.filter((c) => c.workflowID === workflowID)
.sort((a, b) => b.createTime - a.createTime);
return all[0];
const medias = await indexdb["media"]
.where("workflowID")
.equals(workflowID)
.reverse()
.sortBy("createTime");
return medias;
}
public async create(input: {
@@ -32,28 +24,43 @@ export class MediaTable extends TableBase<Media> {
}): Promise<Media | null> {
const format = input.localPath.split(".").pop();
if (format == null) return null;
const res = await getMetadataFromUrl(
`/workspace/view_media?filename=${input.localPath}`,
).catch((e) => {
console.error("get meta error:", e);
return { prompt: "" };
});
const md: Media = {
id: uuidv4(),
id: input.localPath,
localPath: input.localPath,
workflowID: input.workflowID,
createTime: Date.now(),
workflowJSON: JSON.stringify(res?.prompt),
format: format,
};
//link media to workflow
const curMedia = getWorkflow(input.workflowID)?.mediaIDs ?? [];
const newMedia = new Set(curMedia).add(md.id);
updateFlow(input.workflowID, {
mediaIDs: Array.from(newMedia),
coverMediaPath: md.localPath,
await workflowsTable?.updateMetaInfo(input.workflowID, {
latestImage: md.localPath,
});
// save indexdb
// indexdb.media.add(md);
const records = await this.getRecords();
records[md.id] = md;
indexdb.media.put(md);
// save disk file db
saveDB("media", JSON.stringify(records));
// save legacy indexdb backup
updateWorkspaceIndexDB();
this.saveDiskDB();
return md;
}
async delete(id: string): Promise<void> {
super.delete(id);
if (workflowsTable?.curWorkflow?.coverMediaPath === id) {
workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, {
coverMediaPath: undefined,
});
}
if (workflowsTable?.curWorkflow?.latestImage === id) {
workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, {
latestImage: undefined,
});
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Model } from "../types/dbTypes";
import { Table } from "./WorkspaceDB";
import { TableBase } from "./TableBase";
export class ModelsTable extends TableBase<Model> {
static readonly TABLE_NAME: Table = "models";
constructor() {
super("models");
}
static async load(): Promise<ModelsTable> {
const instance = new ModelsTable();
return instance;
}
}
+89 -13
View File
@@ -1,22 +1,92 @@
import { getDB, saveDB } from "../Api";
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
import { Table } from "./WorkspaceDB";
import { getWorkspaceIndexDB } from "./IndexDBUtils";
import { TableBaseModel } from "../types/dbTypes";
import { Table, userSettingsTable } from "./WorkspaceDB";
import { indexdb } from "./indexdb";
import { nanoid } from "nanoid";
export class TableBase<T> {
export class TableBase<T extends TableBaseModel> {
public readonly tableName: Table;
protected constructor(tableName: Table) {
this.tableName = tableName;
}
protected async saveDiskDB() {
const objs = (await indexdb[this.tableName].toArray()) as TableBaseModel[];
const backup: Record<string, TableBaseModel> = {};
objs.forEach((f) => {
// only tags table is using name as primary key, we either give up backup that table to diskDB
// or add a id as primary key to it
if (f.id) {
backup[f.id] = f;
} else if (f.name) {
backup[f.name] = f;
} else {
console.error("TableBaseModel should have id or name");
}
});
try {
saveDB(this.tableName, JSON.stringify(backup));
} catch (e) {
console.error("error saving disk db", e);
}
}
public async add(
newItem: Omit<T, "id" | "createTime"> &
Partial<Pick<T, "id" | "createTime">>,
): Promise<T> {
if (!newItem.id) {
newItem.id = nanoid();
}
if (!newItem.createTime) {
newItem.createTime = Date.now();
}
await indexdb[this.tableName].add(newItem as any);
await this.saveDiskDB();
return newItem as T;
}
public async put(newItem: T): Promise<T> {
await indexdb[this.tableName].put(newItem as any);
await this.saveDiskDB();
return newItem;
}
public async bulkPut(newItems: T[]): Promise<void> {
// @ts-ignore
await indexdb[this.tableName].bulkPut(newItems as any);
this.saveDiskDB();
}
public async update(id: string, changes: Partial<T>): Promise<T | null> {
await indexdb[this.tableName].update(id, changes);
this.saveDiskDB();
return (await this.get(id)) ?? null;
}
public async listAll(): Promise<T[]> {
const records = await this.getRecords();
const workflows = Object.values(records);
return workflows;
const list = await indexdb[this.tableName].toArray();
return list as any[];
}
async filter(filterFn: (obj: T) => boolean): Promise<T[]> {
const list = await indexdb[this.tableName]
.filter(filterFn as any)
.toArray();
return list as any[];
}
public async batchQuery(ids: string[]): Promise<T[]> {
const list = await indexdb[this.tableName].bulkGet(ids);
return list as any[];
}
public async getRecords(): Promise<Record<string, T>> {
let jsonStr = await getDB(this.tableName);
console.warn(
"[DEPRECATED] getRecords() call, should only be used for one-time backfill for indexdb",
this.tableName,
);
const jsonStr = await getDB(this.tableName);
let json: any;
try {
json = jsonStr != null ? JSON.parse(jsonStr) : null;
@@ -28,15 +98,21 @@ export class TableBase<T> {
}
return json ?? {};
}
public async get(id: string): Promise<T | undefined> {
const records = await this.getRecords();
return records[id];
const obj = await indexdb[this.tableName].get(id);
return obj as any;
}
public async delete(id: string) {
const records = await this.getRecords();
delete records[id];
saveDB(this.tableName, JSON.stringify(records));
updateWorkspaceIndexDB();
await indexdb[this.tableName].delete(id);
this.saveDiskDB();
}
public async listByIndex(index: keyof T, value: string): Promise<T[]> {
const list = await indexdb[this.tableName]
.where(index as string)
.equals(value)
.toArray();
return list as any[];
}
}
+79 -30
View File
@@ -1,12 +1,33 @@
import { getDB, getSystemDir, saveDB } from "../Api";
import { UserSettings } from "../types/dbTypes";
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
import type { UserSettings } from "../types/dbTypes";
import { TableBase } from "./TableBase";
import { MODEL_TYPE_TO_FOLDER_MAPPING } from "../model-manager/install-models/util/modelTypes";
import { fetchMyWorkflowsDir } from "../Api";
export class UserSettingsTable {
public records: UserSettings;
export class UserSettingsTable extends TableBase<UserSettings> {
public defaultSettings: UserSettings;
public readonly DEFAULT_USER = "guest";
static readonly TABLE_NAME = "userSettings";
private constructor() {
this.records = {
/**
* Because in App.js we will use window.addEventListener("beforeunload", handleBeforeUnload); to remind the user to save the workflow when the user leaves the page.
* The autoSave needs to be obtained in the handleBeforeUnload function. If it is obtained through getSetting, the handleBeforeUnload function will become an async function, which will cause the "beforeunload" event to fail.
* So we maintain an autoSave here that is always up to date.
*/
private _autoSave: boolean = false;
private _settings: UserSettings | undefined = undefined;
get settings() {
return this._settings;
}
get autoSave() {
return this._autoSave;
}
constructor() {
super("userSettings");
this.defaultSettings = {
id: this.DEFAULT_USER,
topBarStyle: {
top: 0,
left: 0,
@@ -14,40 +35,68 @@ export class UserSettingsTable {
myWorkflowsDir: "",
shortcuts: {
save: "Shift+S",
saveAs: "Control+Alt+S",
openSpotlightSearch: "Control+P",
},
autoSave: true,
defaultFolders: MODEL_TYPE_TO_FOLDER_MAPPING,
autoSave: false,
autoSaveDuration: 3,
twoWaySync: false,
foldersOnTop: false,
cloudHost: "https://www.nodecafe.org",
overwriteCurWorkflowWhenDroppingFileToCanvas: false,
maximumChangelogNumber: 80,
hideCoverImage: false,
disableUnsavedWarning: false,
};
}
public listSettings() {
return this.records;
public async getSetting<K extends keyof UserSettings>(
key: K,
): Promise<UserSettings[K]> {
const currentUserSettings: UserSettings | undefined = await this.get(
this.DEFAULT_USER,
);
if (key === "shortcuts" && currentUserSettings?.shortcuts) {
return {
...this.defaultSettings.shortcuts,
...currentUserSettings.shortcuts,
} as UserSettings[K];
}
return currentUserSettings?.[key] ?? this.defaultSettings[key];
}
public getSetting<K extends keyof UserSettings>(key: K): UserSettings[K] {
return this.records[key];
}
public upsert(newPairs: Partial<UserSettings>) {
this.records = {
...this.records,
public async upsert(newPairs: Partial<UserSettings>) {
const oldSettings =
(await this.get(this.DEFAULT_USER)) ?? this.defaultSettings;
const newSettings = {
...oldSettings,
...newPairs,
};
saveDB(UserSettingsTable.TABLE_NAME, JSON.stringify(this.records));
updateWorkspaceIndexDB();
await this.put(newSettings);
this._settings = {
...this.defaultSettings,
...newSettings,
};
}
static async load(): Promise<UserSettingsTable> {
const instance = new UserSettingsTable();
const jsonStr = await getDB(UserSettingsTable.TABLE_NAME);
let json = jsonStr != null ? JSON.parse(jsonStr) : null;
if (json == null) {
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
const comfyspaceData = JSON.parse(comfyspace);
json = comfyspaceData[UserSettingsTable.TABLE_NAME] || {};
}
if (!json.myWorkflowsDir) {
const getDir = await getSystemDir();
json.myWorkflowsDir = `${getDir.dir_path}/my_workflows`;
}
instance.records = json;
const myWorkflowsDir = await fetchMyWorkflowsDir();
instance.defaultSettings.myWorkflowsDir = myWorkflowsDir.path!;
await instance.get(instance.DEFAULT_USER).then((res) => {
instance._autoSave = res?.autoSave ?? false;
instance._settings = {
...instance.defaultSettings,
...res,
};
});
// overwrite legacy comfyspace.art
await instance.upsert({
cloudHost: instance.defaultSettings.cloudHost,
});
return instance;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Table } from "./WorkspaceDB";
import { WorkflowVersion } from "../types/dbTypes";
import { TableBase } from "./TableBase";
import { indexdb } from "./indexdb";
export class WorkflowVersionsTable extends TableBase<WorkflowVersion> {
static readonly TABLE_NAME: Table = "workflowVersions";
constructor() {
super("workflowVersions");
}
static async load(): Promise<WorkflowVersionsTable> {
const instance = new WorkflowVersionsTable();
return instance;
}
public async listByWorkflowID(
workflowID: string,
): Promise<WorkflowVersion[]> {
const objects = (await indexdb[this.tableName]
.where("workflowID")
.equals(workflowID)
.reverse()
.sortBy("createTime")) as WorkflowVersion[];
return objects ?? [];
}
public async getLatestByWorkflowID(
workflowID: string,
): Promise<WorkflowVersion | null> {
const objects = await this.listByWorkflowID(workflowID);
if (objects?.length) return objects[0];
return null;
}
}
+429
View File
@@ -0,0 +1,429 @@
import {
changelogsTable,
foldersTable,
userSettingsTable,
} from "./WorkspaceDB";
import { Folder, Workflow } from "../types/dbTypes";
import { sortFileItem } from "../utils";
import { nanoid } from "nanoid";
import { TableBase } from "./TableBase";
import { indexdb } from "./indexdb";
import {
deleteJsonFileMyWorkflows,
saveJsonFileMyWorkflows,
} from "./DiskFileUtils";
import { ESortTypes, ImportWorkflow } from "../RecentFilesDrawer/types";
import { defaultGraph } from "../defaultGraph";
import { scanMyWorkflowsDir } from "../utils/twowaySyncUtils";
import {
ScanLocalFile,
TwowaySyncAPI,
scanLocalFiles,
} from "../apis/TwowaySyncApi";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import { tabDataManager } from "../topbar/multipleTabs/TabDataManager";
export class WorkflowsTable extends TableBase<Workflow> {
static readonly TABLE_NAME = "workflows";
private _curWorkflow: Workflow | null = null;
public get curWorkflow() {
return this._curWorkflow;
}
constructor() {
super("workflows");
}
static async load(): Promise<WorkflowsTable> {
const instance = new WorkflowsTable();
return instance;
}
public updateCurWorkflowID(id: string | null) {
if (id == null) {
this._curWorkflow = null;
return;
}
this.get(id).then((w) => {
this._curWorkflow = w ?? null;
});
}
public updateCurWorkflow(workflow: Workflow | null) {
this._curWorkflow = workflow;
}
/**
* Check whether the currently opened workflow is the latest version and is consistent with the DB.
* Through updateTime comparison, if it is inconsistent, it means that a newer version already exists in the DB.
* If the automatic save function is turned on at this time, the latest version in the DB will be overwritten.
* Therefore, the user needs to confirm whether to continue to enable automatic saving.
* @returns boolean
*/
public async latestVersionCheck() {
if (this._curWorkflow) {
const curFlowInDB = await indexdb.workflows.get(this._curWorkflow.id);
if (curFlowInDB) {
return curFlowInDB?.updateTime === this._curWorkflow.updateTime;
}
return true;
}
return true;
}
public async listByIndex(
index: keyof Workflow,
value: string,
): Promise<Workflow[]> {
const list = await super.listByIndex(index, value);
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (!twoWaySyncEnabled) {
return list;
}
// for two way sync we need to varify if it really exists in disk
const results = await Promise.all(
list.map(async (f) => await this.get(f.id)),
);
return results.filter((f) => f != null) as Workflow[];
}
async generateUniqueName(name?: string, parentFolderID?: string) {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
const fileName = await TwowaySyncAPI.genFileUniqueName(
(name ?? "Untitled Flow") + ".json",
parentFolderID ?? "",
);
return fileName?.replace(/\.json$/, "") ?? "Untitled Flow";
}
/**
* Generate a unique name
* For imported scenes, the default name is the file name.
* For new scenes, the default name is Untitled Flow.
* Get the full workflow list. If the default name already exists, search incrementally starting from 2.
* Looks for a unique name in the format `${default name} ${number}`.
*/
let newFlowName = name ?? "Untitled Flow";
const flowList = (await this.listAll()) ?? [];
const flowNameList = flowList
.filter((f) => f.parentFolderID == parentFolderID)
.map((flow) => flow.name);
if (flowNameList.includes(newFlowName)) {
let num = 2;
let flag = true;
while (flag) {
if (flowNameList.includes(`${newFlowName} ${num}`)) {
num++;
} else {
newFlowName = `${newFlowName} ${num}`;
flag = false;
}
}
}
return newFlowName;
}
public async createFlow(
input: Partial<Workflow>,
): Promise<Workflow | undefined> {
const { name } = input;
const newFlowName = await this.generateUniqueName(
name,
input.parentFolderID ?? undefined,
);
const time = Date.now();
const newWorkflow: Workflow = {
...input,
id: input.id ?? nanoid(),
json: input.json ?? JSON.stringify(defaultGraph),
name: newFlowName,
updateTime: input.updateTime ?? time,
createTime: input.createTime ?? time,
};
//add to IndexDB
await indexdb.workflows.add(newWorkflow);
// add to disk file db
this.saveDiskDB();
// add to my_workflows/
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await TwowaySyncAPI.createWorkflow(newWorkflow);
return await this.get(newWorkflow.id);
} else {
saveJsonFileMyWorkflows(newWorkflow);
}
return newWorkflow;
}
// disallow TableBase.update()
async update(
_id: string,
_change: Partial<Workflow>,
): Promise<Workflow | null> {
throw new Error("Method not allowed.");
}
public async updateTopFields(
id: string,
change: Pick<Partial<Workflow>, "topFieldsConfig">,
): Promise<Workflow | null> {
return this._update(id, change);
}
public async updateMetaInfo(
id: string,
change: Omit<Partial<Workflow>, "id" | "name" | "parentFolderID" | "json">,
): Promise<Workflow | null> {
return this._update(id, change);
}
private async _update(
id: string,
change: Partial<Workflow>,
): Promise<Workflow | null> {
//update indexdb
await indexdb.workflows.update(id, change);
const newWorkflow = (await indexdb.workflows.get(id)) ?? null;
//update curWorkflow RAM
if (this._curWorkflow && this._curWorkflow.id === id) {
this._curWorkflow = newWorkflow;
}
this.saveDiskDB();
return newWorkflow;
}
async updateFolder(id: string, change: Pick<Workflow, "parentFolderID">) {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
const oldWorkflow = await this.get(id);
const newWorkflow = await this.updateMetaInfo(id, change as any);
if (!newWorkflow || !oldWorkflow) {
return;
}
if (twoWaySyncEnabled) {
await TwowaySyncAPI.moveWorkflow(oldWorkflow, change.parentFolderID!);
} else {
await saveJsonFileMyWorkflows(newWorkflow);
await deleteJsonFileMyWorkflows(oldWorkflow);
// update parent folder updateTime
if (newWorkflow?.parentFolderID != null) {
await indexdb.folders?.update(newWorkflow.parentFolderID, {
updateTime: Date.now(),
});
}
}
}
public async genLastManualSavedJson(id: string) {
const changelog = await changelogsTable?.getLastestByWorkflowID(id);
return changelog?.json;
}
public async updateName(id: string, change: Pick<Workflow, "name">) {
const before = await this.get(id);
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
before &&
(await TwowaySyncAPI.renameWorkflow(before, change.name + ".json"));
}
tabDataManager.updateTabData(tabDataManager.activeIndex, change);
return await this.updateMetaInfo(id, change as any);
}
public async updateFlow(id: string, input: Pick<Workflow, "json">) {
const before = await this.get(id, false);
if (before == null) {
return;
}
const beforeStr = JSON.stringify(before.json);
const afterStr = JSON.stringify(input.json);
if (beforeStr === afterStr) {
// no change detected
return;
}
const after = await this._update(id, {
updateTime: Date.now(),
json: input.json,
});
// save to my_workflows/
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
after && (await TwowaySyncAPI.saveWorkflow(after));
} else {
after && (await saveJsonFileMyWorkflows(after));
}
}
/**
* Add flows in batches
* @param flowList Need to add a new flow list
* @param isOverwriteExistingFile By automatically scanning the newly added flow on the local disk,
* when synchronizing the DB, the flow on the local disk needs to be rewritten
* because extra.comfyspace_tracking.id needs to be appended to json.
* @param parentFolderID If you are adding batches to the specified files, provide the folder id.
* @returns
*/
public async batchCreateFlows(
flowList: ImportWorkflow[] = [],
isOverwriteExistingFile: boolean = false,
parentFolderID?: string,
) {
const newWorkflows: Workflow[] = [];
for (const flow of flowList) {
const newFlowName =
flow.name && isOverwriteExistingFile
? flow.name
: await this.generateUniqueName(flow.name, parentFolderID);
const uuid = nanoid();
const time = Date.now();
const newWorkflow: Workflow = {
id: uuid,
name: newFlowName,
json: flow.json,
parentFolderID: parentFolderID,
updateTime: time,
createTime: time,
tags: [],
};
newWorkflows.push(newWorkflow);
const twoWaySyncEnabled =
await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
try {
const jsonObj = JSON.parse(flow.json);
const existingWorkflowID =
jsonObj?.extra?.[COMFYSPACE_TRACKING_FIELD_NAME]?.id;
if (existingWorkflowID) {
const existingWorkflow = await this.get(existingWorkflowID);
if (!existingWorkflow) {
newWorkflow.id = existingWorkflowID;
}
}
TwowaySyncAPI.createWorkflow(newWorkflow);
} catch (e) {
console.error("batchCreateFlows error", e);
}
} else {
await saveJsonFileMyWorkflows(newWorkflow);
}
}
try {
await indexdb.workflows.bulkAdd(newWorkflows);
this.saveDiskDB();
} catch (e) {
console.error("batchCreateFlows error", e);
}
}
public async deleteFlow(id: string) {
const workflow = await this.get(id);
if (workflow) {
const twoWaySyncEnabled =
await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await TwowaySyncAPI.deleteWorkflow(workflow);
} else {
deleteJsonFileMyWorkflows({ ...workflow });
}
}
const tabIndex = tabDataManager.tabs.findIndex((tab) => tab.id === id);
if (tabIndex !== -1) {
tabDataManager?.deleteTabData(tabIndex);
}
//add to IndexDB
await indexdb.workflows.delete(id);
this.saveDiskDB();
}
public async batchDeleteFlow(ids: string[]) {
const workflowList = (await indexdb.workflows.bulkGet(ids)) as Workflow[];
for (const flow of workflowList) {
if (!flow) return;
const twoWaySyncEnabled =
await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await TwowaySyncAPI.deleteWorkflow(flow);
} else {
deleteJsonFileMyWorkflows({ ...flow });
}
}
await indexdb.workflows.bulkDelete(ids);
tabDataManager.batchDeleteTabData(ids);
this.saveDiskDB();
}
public async listFolderContent(
folderID?: string, // undefined if root folder
sortBy?: ESortTypes,
): Promise<(Workflow | Folder)[]> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
const items = await scanMyWorkflowsDir(folderID ?? null);
return sortFileItem(items, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
}
const workflows =
(await this.listAll().then((list) =>
list.filter((w) => w.parentFolderID == folderID),
)) ?? [];
const folders =
(await foldersTable
?.listAll()
.then((list) => list.filter((f) => f.parentFolderID == folderID))) ??
[];
const all = [...workflows, ...folders];
return sortFileItem(all, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
}
public async get(
id: string,
fetchJsonContentOnDisk = true, //if false, only get the meta info from indexdb
): Promise<Workflow | undefined> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
const wf = await indexdb.workflows.get(id);
if (!twoWaySyncEnabled || !fetchJsonContentOnDisk) {
return wf;
}
// two way sync mode
if (wf == null) {
return undefined;
}
const data = await TwowaySyncAPI.getFile({
parentFolderID: wf.parentFolderID ?? null,
name: wf.name,
id: wf.id,
});
if (!data.json) {
return undefined;
}
return {
...wf,
json: JSON.stringify(data.json),
};
}
public async listAll(sortBy?: ESortTypes): Promise<Workflow[]> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
let list: Workflow[];
if (twoWaySyncEnabled) {
const fileList = (await scanLocalFiles("", true)).filter(
(f) => f?.type === "workflow",
) as ScanLocalFile[];
const allFilesPromises = fileList.map(async (file) => {
const fileInfoInDB = await indexdb.workflows.get(file.id);
file.updateTime = fileInfoInDB?.updateTime ?? Date.now();
fileInfoInDB?.lastOpenedTime &&
(file.lastOpenedTime = fileInfoInDB?.lastOpenedTime);
return file;
});
list = await Promise.all(allFilesPromises);
} else {
list = await indexdb.workflows.toArray();
}
return sortBy ? sortFileItem(list, sortBy) : list;
}
public async updateLastOpenedTime(id: string) {
await indexdb.workflows.update(id, {
lastOpenedTime: Date.now(),
});
}
}
+99 -281
View File
@@ -1,20 +1,12 @@
import { v4 as uuidv4 } from "uuid";
import { getDB, saveDB, updateFile } from "../Api";
import { generateUniqueName, sortFileItem, sortFlows } from "../utils";
import { ESortTypes, ImportWorkflow } from "../RecentFilesDrawer/types";
import { ChangelogsTable } from "./ChangelogsTable";
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
import { WorkflowsTable } from "./WorkflowsTable";
import { FoldersTable } from "./FoldersTable";
import { MediaTable } from "./MediaTable";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import {
deleteJsonFileMyWorkflows,
generateFilePath,
generateFilePathAbsolute,
} from "./DiskFileUtils";
import { Folder, TagsTable } from "../types/dbTypes";
import { loadTagsTable } from "./tagsTable";
import { UserSettingsTable } from "./UserSettingsTable";
import { TagsTable } from "./tagsTable";
import { indexdb } from "./indexdb";
import { Folder, Workflow } from "../types/dbTypes";
import { WorkflowVersionsTable } from "./WorkflowVersionsTable";
export type Table =
| "workflows"
@@ -22,55 +14,28 @@ export type Table =
| "userSettings"
| "folders"
| "changelogs"
| "media";
| "media"
| "models"
| "workflowVersions";
interface SortableItem {
name: string;
updateTime: number;
}
export interface Workflow extends SortableItem {
id: string;
json: string;
lastSavedJson?: string;
name: string;
updateTime: number;
createTime: number;
filePath?: string;
tags?: string[];
parentFolderID?: string;
mediaIDs?: string[];
coverMediaPath?: string;
}
export function isFolder(n: Folder | Workflow): n is Folder {
// @ts-ignore
return n.type === "folder";
return "type" in n && n.type === "folder";
}
export type Workflows = {
[id: string]: Workflow;
};
export let workspace: Workflows | undefined = undefined;
export let workflowsTable: WorkflowsTable | null = null;
export let tagsTable: TagsTable | null = null;
export let userSettingsTable: UserSettingsTable | null = null;
export let foldersTable: FoldersTable | null = null;
export let changelogsTable: ChangelogsTable | null = null;
export let mediaTable: MediaTable | null = null;
export let workflowVersionsTable: WorkflowVersionsTable | null = null;
export async function loadDBs() {
const loadWorkflows = async () => {
const workflowsStr = await getDB("workflows");
if (workflowsStr == null) {
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
const comfyspaceData = JSON.parse(comfyspace);
workspace = comfyspaceData["workflows"] || {};
} else {
workspace = JSON.parse(workflowsStr);
}
workflowsTable = await WorkflowsTable.load();
};
const loadTags = async () => {
tagsTable = await loadTagsTable();
tagsTable = await TagsTable.load();
};
const loadUserSettings = async () => {
userSettingsTable = await UserSettingsTable.load();
@@ -84,6 +49,9 @@ export async function loadDBs() {
const loadMedia = async () => {
mediaTable = new MediaTable();
};
const loadWorkflowVersions = async () => {
workflowVersionsTable = await WorkflowVersionsTable.load();
};
await Promise.all([
loadWorkflows(),
loadTags(),
@@ -91,238 +59,88 @@ export async function loadDBs() {
loadFolders(),
loadChangelogs(),
loadMedia(),
loadWorkflowVersions(),
]);
if (localStorage.getItem("WORKSPACE_INDEXDB_BACKFILL") !== "true") {
await backfillIndexdb();
localStorage.setItem("WORKSPACE_INDEXDB_BACKFILL", "true");
}
}
export async function backfillIndexdb() {
const backfillWorkflows = async () => {
try {
const all = await workflowsTable?.getRecords();
all && (await indexdb.workflows.bulkPut(Object.values(all)));
} catch (error) {
console.error(error);
}
};
const backfillFolders = async () => {
try {
const all = await foldersTable?.getRecords();
all && (await indexdb.folders.bulkPut(Object.values(all)));
} catch (error) {
console.error(error);
}
};
const backfillMedia = async () => {
try {
const all = await mediaTable?.getRecords();
all && (await indexdb.media.bulkPut(Object.values(all)));
} catch (error) {
console.error(error);
}
};
const backfillChangelogs = async () => {
try {
const all = await changelogsTable?.getRecords();
all && (await indexdb.changelogs.bulkPut(Object.values(all)));
} catch (error) {
console.error(error);
}
};
const backfillTags = async () => {
try {
const all = await tagsTable?.getRecords();
all && (await indexdb.tags.bulkPut(Object.values(all)));
} catch (error) {
console.error(error);
}
};
const backfillUserSettings = async () => {
try {
const tableInstance = userSettingsTable!;
const backupData = await tableInstance.getRecords().then((data) => {
if (!data[tableInstance.DEFAULT_USER]) {
// legacy
return data;
}
return data[tableInstance.DEFAULT_USER];
});
await indexdb.userSettings.put({
...tableInstance.defaultSettings,
...backupData,
});
} catch (error) {
console.error(error);
}
};
const backfillWorkflowVersions = async () => {
try {
const all = await workflowVersionsTable?.getRecords();
all && (await indexdb.workflowVersions.bulkPut(Object.values(all)));
} catch (error) {
console.error(error);
}
};
await Promise.all([
backfillWorkflows(),
backfillFolders(),
backfillMedia(),
backfillChangelogs(),
backfillTags(),
backfillUserSettings(),
backfillWorkflowVersions(),
]);
}
export function listFolderContent(
folderID?: string, // undefined if root folder
sortBy?: ESortTypes
): Array<Workflow | Folder> {
if (workspace == null) {
return [];
}
const workflows = Object.values(workspace).filter(
(w) => w.parentFolderID == folderID
);
const folders =
foldersTable?.listAll()?.filter((f) => f.parentFolderID == folderID) ?? [];
const all = [...workflows, ...folders];
return sortFileItem(all, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
}
/** Class Workflow: below will be migrated to a class */
export function updateFlow(id: string, input: Partial<Workflow>) {
if (workspace == null) {
return;
}
const before = workspace[id];
if (before == null) {
return;
}
const after = {
...before,
...input,
id,
};
const beforeStr = JSON.stringify(before);
const afterStr = JSON.stringify(after);
if (beforeStr === afterStr) {
// no change detected
return;
}
let newWorkflow: Workflow = after;
// When modifying the associated tag or modifying the directory, updateTime is not modified.
const updateKey = Object.keys(input);
const isModifyingTagOrFolder =
updateKey.length === 1 && ["tags", "parentFolderID"].includes(updateKey[0]);
if (!isModifyingTagOrFolder) {
newWorkflow.updateTime = Date.now();
}
// update memory
workspace[id] = newWorkflow;
//update indexdb
// indexdb.workflows.update(id, newWorkflow);
//update legacy indexdb backup
updateWorkspaceIndexDB();
// update disk file db
saveDB("workflows", JSON.stringify(workspace));
// save to my_workflows/
if (input.name != null || input.parentFolderID != null) {
// renamed file or moved file folder
deleteJsonFileMyWorkflows(before);
saveJsonFileMyWorkflows(after);
return;
}
if (input.json != null) {
if (newWorkflow.parentFolderID != null) {
// update parent folder updateTime
foldersTable?.update({
id: newWorkflow.parentFolderID,
updateTime: Date.now(),
});
}
saveJsonFileMyWorkflows(after);
}
}
export async function saveJsonFileMyWorkflows(workflow: Workflow) {
const file_path = generateFilePath(workflow);
if (file_path == null) {
return;
}
if (workspace != null) {
const fullPath = generateFilePathAbsolute(workflow);
workspace[workflow.id].filePath = fullPath ?? undefined;
await updateWorkspaceIndexDB();
await saveDB("workflows", JSON.stringify(workspace));
}
const json = workflow.json;
const flow = JSON.parse(json);
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {
id: workflow.id,
name: workflow.name,
};
await updateFile(file_path, JSON.stringify(flow));
}
export function createFlow({
json,
name,
parentFolderID,
tags,
}: {
json: string;
name?: string;
parentFolderID?: string;
tags?: string[];
}): Workflow {
if (workspace == null) {
throw new Error("workspace is not loaded");
}
const newFlowName = generateUniqueName(name);
const uuid = uuidv4(); // ⇨ '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
const time = Date.now();
const newWorkflow: Workflow = {
id: uuid,
name: newFlowName,
json,
parentFolderID: parentFolderID,
updateTime: time,
createTime: time,
tags: tags ?? [],
};
//add to cache
workspace[uuid] = newWorkflow;
//add to IndexDB
// indexdb.workflows.add(newWorkflow);
// add to disk file db
saveDB("workflows", JSON.stringify(workspace));
// legacy index cache
updateWorkspaceIndexDB();
// add to my_workflows/
saveJsonFileMyWorkflows(workspace[uuid]);
return workspace[uuid];
}
/**
* Add flows in batches
* @param flowList Need to add a new flow list
* @param isOverwriteExistingFile By automatically scanning the newly added flow on the local disk,
* when synchronizing the DB, the flow on the local disk needs to be rewritten
* because extra.comfyspace_tracking.id needs to be appended to json.
* @param parentFolderID If you are adding batches to the specified files, provide the folder id.
* @returns
*/
export async function batchCreateFlows(
flowList: ImportWorkflow[] = [],
isOverwriteExistingFile: boolean = false,
parentFolderID?: string
): Promise<string | undefined> {
flowList.forEach((flow) => {
if (workspace == null) {
return;
}
const newFlowName =
flow.name && isOverwriteExistingFile
? flow.name
: generateUniqueName(flow.name);
const uuid = uuidv4();
const time = Date.now();
workspace[uuid] = {
id: uuid,
name: newFlowName,
json: flow.json,
parentFolderID: parentFolderID,
updateTime: time,
createTime: time,
tags: [],
};
saveJsonFileMyWorkflows(workspace[uuid]);
});
const stringifyWorkspace = JSON.stringify(workspace);
updateWorkspaceIndexDB();
return await saveDB("workflows", stringifyWorkspace);
}
export function listWorkflows(sortBy?: ESortTypes): Workflow[] {
if (workspace == null) {
throw new Error("workspace is not loaded");
}
const workflows = Object.values(workspace);
return sortFlows(workflows, sortBy);
}
export function getWorkflow(id: string): Workflow | undefined {
if (workspace == null) {
console.error("workspace is not loaded");
return;
}
return workspace[id];
}
export function deleteFlow(id: string) {
if (workspace == null) {
return;
}
const workflow = workspace[id];
if (workflow) {
deleteJsonFileMyWorkflows({ ...workflow });
}
delete workspace[id];
updateWorkspaceIndexDB();
saveDB("workflows", JSON.stringify(workspace));
}
export function batchDeleteFlow(ids: string[]) {
ids.forEach((id) => {
if (workspace == null) {
return;
}
const workflow = workspace[id];
if (workflow) {
deleteJsonFileMyWorkflows({ ...workflow });
}
workspace && delete workspace[id];
});
const stringifyWorkspace = JSON.stringify(workspace);
updateWorkspaceIndexDB();
saveDB("workflows", stringifyWorkspace);
}
/** End of Class Workflow */
export async function curComfyspaceJson(): Promise<string> {
const changeLogs = await changelogsTable?.getRecords();
const media = await mediaTable?.getRecords();
return JSON.stringify({
[UserSettingsTable.TABLE_NAME]: userSettingsTable?.records,
["tags"]: tagsTable?.tags,
["workflows"]: workspace,
[FoldersTable.TABLE_NAME]: foldersTable?.getRecords(),
[ChangelogsTable.TABLE_NAME]: changeLogs,
[MediaTable.TABLE_NAME]: media,
});
}
+43 -12
View File
@@ -1,25 +1,56 @@
// db.ts
import Dexie, { Table } from "dexie";
import { Workflow } from "./WorkspaceDB";
import { Changelog, Media } from "../types/dbTypes";
import {
Workflow,
LocalCache,
Changelog,
Folder,
Media,
Model,
Tag,
UserSettings,
WORKSPACE_INDEXDB_NAME,
WorkflowVersion,
} from "../types/dbTypes";
class WorkspaceDB extends Dexie {
class ManagerDB extends Dexie {
workflows!: Table<Workflow, string>;
changelogs!: Table<Changelog, string>;
media!: Table<Media, string>;
folders!: Table<Folder, string>;
tags!: Table<Tag, string>;
userSettings!: Table<UserSettings, string>;
models!: Table<Model, string>;
cache!: Table<LocalCache, string>;
workflowVersions!: Table<WorkflowVersion, string>;
constructor() {
super("WorkspaceDB");
super(WORKSPACE_INDEXDB_NAME);
this.version(1)
.stores({
workflows: "++id, name", // Primary key and indexed props
changelogs: "++id, workflowID",
media: "++id, workflowID",
workflows: "&id, name, parentFolderID", // Primary key and indexed props
changelogs: "&id, workflowID",
media: "&id, workflowID",
folders: "&id, name, parentFolderID",
tags: "&name",
userSettings: "&id",
models: "&id, fileName, fileHash",
cache: "&id",
})
.upgrade((trans) => {
// Here you can write logic to initialize or migrate data to the new 'media' table, if necessary
});
.upgrade((trans) => {});
this.version(2)
.stores({
workflows: "&id, name, parentFolderID,cloudID",
changelogs: "&id, workflowID",
media: "&id, workflowID",
folders: "&id, name, parentFolderID",
tags: "&name",
userSettings: "&id",
models: "&id, fileName, fileHash",
cache: "&id",
workflowVersions: "&id, name, workflowID,cloudID",
})
.upgrade((trans) => {});
}
}
export const indexdb = new WorkspaceDB();
export const indexdb = new ManagerDB();
+13 -34
View File
@@ -1,37 +1,16 @@
import { getDB, saveDB } from "../Api";
import { Tags, TagsTable } from "../types/dbTypes";
import { getWorkspaceIndexDB } from "./IndexDBUtils";
import { Tag } from "../types/dbTypes";
import { Table } from "./WorkspaceDB";
import { TableBase } from "./TableBase";
export async function loadTagsTable(): Promise<TagsTable> {
const tagsStr = await getDB("tags");
let tags: Tags;
if (tagsStr == null) {
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
const comfyspaceData = JSON.parse(comfyspace);
tags = comfyspaceData["tags"] || {};
} else {
tags = JSON.parse(tagsStr ?? "{}") ?? {};
export class TagsTable extends TableBase<Tag> {
static readonly TABLE_NAME: Table = "tags";
constructor() {
super("tags");
}
static async load(): Promise<TagsTable> {
const instance = new TagsTable();
return instance;
}
return {
tags, // Expose the tags array publicly
listAll() {
return Object.values(tags).sort((a, b) => b.updateTime - a.updateTime);
},
upsert(name: string) {
if (tags[name] == null) {
tags[name] = {
name,
workflowIDs: [],
updateTime: Date.now(),
};
}
tags[name].updateTime = Date.now();
saveDB("tags", JSON.stringify(tags));
return tags[name];
},
delete(name: string) {
delete tags[name];
saveDB("tags", JSON.stringify(tags));
},
};
}
+133 -117
View File
@@ -1,119 +1,135 @@
export const defaultGraph = {
last_node_id: 9,
last_link_id: 9,
nodes: [
{
id: 7,
type: "CLIPTextEncode",
pos: [413, 389],
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [6], slot_index: 0 }],
properties: {},
widgets_values: ["text, watermark"],
},
{
id: 6,
type: "CLIPTextEncode",
pos: [415, 186],
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [4], slot_index: 0 }],
properties: {},
widgets_values: ["beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"],
},
{
id: 5,
type: "EmptyLatentImage",
pos: [473, 609],
size: { 0: 315, 1: 106 },
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
},
{
id: 3,
type: "KSampler",
pos: [863, 186],
size: { 0: 315, 1: 262 },
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
},
{
id: 8,
type: "VAEDecode",
pos: [1209, 188],
size: { 0: 210, 1: 46 },
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
},
{
id: 9,
type: "SaveImage",
pos: [1451, 189],
size: { 0: 210, 1: 26 },
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
},
{
id: 4,
type: "CheckpointLoaderSimple",
pos: [26, 474],
size: { 0: 315, 1: 98 },
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
],
groups: [],
config: {},
extra: {},
version: 0.4,
last_node_id: 9,
last_link_id: 9,
nodes: [
{
id: 7,
type: "CLIPTextEncode",
pos: [413, 389],
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
links: [6],
slot_index: 0,
},
],
properties: {},
widgets_values: ["text, watermark"],
},
{
id: 6,
type: "CLIPTextEncode",
pos: [415, 186],
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
links: [4],
slot_index: 0,
},
],
properties: {},
widgets_values: [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
],
},
{
id: 5,
type: "EmptyLatentImage",
pos: [473, 609],
size: { 0: 315, 1: 106 },
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
},
{
id: 3,
type: "KSampler",
pos: [863, 186],
size: { 0: 315, 1: 262 },
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
},
{
id: 8,
type: "VAEDecode",
pos: [1209, 188],
size: { 0: 210, 1: 46 },
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
},
{
id: 9,
type: "SaveImage",
pos: [1451, 189],
size: { 0: 210, 1: 26 },
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
},
{
id: 4,
type: "CheckpointLoaderSimple",
pos: [26, 474],
size: { 0: 315, 1: 98 },
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
],
groups: [],
config: {},
extra: {},
version: 0.4,
};
+18
View File
@@ -0,0 +1,18 @@
import { type Dispatch, type SetStateAction, createContext } from "react";
import type { Media } from "../types/dbTypes.ts";
interface GalleryContextProps {
showAllImages: boolean;
curMedia: Media | null;
setCurMedia: (media: Media | null) => void;
setMediaList: Dispatch<SetStateAction<Media[]>>;
setShowAllImages: (showAllImages: boolean) => void;
}
export const GalleryContext = createContext<GalleryContextProps>({
showAllImages: false,
curMedia: null,
setCurMedia(): void {},
setMediaList(): void {},
setShowAllImages(): void {},
});
+96 -224
View File
@@ -1,260 +1,132 @@
// @ts-expect-error ComfyUI import
import { app } from "/scripts/app.js";
import {
Button,
Checkbox,
Flex,
Heading,
HStack,
Text,
IconButton,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Image,
Link,
Stack,
Tooltip,
IconButton,
Heading,
Checkbox,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
} from "@chakra-ui/react";
import { useContext, useEffect, useState } from "react";
import { getWorkflow, mediaTable, updateFlow } from "../db-tables/WorkspaceDB";
import { IconDownload, IconPin, IconPinFilled, IconTrash, IconX } from "@tabler/icons-react";
import { mediaTable, workflowsTable } from "../db-tables/WorkspaceDB";
import { IconX } from "@tabler/icons-react";
import { WorkspaceContext } from "../WorkspaceContext";
import { formatTimestamp, isImageFormat } from "../utils";
import { useDialog } from "../components/AlertDialogProvider";
import { Media } from "../types/dbTypes";
const IMAGE_SIZE = 200;
import { GalleryCarouselImageViewer } from "./components/GalleryCarouselImageViewer.tsx";
import GalleryGridView from "./components/GalleryGridView.tsx";
import { useDebounce } from "../customHooks/useDebounce.ts";
import SearchInput from "../components/SearchInput.tsx";
import { GalleryContext } from "./GalleryContext.ts";
export default function GalleryModal({ onclose }: { onclose: () => void }) {
const { curFlowID, loadNewWorkflow, loadFilePath } =
useContext(WorkspaceContext);
const workflow = curFlowID != null ? getWorkflow(curFlowID) : null;
const { curFlowID } = useContext(WorkspaceContext);
const [workflowName, setWorkflowName] = useState("");
const [selectedID, setSelectedID] = useState<string[]>([]);
const [isSelecting, setIsSelecting] = useState(false);
const [coverPath, setCoverPath] = useState(workflow?.coverMediaPath);
const [images, setImages] = useState<Media[]>([]);
const { showDialog } = useDialog();
const [curMedia, setCurMedia] = useState<Media | null>();
const [searchValue, setSearchValue] = useState("");
const debounceSearchValue = useDebounce(searchValue, 300);
const [showAllImages, setShowAllImages] = useState(false);
const loadData = async () => {
if (curFlowID == null) return;
const media = await mediaTable?.listByWorkflowID(curFlowID);
setImages(media ?? []);
if (media?.length) {
setCurMedia(media[0]);
}
};
useEffect(() => {
loadData();
curFlowID &&
workflowsTable?.get(curFlowID).then((flow) => {
if (flow) {
setWorkflowName(flow.name);
}
});
}, []);
if (curFlowID == null) {
return null;
}
const onRefreshImagesList = () => {
loadData();
};
const onClickMedia = (media: Media) => {
if (isSelecting) {
if (selectedID.includes(media.id)) {
setSelectedID(selectedID.filter((id) => id !== media.id));
} else {
setSelectedID([...selectedID, media.id]);
}
return;
}
window.open(`/workspace/view_media?filename=${media.localPath}`);
};
const isAllSelected =
images.length > 0 && selectedID.length === images.length;
return (
<Modal isOpen={true} onClose={onclose} blockScrollOnMount={true}>
<ModalOverlay />
<ModalContent width={"90%"} maxWidth={"90vw"} height={"90vh"}>
<ModalHeader>
<HStack gap={2} mb={2}>
<Heading size={"md"} mr={2}>
Gallery - {workflow?.name}
</Heading>
{/* <Button
size={"sm"}
colorScheme="pink"
onClick={() => {
setIsSelecting(true);
setSelectedID(images.map((i) => i.id));
}}
>
Share{" "}
{isSelecting && selectedID.length > 0 ? selectedID.length : ""}
</Button> */}
</HStack>
{isSelecting && (
<HStack gap={3}>
<Checkbox
isChecked={isAllSelected}
onChange={() => {
if (isAllSelected) {
setSelectedID([]);
} else {
setSelectedID(images.map((i) => i.id));
<GalleryContext.Provider
value={{
curMedia: curMedia ?? null,
setCurMedia,
setMediaList: setImages,
showAllImages,
setShowAllImages,
}}
>
<Modal isOpen={true} onClose={onclose} blockScrollOnMount={true}>
<ModalOverlay />
<ModalContent width={"90%"} maxWidth={"90vw"} height={"90vh"}>
<ModalHeader>
<HStack gap={2} mb={2}>
<Heading size={"md"} mr={2}>
Gallery - {workflowName}
</Heading>
<SearchInput
searchValue={searchValue}
onUpdateSearchValue={(val) => {
setSearchValue(val);
if (!showAllImages) {
setShowAllImages(true);
}
}}
>
All
</Checkbox>
<Text fontSize={16}>{selectedID.length} Selected</Text>
<IconButton
size={"sm"}
icon={<IconX size={19} />}
onClick={() => setIsSelecting(false)}
aria-label="cancel"
placeholder="Search prompt, model name, etc."
style={{ width: "300px" }}
/>
</HStack>
)}
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY={"auto"}>
<HStack wrap={"wrap"}>
{images.map((media) => {
if (media.localPath == null) {
return null;
}
const mediaPreview = isImageFormat(media.localPath) ? (
<Link
isExternal
onClick={() => onClickMedia(media)}
position={"relative"}
{isSelecting && (
<HStack gap={3}>
<Checkbox
isChecked={isAllSelected}
onChange={() => {
if (isAllSelected) {
setSelectedID([]);
} else {
setSelectedID(images.map((i) => i.id));
}
}}
>
{isSelecting && (
<Checkbox
isChecked={selectedID.includes(media.id)}
position={"absolute"}
top={2}
left={2}
/>
)}
<Image
borderRadius={3}
boxSize={IMAGE_SIZE + "px"}
objectFit="cover"
src={`/workspace/view_media?filename=${media.localPath}`}
alt={"workflow image renamed or moved from output folder"}
/>
</Link>
) : (
<Link isExternal onClick={() => onClickMedia(media)}>
{isSelecting && (
<Checkbox isChecked={selectedID.includes(media.id)} />
)}
<video
width={IMAGE_SIZE}
height={IMAGE_SIZE}
src={`/workspace/view_media?filename=${media.localPath}`}
loop={true}
autoPlay={true}
muted={true}
>
<track kind="captions" />
</video>
</Link>
);
const isCover =
coverPath != null && coverPath === media.localPath;
return (
<Stack
width={IMAGE_SIZE}
justifyContent={"space-between"}
mb={2}
>
<Tooltip label={formatTimestamp(media.createTime, true)}>
{mediaPreview}
</Tooltip>
<Tooltip label={media.localPath}>
<Text color={"GrayText"} noOfLines={1}>
{media.localPath}
</Text>
</Tooltip>
<HStack justifyContent={"space-between"} hidden={isSelecting}>
<Tooltip label="Set as cover">
<IconButton
size={"sm"}
variant={"ghost"}
icon={
isCover ? (
<IconPinFilled size={19} />
) : (
<IconPin size={19} />
)
}
aria-label="set as cover"
isActive={isCover}
onClick={() => {
updateFlow(curFlowID, {
coverMediaPath: media.localPath,
});
setCoverPath(media.localPath);
}}
/>
</Tooltip>
<Button
flexGrow={1}
onClick={() =>
showDialog("How do you want to load this workflow?", [
{
label: "Load in new workflow",
onClick: () => {
loadFilePath(media.localPath);
},
colorScheme: "teal",
},
{
label: "Overwrite current workflow",
onClick: () => loadFilePath(media.localPath, true),
colorScheme: "red",
},
])
}
size={"sm"}
>
Load
</Button>
<Tooltip label="Donwload image from gallery">
<Link href={`/workspace/view_media?filename=${media.localPath}`} download={media.localPath} >
<IconButton
size={"sm"}
variant={"ghost"}
icon={<IconDownload size={19} />}
aria-label="donwload image from gallery"
/>
</Link>
</Tooltip>
<Tooltip label="Remove image from gallery">
<IconButton
size={"sm"}
variant={"ghost"}
icon={<IconTrash size={19} />}
aria-label="remove image from gallery"
colorScheme="red"
onClick={() => {
const res = confirm(
"Are you sure to remove this image from gallery? (won't delete image on your disk)"
);
if (!res) return;
mediaTable?.delete(media.id);
if (isCover) {
updateFlow(curFlowID, {
coverMediaPath: undefined,
});
setCoverPath(undefined);
}
onRefreshImagesList();
}}
/>
</Tooltip>
</HStack>
</Stack>
);
})}
</HStack>
</ModalBody>
</ModalContent>
</Modal>
All
</Checkbox>
<Text fontSize={16}>{selectedID.length} Selected</Text>
<IconButton
size={"sm"}
icon={<IconX size={19} />}
onClick={() => setIsSelecting(false)}
aria-label="cancel"
/>
</HStack>
)}
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY={"auto"}>
{showAllImages ? (
<GalleryGridView searchQuery={debounceSearchValue} />
) : (
<GalleryCarouselImageViewer mediaList={images} />
)}
</ModalBody>
</ModalContent>
</Modal>
</GalleryContext.Provider>
);
}
@@ -0,0 +1,110 @@
import { Box, Flex, Text, Stack } from "@chakra-ui/react";
import { FormItemComponent } from "../FormItem/FormItemComponent.tsx";
import { isInTopField } from "../MetaBox/MetadataForm.tsx";
import { useCallback, useContext, useEffect, useState } from "react";
import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export default function AllPromptForm() {
const { topFields, calcInputList, showNodeName } = useContext(MetaBoxContext);
const groupInputsByNodeType = useCallback(
(inputList: PromptNodeInputItem[]) => {
const groupedInputs: PromptNodeInputItem[][] = [];
inputList.forEach((input) => {
const lastGroup = groupedInputs[groupedInputs.length - 1];
if (
!lastGroup ||
lastGroup[0].nodeID !== input.nodeID ||
lastGroup[0].classType !== input.classType
) {
groupedInputs.push([input]);
} else {
lastGroup.push(input);
}
});
return groupedInputs;
},
[],
);
useEffect(() => {}, [showNodeName]);
if (!showNodeName) {
return (
<Stack>
{calcInputList.map((input) => {
if (
isInTopField(topFields, {
name: input.inputName,
promptKey: input.nodeID,
classType: input.classType,
})
) {
return null;
}
return (
<FormItemComponent
key={`form${input.nodeID}${input.inputName}`}
inputItem={input}
/>
);
})}
</Stack>
);
}
const nodes = groupInputsByNodeType(calcInputList);
return (
<Stack>
{nodes.map((nodeInputs) => {
if (!nodeInputs[0]) {
return null;
}
return (
<CustomAccordionPanel
title={nodeInputs[0].classType + " #" + nodeInputs[0].nodeID}
key={nodeInputs[0].nodeID}
>
<Flex px={2} gap={1} direction={"column"}>
{nodeInputs.map((input) => {
if (
isInTopField(topFields, {
name: input.inputName,
promptKey: input.nodeID,
classType: input.classType,
})
) {
return null;
}
return (
<FormItemComponent
key={`form${input.nodeID}${input.inputName}`}
inputItem={input}
/>
);
})}
</Flex>
</CustomAccordionPanel>
);
})}
</Stack>
);
}
function CustomAccordionPanel({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Box border="1px" borderColor="gray.500" borderRadius="md" p={4}>
<Text size="sm" mb={1} color={"GrayText"}>
{title}
</Text>
<div>{children}</div>
</Box>
);
}
@@ -0,0 +1,30 @@
import { FC } from "react";
import { Checkbox, Flex } from "@chakra-ui/react";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export const CheckboxBase: FC<
{
inputItem: PromptNodeInputItem;
} & {
[key in string]: any;
}
> = (props) => {
const inputItem = props.inputItem;
return (
<Flex gap={2}>
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
{inputItem.label ?? inputItem.inputName}
</Flex>
<Checkbox
defaultChecked={!!inputItem.inputValue}
onChange={(e) => {
props?.updateMetaData?.({
promptKey: props.promptKey,
name: props.name,
value: e.target.checked,
});
}}
/>
</Flex>
);
};
@@ -0,0 +1,133 @@
import { FC, useContext, useState } from "react";
import { InputBase } from "./InputBase.tsx";
import { InputSlider } from "./InputSlider.tsx";
import { SelectBase } from "./SelectBase.tsx";
import { TextareaBase } from "./TextareaBase.tsx";
import { FormItem, FormItemType } from "./types.ts";
import { getNodesInfo } from "../../utils.ts";
import { NoSupport } from "./NoSupport.tsx";
import { CheckboxBase } from "./CheckboxBase.tsx";
import { Flex, Grid, IconButton } from "@chakra-ui/react";
import { IconPin, IconPinFilled } from "@tabler/icons-react";
import { isInTopField } from "../MetaBox/MetadataForm.tsx";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts";
const INPUT_TYPE_COMPONENT_MAPPING = {
[FormItemType.Input]: InputBase,
[FormItemType.InputSlider]: InputSlider,
[FormItemType.Select]: SelectBase,
[FormItemType.Textarea]: TextareaBase,
[FormItemType.Checkbox]: CheckboxBase,
NoSupport: NoSupport,
} as Record<FormItemType, any>;
// @ts-expect-error
const nodesInfo = LiteGraph.registered_node_types;
function getInputConfigByInfo(
classType: string,
inputName: string,
): Partial<FormItem> {
const nodeData = nodesInfo[classType]?.nodeData;
const inputsInfo = {
...(nodeData?.input?.required ?? {}),
...(nodeData?.input?.optional ?? {}),
}[inputName];
if (inputsInfo?.[0] === "STRING") {
if (inputsInfo?.[1]?.multiline) {
return {
type: FormItemType.Textarea,
};
}
return {
type: FormItemType.Input,
};
}
if (inputsInfo?.[0] === "BOOLEAN") {
return {
type: FormItemType.Checkbox,
};
}
if (inputsInfo?.[0] === "FLOAT") {
return {
...(inputsInfo?.[1] ?? {}),
type: FormItemType.InputSlider,
};
}
if (inputsInfo?.[0] === "INT") {
return {
...(inputsInfo?.[1] ?? {}),
type: FormItemType.InputSlider,
};
}
if (
Array.isArray(inputsInfo?.[0]) &&
inputsInfo?.[0]?.every((v) => typeof v === "string")
) {
return {
options: inputsInfo?.[0],
type: FormItemType.Select,
};
}
return { type: FormItemType.NoSupport };
}
export const FormItemComponent: FC<{ inputItem: PromptNodeInputItem }> = ({
inputItem,
}) => {
const configByNodeInfo = getInputConfigByInfo(
inputItem.classType,
inputItem.inputName,
);
const [isHovered, setIsHovered] = useState(false); // Track hover state
const Com = INPUT_TYPE_COMPONENT_MAPPING[configByNodeInfo.type ?? "Input"];
const { updateTopField, topFields } = useContext(MetaBoxContext);
const childrenOutputLink =
inputItem.children[0] === "negative" || inputItem.children[0] === "positive"
? `-${inputItem.children[0]}`
: "";
const inputWithLabel = {
...inputItem,
label: inputItem.label ?? inputItem.inputName + childrenOutputLink,
};
return (
<Flex
alignItems={"center"}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{true && (
<IconButton
size={"xs"}
onClick={() =>
updateTopField?.({
name: inputItem.inputName,
promptKey: inputItem.nodeID,
class_type: inputItem.classType,
})
}
variant={"ghost"}
icon={
isInTopField(topFields, {
name: inputItem.inputName,
promptKey: inputItem.nodeID,
classType: inputItem.classType,
}) ? (
<IconPinFilled size={14} />
) : (
<IconPin size={14} />
)
}
aria-label={"pin"}
/>
)}
<div style={{ flex: 1 }}>
<Com inputItem={inputWithLabel} {...configByNodeInfo} />
</div>
</Flex>
);
};
@@ -0,0 +1,20 @@
import { FC } from "react";
import { Flex, Input } from "@chakra-ui/react";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export const InputBase: FC<
{
inputItem: PromptNodeInputItem;
} & {
[key in string]: any;
}
> = ({ inputItem }) => {
return (
<Flex gap={2}>
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
{inputItem.label ?? inputItem.inputName}
</Flex>
<Input value={inputItem.inputValue} onChange={(e) => {}} />
</Flex>
);
};
@@ -0,0 +1,62 @@
import { FC } from "react";
import {
Flex,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Slider,
SliderFilledTrack,
SliderThumb,
SliderTrack,
} from "@chakra-ui/react";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export const InputSlider: FC<
{
inputItem: PromptNodeInputItem;
} & {
[key in string]: any;
}
> = (props) => {
const inputItem = props.inputItem;
return (
<Flex gap={1} direction={"column"}>
<Flex gap={2}>
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
{inputItem.label ?? inputItem.inputName}
</Flex>
<NumberInput
width={"100%"}
step={props?.step}
value={inputItem.inputValue}
min={props?.min}
max={props?.max}
onChange={(val) => {}}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</Flex>
<Flex>
<Slider
maxWidth={"400px"}
step={props?.step}
value={Number(inputItem.inputValue)}
min={props?.min ?? 0}
max={props?.max ?? 100}
onChange={(val) => {}}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</Flex>
</Flex>
);
};
@@ -0,0 +1,14 @@
import { FC } from "react";
import { Flex } from "@chakra-ui/react";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export const NoSupport: FC<{ inputItem: PromptNodeInputItem }> = (props) => {
return (
<Flex gap={2}>
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
{props.inputItem.label ?? props.inputItem.inputName}
</Flex>
<Flex>No Support</Flex>
</Flex>
);
};
@@ -0,0 +1,31 @@
import { FC } from "react";
import { Flex, Select } from "@chakra-ui/react";
import { FormItem } from "./types.ts";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export const SelectBase: FC<
{
inputItem: PromptNodeInputItem;
} & {
[key in string]: any;
}
> = (props) => {
const { inputItem } = props;
return (
<Flex gap={2}>
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
{inputItem.label ?? inputItem.inputName}
</Flex>
<Select value={inputItem.inputValue} onChange={(e) => {}}>
{props?.options?.map((v: string, i: number) => (
<option
key={`select${inputItem.nodeID}${inputItem.inputName}${i}`}
value={v}
>
{v}
</option>
))}
</Select>
</Flex>
);
};
@@ -0,0 +1,19 @@
import { FC } from "react";
import { Flex, Textarea } from "@chakra-ui/react";
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
export const TextareaBase: FC<
{
inputItem: PromptNodeInputItem;
} & {
[key in string]: any;
}
> = (props) => {
const inputItem = props.inputItem;
return (
<Flex gap={1} direction={"column"}>
<Flex>{inputItem.label ?? inputItem.inputName}</Flex>
<Textarea value={inputItem.inputValue} onChange={(e) => {}} rows={5} />
</Flex>
);
};
@@ -0,0 +1,20 @@
export enum FormItemType {
Input = "Input",
InputSlider = "InputSlider",
Select = "Select",
Textarea = "Textarea",
Checkbox = "Checkbox",
NoSupport = "NoSupport",
}
export type FormItem = {
type?: FormItemType;
name: string;
label?: string;
value: string | number;
onChange?: (val: any) => void;
classType: string;
promptKey: string | number;
} & {
[key in string]: any;
};
@@ -0,0 +1,65 @@
import { Media } from "../../types/dbTypes.ts";
import { useContext } from "react";
import { Box, Flex, Grid } from "@chakra-ui/react";
import Carousel from "../../components/Carousel/Carousel.tsx";
import { GalleryRightCol } from "./GalleryRightCol.tsx";
import MediaPreview from "../../components/MediaPreview.tsx";
import { mediaTable } from "../../db-tables/WorkspaceDB.ts";
import { GalleryContext } from "../GalleryContext.ts";
interface MetaDataInfoProps {
mediaList: Media[];
}
const GALLERY_IMAGE_SIZE = 120;
export function GalleryCarouselImageViewer({ mediaList }: MetaDataInfoProps) {
const { curMedia, setCurMedia } = useContext(GalleryContext);
return (
<Flex gap={3} h={"100%"}>
<Grid
gridTemplateRows={mediaList.length <= 6 ? "1fr 20%" : "1fr"}
flex={1}
gap={2}
>
<div style={{ height: "56vh" }}>
<Carousel
media={mediaList.map((v) => ({
id: v.id,
imageUrl: `/workspace/view_media?filename=${v.localPath}`,
}))}
currentNum={mediaList?.findIndex((p) => p.id === curMedia?.id) ?? 0}
setMediaAct={(newMedia) =>
setCurMedia(mediaList?.find((v) => v.id === newMedia.id) ?? null)
}
/>
</div>
<Flex wrap={"wrap"}>
{mediaList?.map((media) => (
<Box
display={"inline-block"}
p={1}
borderRadius={"4px"}
key={`image-bottom-${media.id}`}
width={`${GALLERY_IMAGE_SIZE + 3}px`}
height={`${GALLERY_IMAGE_SIZE + 3}px`}
cursor={"pointer"}
border={curMedia?.id === media.id ? "1px solid gray" : ""}
onClick={() => setCurMedia(media)}
>
<MediaPreview
mediaLocalPath={media.localPath}
size={GALLERY_IMAGE_SIZE}
objectFit="contain"
hideBrokenImage
onBrokenLink={() => {
mediaTable?.delete(media.id);
}}
/>
</Box>
))}
</Flex>
</Grid>
<GalleryRightCol media={curMedia ?? undefined} />
</Flex>
);
}
@@ -0,0 +1,50 @@
import { useContext, useEffect, useState } from "react";
import GalleryMediaItem from "./GalleryMediaItem";
import { HStack } from "@chakra-ui/react";
import type { Media } from "../../types/dbTypes";
import { mediaTable } from "../../db-tables/WorkspaceDB";
import { GalleryContext } from "../GalleryContext";
export default function GalleryGridView({
searchQuery,
}: {
searchQuery: string;
}) {
const [medias, setMedias] = useState<Media[]>([]);
const { setCurMedia, setShowAllImages, setMediaList } =
useContext(GalleryContext);
useEffect(() => {
if (searchQuery === "") {
mediaTable?.listAll().then((data) => {
setMedias(data.slice(0, 50) ?? []);
});
} else {
mediaTable
?.filter((v) => v.workflowJSON?.includes(searchQuery) ?? false)
.then((data) => {
setMedias(data);
});
}
}, [searchQuery]);
const onClickMedia = (media: Media) => {
setMediaList([media].concat(medias.filter((v) => v.id !== media.id)));
setCurMedia(media);
setShowAllImages(false);
};
return (
<HStack wrap={"wrap"}>
{medias.map((media) => {
return (
<GalleryMediaItem
key={media.id}
selectedID={[]}
media={media}
isSelecting={false}
onClickMedia={onClickMedia}
/>
);
})}
</HStack>
);
}
@@ -0,0 +1,132 @@
import React, { useContext } from "react";
import {
Link,
Checkbox,
Stack,
HStack,
Tooltip,
IconButton,
Text,
Button,
} from "@chakra-ui/react";
import { formatTimestamp } from "../../utils";
import { Media } from "../../types/dbTypes";
import { IconDownload, IconTrash } from "@tabler/icons-react";
import { mediaTable } from "../../db-tables/WorkspaceDB";
import { WorkspaceContext } from "../../WorkspaceContext";
import { useDialog } from "../../components/AlertDialogProvider";
import MediaPreview from "../../components/MediaPreview";
interface GalleryMediaItemProps {
media: Media;
isSelecting: boolean;
selectedID: string[];
onClickMedia: (media: Media) => void;
}
const GalleryMediaItem: React.FC<GalleryMediaItemProps> = ({
media,
isSelecting,
selectedID,
onClickMedia,
}) => {
const IMAGE_SIZE = 180; // Define your image size
const { curFlowID, loadFilePath } = useContext(WorkspaceContext);
const { showDialog } = useDialog();
if (media.localPath == null) {
return null;
}
if (curFlowID == null) {
return null;
}
const mediaPreview = (
<Link isExternal onClick={() => onClickMedia(media)} position="relative">
{isSelecting && (
<Checkbox
isChecked={selectedID.includes(media.id)}
position="absolute"
top={2}
left={2}
/>
)}
<MediaPreview
mediaLocalPath={media.localPath}
size={IMAGE_SIZE}
onBrokenLink={() => {
mediaTable?.delete(media.id);
}}
hideBrokenImage
autoPlay={true}
isPreview
/>
</Link>
);
return (
<Stack width={IMAGE_SIZE} mb={2}>
<Tooltip label={formatTimestamp(media.createTime, true)}>
{mediaPreview}
</Tooltip>
<Tooltip label={media.localPath}>
<Text color={"GrayText"} noOfLines={1}>
{media.localPath}
</Text>
</Tooltip>
<HStack hidden={isSelecting} gap={0}>
<Button
flexGrow={1}
onClick={() =>
showDialog("How do you want to load this workflow?", [
{
label: "Load in new workflow",
onClick: () => {
loadFilePath(media.localPath);
},
colorScheme: "teal",
},
{
label: "Overwrite current workflow",
onClick: () => loadFilePath(media.localPath, true),
colorScheme: "red",
},
])
}
size={"sm"}
>
Load
</Button>
<Tooltip label="Donwload image from gallery">
<Link
href={`/workspace/view_media?filename=${media.localPath}`}
download={media.localPath}
>
<IconButton
size={"sm"}
variant={"ghost"}
icon={<IconDownload size={19} />}
aria-label="donwload image from gallery"
/>
</Link>
</Tooltip>
<Tooltip label="Remove image from gallery">
<IconButton
size={"sm"}
variant={"ghost"}
icon={<IconTrash size={19} />}
aria-label="remove image from gallery"
colorScheme="red"
onClick={async () => {
const res = confirm(
"Are you sure to remove this image from gallery? (won't delete image on your disk)",
);
if (!res) return;
await mediaTable?.delete(media.id);
}}
/>
</Tooltip>
</HStack>
</Stack>
);
};
export default GalleryMediaItem;
@@ -0,0 +1,17 @@
import { MetaData } from "../utils.ts";
import { Media } from "../../types/dbTypes.ts";
import { Flex } from "@chakra-ui/react";
import MetadataForm from "./MetaBox/MetadataForm.tsx";
import { GalleryRightColHeaderButtons } from "./GalleryRightColHeaderButtons.tsx";
export type MediaWithMetaData = Media & {
metaData?: MetaData;
};
export const GalleryRightCol = ({ media }: { media?: MediaWithMetaData }) => {
return (
<Flex overflowY={"auto"} mb={4} direction={"column"} gap={2} flex={1}>
<GalleryRightColHeaderButtons media={media} />
<MetadataForm media={media ?? null} />
</Flex>
);
};

Some files were not shown because too many files have changed in this diff Show More