├── .github
├── FUNDING.yml
└── workflows
│ └── publish_action.yml
├── .gitignore
├── LICENSE
├── README.md
├── README_for_developer.md
├── __init__.py
├── dist
└── workspace_web
│ ├── App-qo42s2ji.js
│ ├── AppIsDirtyEventListener-MhB0DEkr.js
│ ├── GalleryModal-7uZByh-M.js
│ ├── IconCopy-iXaxUe59.js
│ ├── IconSearch-ZN34j33e.js
│ ├── MediaPreview-Saiymhm2.js
│ ├── ModelManagerTopbar-jQR5azdv.js
│ ├── RecentFilesDrawer-_YgQRVlY.js
│ ├── ShareDialog-nc3gNuNm.js
│ ├── SpotlightSearch-5RTzhpQn.js
│ ├── assets
│ ├── App-JXePnJiV.css
│ └── ModelManagerTopbar--iv3sdjQ.css
│ ├── chunk-3RSXBRAN-0rzvZpzF.js
│ ├── chunk-7D6N5TE5-rpRnbuGR.js
│ ├── chunk-JARCRF6W-U6cp0GaY.js
│ ├── chunk-NTCQBYKE-IkW3A38m.js
│ ├── chunk-VTV6N5LE-VOXf0Qhu.js
│ ├── civitUtils-zqcTmmqF.js
│ ├── input.js
│ └── useDebounceFn-ld478fd0.js
├── entry
└── entry.js
├── pyproject.toml
├── requirements.txt
├── scripts
└── setupGitHooks.js
├── service
├── db_service.py
├── file_sync_service.py
├── media_service.py
├── model_manager
│ ├── missing_models.py
│ ├── model_installer.py
│ ├── model_list.py
│ ├── model_preview.py
│ └── nodes_installer.py
├── node_service.py
├── scan_my_workflows_folder.py
├── setting_service.py
└── twoway_sync_folder_service.py
└── ui
├── .eslintrc.cjs
├── .prettierrc
├── index.html
├── package-lock.json
├── package.json
├── src
├── Api.ts
├── App.tsx
├── MyCSSReset.tsx
├── RecentFilesDrawer
│ ├── AddTagToWorkflowPopover.tsx
│ ├── FilesListFolderItem.tsx
│ ├── FilesListFolderItemRightClickMenu.tsx
│ ├── ImportFlowsFileInput.tsx
│ ├── InsertWorkflowToCanvas.ts
│ ├── ItemsList.tsx
│ ├── ManageTagsModal.tsx
│ ├── MoreActionMenu.tsx
│ ├── MultipleSelectionOperation.tsx
│ ├── MyTagsRow.tsx
│ ├── RecentFilesDrawer.tsx
│ ├── RecentFilesDrawerMenu.tsx
│ ├── WorkflowListItem.tsx
│ ├── WorkflowListItemActionButtons.tsx
│ ├── WorkspaceSettingsModal.tsx
│ └── types.ts
├── WorkspaceContext.ts
├── apis
│ ├── TwowaySyncApi.ts
│ └── TwowaySyncFolderApi.ts
├── components
│ ├── AlertDialogProvider.tsx
│ ├── Carousel
│ │ └── Carousel.tsx
│ ├── CopyShareLinkMenuItem.tsx
│ ├── CreateVersionDialog.tsx
│ ├── CustomMenu.tsx
│ ├── CustomSelector.tsx
│ ├── DeleteConfirm.tsx
│ ├── Draggable.tsx
│ ├── DropdownTitle.tsx
│ ├── EditFlowName.tsx
│ ├── EditFolderName.tsx
│ ├── HoverMenu.tsx
│ ├── MediaPreview.tsx
│ ├── Overlay.tsx
│ ├── SearchInput.tsx
│ ├── SpotlightSearch.tsx
│ ├── VersionHistoryDrawer.tsx
│ └── copySharelink.tsx
├── const.ts
├── customHooks
│ ├── useDebounce.ts
│ ├── useDebounceFn.ts
│ └── useStateRef.ts
├── db-tables
│ ├── ChangelogsTable.ts
│ ├── DiskFileUtils.ts
│ ├── FoldersTable.ts
│ ├── IndexDBUtils.ts
│ ├── MediaTable.ts
│ ├── ModelsTable.ts
│ ├── TableBase.ts
│ ├── UserSettingsTable.ts
│ ├── WorkflowVersionsTable.ts
│ ├── WorkflowsTable.ts
│ ├── WorkspaceDB.ts
│ ├── indexdb.ts
│ └── tagsTable.ts
├── defaultGraph.ts
├── gallery
│ ├── GalleryContext.ts
│ ├── GalleryModal.tsx
│ ├── components
│ │ ├── AllPromptForm
│ │ │ └── AllPromptForm.tsx
│ │ ├── FormItem
│ │ │ ├── CheckboxBase.tsx
│ │ │ ├── FormItemComponent.tsx
│ │ │ ├── InputBase.tsx
│ │ │ ├── InputSlider.tsx
│ │ │ ├── NoSupport.tsx
│ │ │ ├── SelectBase.tsx
│ │ │ ├── TextareaBase.tsx
│ │ │ └── types.ts
│ │ ├── GalleryCarouselImageViewer.tsx
│ │ ├── GalleryGridView.tsx
│ │ ├── GalleryMediaItem.tsx
│ │ ├── GalleryRightCol.tsx
│ │ ├── GalleryRightColHeaderButtons.tsx
│ │ ├── MetaBox
│ │ │ ├── MetadataForm.tsx
│ │ │ ├── metaBoxContext.ts
│ │ │ └── utils.ts
│ │ └── TopForm
│ │ │ └── TopForm.tsx
│ └── utils.ts
├── index.css
├── main.tsx
├── model-manager
│ ├── ManagerContext.ts
│ ├── api
│ │ └── modelsApi.ts
│ ├── civitSearchTypes.ts
│ ├── civitiModelFull.json
│ ├── comfy-types
│ │ └── comfy.d.ts
│ ├── components
│ │ └── Overlay.tsx
│ ├── hooks
│ │ ├── ServerEventListener.ts
│ │ ├── useDebaunce.ts
│ │ └── useUpdateModels.ts
│ ├── install-models
│ │ ├── AddApiKeyPopover.tsx
│ │ ├── ChooseFolder.tsx
│ │ ├── InstallModelSearchBar.tsx
│ │ ├── InstallModelsButton.tsx
│ │ ├── InstallModelsModal.tsx
│ │ ├── InstallProgress.tsx
│ │ ├── ModelCard.tsx
│ │ └── util
│ │ │ ├── getModelFromCivitAPI.ts
│ │ │ ├── getModelFromSearch.ts
│ │ │ └── modelTypes.ts
│ ├── missing-models-drawer
│ │ ├── MissingModelItem.tsx
│ │ └── MissingModelsListDrawer.tsx
│ ├── modelGetCivitiResp.json
│ ├── models-list-drawer
│ │ ├── ModelItem.tsx
│ │ ├── ModelsList.tsx
│ │ ├── ModelsListDrawer.tsx
│ │ └── ModelsTags.tsx
│ ├── topbar
│ │ ├── InstallMissingModelsButton.tsx
│ │ ├── ModelDropEventListener.tsx
│ │ ├── ModelManagerTopbar.tsx
│ │ └── index.css
│ ├── types.ts
│ └── utils.tsx
├── settings
│ ├── AutosaveSetting.tsx
│ ├── CloudHostSetting.tsx
│ ├── CommonCheckboxSettings.tsx
│ ├── CommonNumberSetting.tsx
│ ├── EnableTwowaySyncConfirm.tsx
│ ├── FolderOnTopSettings.tsx
│ ├── SelectMyWorkflowsDir.tsx
│ ├── SharekeySetting.tsx
│ ├── ShortcutSettings.tsx
│ ├── ShowNsfwModelThumbnailSettings.tsx
│ └── TwoWaySyncSettings.tsx
├── share
│ ├── ShareDialog.tsx
│ ├── ShareDialogWorkflowVersionRadio.tsx
│ ├── SharedTopbarButton.tsx
│ └── shareUtils.tsx
├── spacejson
│ ├── ResourceDepsForm.tsx
│ └── handleDownloadSpaceJson.ts
├── topbar
│ ├── AppIsDirtyEventListener.tsx
│ ├── TabBroadcastChannel.ts
│ ├── Topbar.css
│ ├── Topbar.tsx
│ ├── TopbarNewWorkflowButton.tsx
│ └── VersionNameTopbar.tsx
├── types
│ ├── comfy.d.ts
│ ├── dbTypes.ts
│ ├── litegraph.d.ts
│ ├── types.ts
│ └── workspace.d.ts
├── utils.tsx
├── utils
│ ├── OsPathUtils.ts
│ ├── civitUtils.ts
│ ├── comfyapp.ts
│ ├── deepJsonDiffCheck.ts
│ ├── downloadJsonFile.ts
│ ├── downloadWorkflowsZip.ts
│ ├── encryptUtils.ts
│ ├── findSfwImage.ts
│ ├── jsonUtils.ts
│ ├── mediaMetadataUtils.ts
│ ├── privacyUtils.ts
│ ├── saveShareKey.ts
│ ├── showAlert.css
│ ├── showAlert.ts
│ └── twowaySyncUtils.ts
├── versionHistory
│ └── CreateVersionLogin.tsx
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [11cafe]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.github/workflows/publish_action.yml:
--------------------------------------------------------------------------------
1 | name: Publish to Comfy registry
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - "pyproject.toml"
9 |
10 | jobs:
11 | publish-node:
12 | name: Publish Custom Node to registry
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Check out code
16 | uses: actions/checkout@v4
17 | - name: Publish Custom Node
18 | uses: Comfy-Org/publish-node-action@main
19 | with:
20 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | hash
3 | .env
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 | pnpm-debug.log*
11 | lerna-debug.log*
12 |
13 | ui/node_modules
14 | # ui/src/utils/encryptUtils.ts
15 | db
16 | dist-ssr
17 | *.local
18 | backup
19 | # Editor directories and files
20 | .vscode/*
21 | !.vscode/extensions.json
22 | .idea
23 | .DS_Store
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 | .cache
30 | # dev
31 | ui/yarn.lock
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 11cafe
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README_for_developer.md:
--------------------------------------------------------------------------------
1 | # ☕️ ComfyUI Workspace Manager - Comfyspace
2 |
3 | This is a document specially prepared for developers, explaining some special development details.Installation
4 |
5 | ## Clean up indexdb if it's in bad state
6 |
7 | 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
8 |
9 |
10 |
11 | 1. F12 -> Application -> IndexedDB -> delete current indexdb
12 |
13 |
14 |
15 | 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)
16 |
17 |
18 |
19 | ## Install custom git hooks
20 |
21 | When running the project for the first time, it is recommended that you execute the following command to install our customized git hooks
22 |
23 | ```javascript
24 | cd ui
25 | npm run setupGithooks
26 | ```
27 |
28 | Current hooks include:
29 |
30 | 1. When switching to a non-main/beta branch, additional .gitignore logic is automatically added to ignore the "/dist" folder.
31 |
32 | ## How to use Hot Module Replacement
33 |
34 | 1. npm run dev starts the project;
35 | 2. If the dist directory currently exists, please delete the dist directory or keep the dist directory empty;
36 | 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.
37 |
38 | ```javascript
39 | import RefreshRuntime from "http://localhost:5173/@react-refresh";
40 | RefreshRuntime.injectIntoGlobalHook(window);
41 | window.$RefreshReg$ = () => {};
42 | window.$RefreshSig$ = () => (type) => type;
43 | window.__vite_plugin_react_preamble_installed__ = true;
44 |
45 | const head = document.getElementsByTagName("head")[0];
46 | const viteClientScript = document.createElement("script");
47 | viteClientScript.src = "http://localhost:5173/@vite/client";
48 | viteClientScript.type = "module";
49 | head.appendChild(viteClientScript);
50 | const workspaceMainScript = document.createElement("script");
51 | workspaceMainScript.src = "http://localhost:5173/src/main.tsx";
52 | workspaceMainScript.type = "module";
53 | head.appendChild(workspaceMainScript);
54 | ```
55 |
--------------------------------------------------------------------------------
/dist/workspace_web/AppIsDirtyEventListener-MhB0DEkr.js:
--------------------------------------------------------------------------------
1 | import{r as l,N as r}from"./input.js";import{W as S,ag as w,w as p,E as f,ah as _,ai as C}from"./App-qo42s2ji.js";import{u as b}from"./useDebounceFn-ld478fd0.js";function R(){const{isDirty:d,setIsDirty:h,setRoute:g,saveCurWorkflow:v,setCurFlowIDAndName:k}=l.useContext(S),E=l.useRef(d);l.useEffect(()=>{E.current=d},[d]),l.useEffect(()=>{const c=e=>{var n;if(document.visibilityState==="hidden")return;const t=C(e);if(t)switch(t===f.openSpotlightSearch&&e.preventDefault(),window.dispatchEvent(new CustomEvent(_,{detail:{shortcutType:t}})),t){case f.SAVE:v();break;case f.SAVE_AS:g("saveAsModal");break;case f.openSpotlightSearch:g("spotlightSearch");break}else(n=e.target)!=null&&n.matches("input, textarea")&&Object.keys(r.canvas.selected_nodes??{}).length&&m()},u=async()=>{var t,n,a,i;if(!((t=r)!=null&&t.graph)){console.error("🦄 Error in workspace manager! app.graph is not available in restoreCurWorkflow()");return}const e=(a=(n=r.graph.extra)==null?void 0:n[w])==null?void 0:a.id;if(e){const s=await((i=p)==null?void 0:i.get(e));s&&k(s),!(s!=null&&s.saveLock)&&s&&s.json!==JSON.stringify(r.graph.serialize())&&h(!0)}},o=r.graph.onConfigure;return r.graph.onConfigure=function(){o==null||o.apply(this,arguments),setTimeout(()=>{var t,n,a,i;const e=(n=(t=r.graph.extra)==null?void 0:t[w])==null?void 0:n.id;(e==null||e!=((i=(a=p)==null?void 0:a.curWorkflow)==null?void 0:i.id))&&k(null)},500)},document.addEventListener("click",e=>{Object.keys(r.canvas.selected_nodes??{}).length&&(r.canvas.node_over!=null||r.canvas.node_capturing_input!=null||r.canvas.node_widget!=null)&&m()}),document.addEventListener("keydown",c),u(),()=>{document.removeEventListener("keydown",c)}},[]);const y=async()=>{var u,o,e,t;if((o=(u=p)==null?void 0:u.curWorkflow)!=null&&o.saveLock||E.current)return;const c=r.graph.serialize();JSON.stringify(c)!==((t=(e=p)==null?void 0:e.curWorkflow)==null?void 0:t.json)&&h(!0)},[m,D]=b(y,900);return null}export{R as default};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/IconCopy-iXaxUe59.js:
--------------------------------------------------------------------------------
1 | import{d as a}from"./App-qo42s2ji.js";var p=a("copy","IconCopy",[["path",{d:"M7 7m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z",key:"svg-0"}],["path",{d:"M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1",key:"svg-1"}]]);export{p as I};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/IconSearch-ZN34j33e.js:
--------------------------------------------------------------------------------
1 | import{d as a}from"./App-qo42s2ji.js";var r=a("search","IconSearch",[["path",{d:"M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0",key:"svg-0"}],["path",{d:"M21 21l-6 -6",key:"svg-1"}]]);export{r as I};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/assets/App-JXePnJiV.css:
--------------------------------------------------------------------------------
1 | .dragPanelIcon{display:none!important}.workspaceManagerPanel:hover .dragPanelIcon{display:block!important}
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/assets/ModelManagerTopbar--iv3sdjQ.css:
--------------------------------------------------------------------------------
1 | .drag-model-manager-top-bar-icon{visibility:hidden!important}.model-manager-top-bar:hover .drag-model-manager-top-bar-icon{visibility:visible!important}
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/chunk-3RSXBRAN-0rzvZpzF.js:
--------------------------------------------------------------------------------
1 | import{f as x,j as l,g as i,e as w,k as C,o as E,l as I,r as f,a5 as P}from"./input.js";import{ab as z}from"./App-qo42s2ji.js";var v=x(function(o,s){const{children:t,placeholder:n,className:r,...a}=o;return l.jsxs(i.select,{...a,ref:s,className:w("chakra-select",r),children:[n&&l.jsx("option",{value:"",children:n}),t]})});v.displayName="SelectField";function F(e,o){const s={},t={};for(const[n,r]of Object.entries(e))o.includes(n)?s[n]=r:t[n]=r;return[s,t]}var H=x((e,o)=>{var s;const t=C("Select",e),{rootProps:n,placeholder:r,icon:a,color:c,height:_,h:d,minH:h,minHeight:y,iconColor:p,iconSize:u,...j}=E(e),[g,N]=F(j,P),m=z(N),k={width:"100%",height:"fit-content",position:"relative",color:c},b={paddingEnd:"2rem",...t.field,_focus:{zIndex:"unset",...(s=t.field)==null?void 0:s._focus}};return l.jsxs(i.div,{className:"chakra-select__wrapper",__css:k,...g,...n,children:[l.jsx(v,{ref:o,height:d??_,minH:h??y,placeholder:r,...m,__css:b,children:e.children}),l.jsx(S,{"data-disabled":I(m.disabled),...(p||c)&&{color:p||c},__css:t.icon,...u&&{fontSize:u},children:a})]})});H.displayName="Select";var M=e=>l.jsx("svg",{viewBox:"0 0 24 24",...e,children:l.jsx("path",{fill:"currentColor",d:"M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"})}),R=i("div",{baseStyle:{position:"absolute",display:"inline-flex",alignItems:"center",justifyContent:"center",pointerEvents:"none",top:"50%",transform:"translateY(-50%)"}}),S=e=>{const{children:o=l.jsx(M,{}),...s}=e,t=f.cloneElement(o,{role:"presentation",className:"chakra-select__icon",focusable:!1,"aria-hidden":!0,style:{width:"1em",height:"1em",color:"currentColor"}});return l.jsx(R,{...s,className:"chakra-select__icon-wrapper",children:f.isValidElement(o)?t:null})};S.displayName="SelectIcon";export{H as S};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/chunk-JARCRF6W-U6cp0GaY.js:
--------------------------------------------------------------------------------
1 | import{b as D,r as h,j as t,g as i,a2 as v,f as j,k as T,o as B,H as E,e as $}from"./input.js";import{u as z}from"./chunk-7D6N5TE5-rpRnbuGR.js";var[Z,F]=D({name:"CheckboxGroupContext",strict:!1});function M(o){const[s,n]=h.useState(o),[e,r]=h.useState(!1);return o!==s&&(r(!0),n(o)),e}function W(o){return t.jsx(i.svg,{width:"1.2em",viewBox:"0 0 12 10",style:{fill:"none",strokeWidth:2,stroke:"currentColor",strokeDasharray:16},...o,children:t.jsx("polyline",{points:"1.5 6 4.5 9 10.5 1"})})}function X(o){return t.jsx(i.svg,{width:"1.2em",viewBox:"0 0 24 24",style:{stroke:"currentColor",strokeWidth:4},...o,children:t.jsx("line",{x1:"21",x2:"3",y1:"12",y2:"12"})})}function H(o){const{isIndeterminate:s,isChecked:n,...e}=o,r=s?X:W;return n||s?t.jsx(i.div,{style:{display:"flex",alignItems:"center",justifyContent:"center",height:"100%"},children:t.jsx(r,{...e})}):null}var L={display:"inline-flex",alignItems:"center",justifyContent:"center",verticalAlign:"top",userSelect:"none",flexShrink:0},O={cursor:"pointer",display:"inline-flex",alignItems:"center",verticalAlign:"top",position:"relative"},q=v({from:{opacity:0,strokeDashoffset:16,transform:"scale(0.95)"},to:{opacity:1,strokeDashoffset:0,transform:"scale(1)"}}),J=v({from:{opacity:0},to:{opacity:1}}),K=v({from:{transform:"scaleX(0.65)"},to:{transform:"scaleX(1)"}}),Q=j(function(s,n){const e=F(),r={...e,...s},a=T("Checkbox",r),c=B(s),{spacing:x="0.5rem",className:C,children:m,iconColor:u,iconSize:d,icon:k=t.jsx(H,{}),isChecked:f,isDisabled:g=e==null?void 0:e.isDisabled,onChange:p,inputProps:w,..._}=c;let y=f;e!=null&&e.value&&c.value&&(y=e.value.includes(c.value));let b=p;e!=null&&e.onChange&&c.value&&(b=E(e.onChange,p));const{state:l,getInputProps:A,getCheckboxProps:S,getLabelProps:G,getRootProps:P}=z({..._,isDisabled:g,isChecked:y,onChange:b}),I=M(l.isChecked),R=h.useMemo(()=>({animation:I?l.isIndeterminate?`${J} 20ms linear, ${K} 200ms linear`:`${q} 200ms linear`:void 0,fontSize:d,color:u,...a.icon}),[u,d,I,l.isIndeterminate,a.icon]),N=h.cloneElement(k,{__css:R,isIndeterminate:l.isIndeterminate,isChecked:l.isChecked});return t.jsxs(i.label,{__css:{...O,...a.container},className:$("chakra-checkbox",C),...P(),children:[t.jsx("input",{className:"chakra-checkbox__input",...A(w,n)}),t.jsx(i.span,{__css:{...L,...a.control},className:"chakra-checkbox__control",...S(),children:N}),m&&t.jsx(i.span,{className:"chakra-checkbox__label",...G(),__css:{marginStart:x,...a.label},children:m})]})});Q.displayName="Checkbox";var U=j(function(s,n){const{templateAreas:e,gap:r,rowGap:a,columnGap:c,column:x,row:C,autoFlow:m,autoRows:u,templateRows:d,autoColumns:k,templateColumns:f,...g}=s,p={display:"grid",gridTemplateAreas:e,gridGap:r,gridRowGap:a,gridColumnGap:c,gridAutoColumns:k,gridColumn:x,gridRow:C,gridAutoFlow:m,gridAutoRows:u,gridTemplateRows:d,gridTemplateColumns:f};return t.jsx(i.div,{ref:n,__css:p,...g})});U.displayName="Grid";export{Q as C,U as G};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/chunk-NTCQBYKE-IkW3A38m.js:
--------------------------------------------------------------------------------
1 | import{f as t}from"./App-qo42s2ji.js";import{f as o,j as s}from"./input.js";var e=o((a,r)=>s.jsx(t,{align:"center",...a,direction:"column",ref:r}));e.displayName="VStack";export{e as V};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/chunk-VTV6N5LE-VOXf0Qhu.js:
--------------------------------------------------------------------------------
1 | import{f as l,n as f,o as h,j as e,g as n,e as m,k as y,r as o}from"./input.js";import{u as w}from"./chunk-7D6N5TE5-rpRnbuGR.js";var N=l(function(a,i){const s=f("Link",a),{className:c,isExternal:t,...r}=h(a);return e.jsx(n.a,{target:t?"_blank":void 0,rel:t?"noopener":void 0,ref:i,className:m("chakra-link",c),...r,__css:s})});N.displayName="Link";var j=l(function(a,i){const s=y("Switch",a),{spacing:c="0.5rem",children:t,...r}=h(a),{getIndicatorProps:u,getInputProps:p,getCheckboxProps:x,getRootProps:_,getLabelProps:g}=w(r),b=o.useMemo(()=>({display:"inline-block",position:"relative",verticalAlign:"middle",lineHeight:0,...s.container}),[s.container]),S=o.useMemo(()=>({display:"inline-flex",flexShrink:0,justifyContent:"flex-start",boxSizing:"content-box",cursor:"pointer",...s.track}),[s.track]),d=o.useMemo(()=>({userSelect:"none",marginStart:c,...s.label}),[c,s.label]);return e.jsxs(n.label,{..._(),className:m("chakra-switch",a.className),__css:b,children:[e.jsx("input",{className:"chakra-switch__input",...p({},i)}),e.jsx(n.span,{...x(),className:"chakra-switch__track",__css:S,children:e.jsx(n.span,{__css:s.thumb,className:"chakra-switch__thumb",...u()})}),t&&e.jsx(n.span,{className:"chakra-switch__label",...g(),__css:d,children:t})]})});j.displayName="Switch";export{N as L,j as S};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/civitUtils-zqcTmmqF.js:
--------------------------------------------------------------------------------
1 | import{h as m}from"./App-qo42s2ji.js";const r="WORKSPACE_CIVIT_API_KEY_STORAGE_KEY";function h(){return localStorage.getItem(r)}function g(e){localStorage.setItem(r,e)}function v(e){return`https://civitai.com/api/download/models/${e}`}async function w(e){var s,a,n;try{const l=`https://civitai.com/api/v1/model-versions/by-hash/${e}`,t=await(await fetch(l)).json();let i;if(await((s=m)==null?void 0:s.getSetting("showNsfwModelThumbnail"))===!0)i=(n=(a=t==null?void 0:t.images)==null?void 0:a[0])==null?void 0:n.url;else if(!t.model.nsfw){const o=t.images.find(c=>c.nsfwLevel==1);i=o==null?void 0:o.url}return{modelName:t.model.name,civitModelID:String(t.modelId),civitModelVersionID:String(t.id),imageUrl:i??void 0}}catch{return{}}}export{v as a,w as f,h as g,g as s};
2 |
--------------------------------------------------------------------------------
/dist/workspace_web/useDebounceFn-ld478fd0.js:
--------------------------------------------------------------------------------
1 | import{r}from"./input.js";function a(u,c){const e=r.useRef(0),t=r.useCallback(()=>{e.current!==null&&(clearTimeout(e.current),e.current=0)},[]),n=r.useCallback((...o)=>{t(),e.current=setTimeout(()=>u(...o),c)},[u,c,t]);return r.useEffect(()=>()=>{t()},[]),[n,t]}export{a as u};
2 |
--------------------------------------------------------------------------------
/entry/entry.js:
--------------------------------------------------------------------------------
1 | //@ts-ignore
2 | import { api } from "../../scripts/api.js";
3 |
4 | setTimeout(() => {
5 | import(api.api_base + "/workspace_web/input.js");
6 | }, 500);
7 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "comfyui-workspace-manager"
3 | description = "A ComfyUI custom node for project management to centralize the management of all your workflows in one place. Seamlessly switch between workflows, create and update them within a single workspace, like Google Docs."
4 | version = "1.0.0"
5 | license = { file = "LICENSE" }
6 |
7 | [project.urls]
8 | Repository = "https://github.com/11cafe/comfyui-workspace-manager"
9 |
10 | [tool.comfy]
11 | PublisherId = "weixuan11"
12 | DisplayName = "comfyui-workspace-manager"
13 | Icon = ""
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | send2trash
2 |
--------------------------------------------------------------------------------
/scripts/setupGitHooks.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { exec } = require('child_process');
3 | const path = require('path');
4 |
5 | const files = [
6 | { path: '../.git/info/exclude.dist', content: '/dist' },
7 | {
8 | path: '../.git/hooks/post-checkout',
9 | content: `
10 | #!/bin/sh
11 |
12 | # Get the current branch name
13 | BRANCH_NAME=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD)
14 |
15 | # Remove previously set exclude files
16 | rm -f .git/info/exclude
17 |
18 | # When not in the beta/main branch, add additional files that need to be ignored
19 | if [ "$BRANCH_NAME" != "beta" ] && [ "$BRANCH_NAME" != "main" ]; then
20 | cp .git/info/exclude.dist .git/info/exclude
21 | # elif [ "$BRANCH_NAME" = "xxx" ]; then
22 | # cp .git/info/exclude.xxx .git/info/exclude
23 | fi`,
24 | },
25 | ];
26 |
27 | function createFile(filePath, content) {
28 | fs.writeFile(filePath, content, (err) => {
29 | if (err) {
30 | console.error(`An error occurred while creating file ${filePath}:`, err);
31 | }
32 | });
33 | }
34 |
35 | function executeCommand(command, workingDirectory = '../') {
36 | exec(command, { cwd: path.resolve(__dirname, workingDirectory) }, (error, stdout, stderr) => {
37 | if (error) {
38 | console.error(`An error occurred while executing command "${command}":`, error);
39 | return;
40 | }
41 | if (stderr) {
42 | console.error(`Command error "${stderr}"`);
43 | return;
44 | }
45 | });
46 | }
47 |
48 | function main() {
49 | files.forEach((file) => {
50 | createFile(file.path, file.content);
51 | });
52 | executeCommand('git config advice.ignoredHook false');
53 | executeCommand('chmod +x .git/hooks/post-checkout');
54 | console.log('Custom git hooks installation completed')
55 | }
56 |
57 | main();
58 |
--------------------------------------------------------------------------------
/service/db_service.py:
--------------------------------------------------------------------------------
1 |
2 | import server
3 | import os
4 | import folder_paths
5 | import json
6 | import asyncio
7 | from aiohttp import web
8 | from .model_manager.model_preview import get_thumbnail_for_image_file
9 |
10 | workspace_path = os.path.join(os.path.dirname(os.path.dirname(__file__)))
11 | comfy_path = os.path.dirname(folder_paths.__file__)
12 | db_dir_path = os.path.join(workspace_path, "db")
13 | DEFAULT_USER = "guest"
14 |
15 | @server.PromptServer.instance.routes.post("/workspace/save_db")
16 | async def save_db(request):
17 | # Extract parameters from the request
18 | data = await request.json()
19 | table = data['table']
20 | json_data = data['json']
21 |
22 | file_name = f'{db_dir_path}/{table}.json'
23 | # Offload file writing to a separate thread
24 | def write_json_string_to_db(file_name, json_data):
25 | if not os.path.exists(db_dir_path):
26 | os.makedirs(db_dir_path)
27 | # Write the JSON data to the specified file
28 | with open(file_name, 'w') as file:
29 | file.write(json.dumps(json_data, indent=4))
30 | await asyncio.to_thread(write_json_string_to_db, file_name, json_data)
31 | return web.Response(text=f"JSON saved to {file_name}")
32 |
33 | def read_table(table):
34 | if not table:
35 | return None
36 | file_name = f'{db_dir_path}/{table}.json'
37 | if not os.path.exists(file_name):
38 | return None
39 |
40 | try:
41 | with open(file_name, 'r', encoding='utf-8') as file:
42 | data = json.load(file)
43 | return data
44 | except json.JSONDecodeError as e:
45 | return None
46 |
47 |
48 | @server.PromptServer.instance.routes.get("/workspace/get_db")
49 | async def get_workspace(request):
50 | # Extract the table parameter from the query string
51 | table = request.query.get('table')
52 | data = await asyncio.to_thread(read_table, table)
53 | return web.json_response(data)
54 |
--------------------------------------------------------------------------------
/service/model_manager/model_preview.py:
--------------------------------------------------------------------------------
1 | import os
2 | from PIL import Image
3 | import base64
4 | from io import BytesIO
5 | from pathlib import Path
6 |
7 | MAX_IMAGE_SIZE = 250
8 |
9 | def preview_file(filename: str):
10 | preview_exts = [".jpg", ".png", ".jpeg", ".gif"]
11 | preview_exts = [*preview_exts, *[".preview" + x for x in preview_exts]]
12 | for ext in preview_exts:
13 | try:
14 | pathStr = os.path.splitext(filename)[0] + ext
15 | path = Path(pathStr).resolve()
16 | if os.path.exists(path):
17 | # because ComfyUI has extra model path feature
18 | # the path might not be relative to the ComfyUI root
19 | # so instead of returning the path, we return the image data directly, to avoid security issues
20 | bytes = get_thumbnail_for_image_file(path)
21 | # Get the base64 string
22 | img_base64 = base64.b64encode(bytes).decode()
23 | # Return the base64 string
24 | return f"data:image/jpeg;base64, {img_base64}"
25 | except Exception as e:
26 | print(f"Error opening image preview: {e}")
27 | return None
28 |
29 |
30 | def get_thumbnail_for_image_file(file_path: Path):
31 | try:
32 | with Image.open(file_path) as img:
33 | # If the image is too large, resize it
34 | if img.width > MAX_IMAGE_SIZE and img.height > MAX_IMAGE_SIZE:
35 | # Calculate new width to maintain aspect ratio
36 | width = int(img.width * MAX_IMAGE_SIZE / img.height)
37 | # Resize the image
38 | img = img.resize((width, MAX_IMAGE_SIZE))
39 | img = img.convert("RGB")
40 | # Save the image to a BytesIO object
41 | buffer = BytesIO()
42 | img.save(buffer, format="JPEG", quality=85)
43 | return buffer.getvalue()
44 | except Exception as e:
45 | print(f"Error opening image preview: {e}")
46 | return None
47 |
--------------------------------------------------------------------------------
/service/model_manager/nodes_installer.py:
--------------------------------------------------------------------------------
1 | from .model_installer import download_url_with_wget
2 | import server
3 | from aiohttp import web
4 | import aiohttp
5 | import requests
6 | import folder_paths
7 | import os
8 | import sys
9 | import threading
10 | import subprocess # don't remove this
11 | from urllib.parse import urlparse
12 | import subprocess
13 | import os
14 | import json
15 | import urllib.request
16 |
17 | workspace_path = os.path.join(os.path.dirname(__file__))
18 | comfy_path = os.path.dirname(folder_paths.__file__)
19 |
20 | @server.PromptServer.instance.routes.post("/workspace/find_nodes")
21 | async def install_nodes(request):
22 | post_params = await request.json()
23 | # [{'authorName': 'Fannovel16', 'gitHtmlUrl': 'https://github.com/Fannovel16/comfyui_controlnet_aux', 'totalInstalls': 1, 'description': None, 'id': 'TilePreprocessor'}]
24 | resp = fetch_server(post_params['nodes'])
25 | return web.json_response(resp, content_type='application/json')
26 |
27 |
28 | async def install_node(gitUrl):
29 | print(f"Installing custom node from git '{gitUrl}'")
30 | try:
31 | if gitUrl.endswith("/"):
32 | gitUrl = gitUrl[:-1]
33 | repo_name = os.path.splitext(os.path.basename(gitUrl))[0]
34 | repo_path = os.path.join(comfy_path, 'custom_nodes', repo_name)
35 | print('repo_path', repo_path)
36 | try:
37 | Repo.clone_from(gitUrl+'.git', repo_path)
38 | except Exception as e:
39 | print(f"Error cloning repo: {e}")
40 | return f"Error cloning repo: {e}\n"
41 | return f"Installed custom node from git '{gitUrl}'\n"
42 | except Exception as e:
43 | return f"Error installing custom node from git '{gitUrl}': {e}\n"
44 |
45 |
46 | @server.PromptServer.instance.routes.post("/workspace/install_nodes")
47 | async def install_nodes(request):
48 | response = web.StreamResponse()
49 | response.headers['Content-Type'] = 'text/plain'
50 | await response.prepare(request)
51 |
52 | post_params = await request.json()
53 | nodes = post_params['nodes']
54 |
55 | tasks = []
56 | print(f"Installing custom nodes", nodes)
57 | custom_node_path = os.path.join(comfy_path, 'custom_nodes')
58 | for custom_node in nodes:
59 | gitUrl = custom_node['gitHtmlUrl']
60 | print(f"Cloning repository: {gitUrl}")
61 | run_script(["git", "clone", gitUrl+'.git'], custom_node_path)
62 |
63 |
64 | def handle_stream(stream, prefix):
65 | for line in stream:
66 | print(prefix + line, end='')
67 |
68 |
69 | def run_script(cmd, cwd='.'):
70 | if len(cmd) > 0 and cmd[0].startswith("#"):
71 | print(f"[model-manager] Unexpected behavior: `{cmd}`")
72 | return 0
73 |
74 | process = subprocess.Popen(
75 | cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
76 |
77 | stdout_thread = threading.Thread(
78 | target=handle_stream, args=(process.stdout, ""))
79 | stderr_thread = threading.Thread(
80 | target=handle_stream, args=(process.stderr, "[!]"))
81 |
82 | stdout_thread.start()
83 | stderr_thread.start()
84 |
85 | stdout_thread.join()
86 | stderr_thread.join()
87 |
88 | return process.wait()
89 |
90 |
--------------------------------------------------------------------------------
/service/setting_service.py:
--------------------------------------------------------------------------------
1 |
2 | import server
3 | import os
4 | import folder_paths
5 | import json
6 | import asyncio
7 | from aiohttp import web
8 | from .db_service import DEFAULT_USER, read_table, db_dir_path
9 |
10 | comfy_path = os.path.dirname(folder_paths.__file__)
11 |
12 | @server.PromptServer.instance.routes.get("/workspace/get_settings")
13 | def get_settings_endpoint(request):
14 | settings = get_settings()
15 | return web.json_response(text=json.dumps(settings), content_type='application/json')
16 |
17 | def get_settings():
18 | new_settings_path = os.path.join(db_dir_path, 'settings.json')
19 | if os.path.exists(new_settings_path):
20 | # to deprecate and remove legacy settings file
21 | # if os.path.exists(f'{db_dir_path}/useSettings.json'):
22 | # os.remove(f'{db_dir_path}/useSettings.json')
23 | with open(new_settings_path, 'r') as file:
24 | return json.load(file)
25 | data = read_table('userSettings')
26 | if (data):
27 | records = json.loads(data)
28 | if DEFAULT_USER in records and 'myWorkflowsDir' in records[DEFAULT_USER]:
29 | return records[DEFAULT_USER]
30 | return None
31 |
32 | @server.PromptServer.instance.routes.post("/workspace/save_settings")
33 | async def save_settings(request):
34 | data = await request.json()
35 | json_data = data
36 | file_name = f'{db_dir_path}/settings.json'
37 | # Offload file writing to a separate thread
38 | def write_json_string_to_db(file_name, json_data):
39 | if not os.path.exists(db_dir_path):
40 | os.makedirs(db_dir_path)
41 | # Write the JSON data to the specified file
42 | with open(file_name, 'w') as file:
43 | file.write(json.dumps(json_data, indent=4))
44 | await asyncio.to_thread(write_json_string_to_db, file_name, json_data)
45 | return web.Response(text=f"JSON saved to {file_name}")
46 |
47 | def get_my_workflows_dir():
48 | data = get_settings()
49 | if (data):
50 | if 'myWorkflowsDir' in data:
51 | curDir = data['myWorkflowsDir']
52 |
53 | # this is to be compatible of a bug that a dict is stored in userSettings.myWorkflowsDir
54 | # should not be needed once all users refresh their settings
55 | if not isinstance(curDir, str):
56 | curDir = curDir.get('path', None)
57 |
58 | if curDir and os.path.exists(curDir):
59 | return curDir
60 | return os.path.join(comfy_path, 'my_workflows')
61 |
--------------------------------------------------------------------------------
/service/twoway_sync_folder_service.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import shutil
3 | from aiohttp import web
4 | import os
5 | from pathlib import Path
6 | import server
7 | from .setting_service import get_my_workflows_dir
8 | try:
9 | from send2trash import send2trash
10 | except ImportError:
11 | send2trash = None
12 |
13 | @server.PromptServer.instance.routes.post('/workspace/folder/create')
14 | async def create_folder(request):
15 | reqJson = await request.json()
16 | data = await asyncio.to_thread(create_folder_sync, reqJson)
17 | return web.json_response(data, content_type='application/json')
18 |
19 | def create_folder_sync(reqJson):
20 | folder_path = reqJson.get('path')
21 | folder_path = os.path.join(get_my_workflows_dir(), folder_path)
22 | try:
23 | Path(folder_path).mkdir(parents=True, exist_ok=True)
24 | return {"success": True}
25 | except Exception as e:
26 | return {"success": False, "error": str(e)}
27 |
28 | @server.PromptServer.instance.routes.post('/workspace/folder/delete')
29 | async def delete_folder(request):
30 | reqJson = await request.json()
31 | data = await asyncio.to_thread(delete_folder_sync, reqJson)
32 | return web.json_response(data, content_type='application/json')
33 |
34 | def delete_folder_sync(reqJson):
35 | folder_path = reqJson.get('path')
36 | folder_path = os.path.join(get_my_workflows_dir(), folder_path)
37 | try:
38 | if send2trash:
39 | send2trash(folder_path)
40 | else:
41 | shutil.rmtree(folder_path)
42 | print("❌⛔️send2trash is not available. Deleting file permanently. Please `pip install send2trash`")
43 | return {"success": True}
44 | except Exception as e:
45 | return {"success": False, "error": str(e)}
46 |
47 | @server.PromptServer.instance.routes.post('/workspace/folder/rename')
48 | async def rename_folder(request):
49 | reqJson = await request.json()
50 | data = await asyncio.to_thread(rename_folder_sync, reqJson)
51 | return web.json_response(data, content_type='application/json')
52 |
53 | def rename_folder_sync(reqJson):
54 | current_path = reqJson.get('absPath')
55 | current_path = os.path.join(get_my_workflows_dir(), current_path)
56 | new_name = reqJson.get('newName')
57 | try:
58 | new_path = Path(current_path).parent / new_name
59 | Path(current_path).rename(new_path)
60 | return {"success": True}
61 | except Exception as e:
62 | return {"success": False, "error": str(e)}
63 |
64 | @server.PromptServer.instance.routes.post('/workspace/folder/move')
65 | async def move_folder(request):
66 | reqJson = await request.json()
67 | data = await asyncio.to_thread(move_folder_sync, reqJson)
68 | return web.json_response(data, content_type='application/json')
69 |
70 | def move_folder_sync(reqJson):
71 | current_path = reqJson.get('folder')
72 | current_path = os.path.join(get_my_workflows_dir(), current_path)
73 | new_path = os.path.join(get_my_workflows_dir(), reqJson.get('newParentPath',""))
74 | try:
75 | shutil.move(current_path, new_path)
76 | return {"success": True}
77 | except Exception as e:
78 | return {"success": False, "error": str(e)}
79 |
80 |
--------------------------------------------------------------------------------
/ui/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | 'prettier',
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parser: '@typescript-eslint/parser',
12 | plugins: ['react-refresh', '@typescript-eslint', 'prettier'],
13 | rules: {
14 | 'react-refresh/only-export-components': [
15 | 'warn',
16 | { allowConstantExport: true },
17 | ],
18 | 'prettier/prettier': ['error'],
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/ui/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "htmlWhitespaceSensitivity": "css",
4 | "insertPragma": false,
5 | "jsxBracketSameLine": false,
6 | "printWidth": 80,
7 | "proseWrap": "always",
8 | "quoteProps": "as-needed",
9 | "requirePragma": false,
10 | "semi": true,
11 | "tabWidth": 2,
12 | "trailingComma": "all",
13 | "useTabs": false,
14 | "endOfLine": "auto"
15 | }
16 |
--------------------------------------------------------------------------------
/ui/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "comfyui-workspace-manager",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview",
11 | "setupGithooks": "node ../scripts/setupGitHooks"
12 | },
13 | "dependencies": {
14 | "@chakra-ui/react": "^2.8.2",
15 | "@emotion/react": "^11.11.1",
16 | "@emotion/styled": "^11.11.0",
17 | "@tabler/icons-react": "^2.42.0",
18 | "chakra-react-select": "^4.7.6",
19 | "dexie": "^3.2.4",
20 | "dexie-react-hooks": "^1.1.7",
21 | "fuse.js": "^7.0.0",
22 | "jszip": "^3.10.1",
23 | "nanoid": "^5.0.6",
24 | "react": "^18.2.0",
25 | "react-dom": "^18.2.0"
26 | },
27 | "devDependencies": {
28 | "@types/react": "^18.2.37",
29 | "@types/react-dom": "^18.2.15",
30 | "@types/uuid": "^9.0.7",
31 | "@typescript-eslint/eslint-plugin": "^6.10.0",
32 | "@typescript-eslint/parser": "^6.10.0",
33 | "@vitejs/plugin-react": "^4.2.0",
34 | "eslint": "^8.53.0",
35 | "eslint-config-prettier": "^9.1.0",
36 | "eslint-plugin-prettier": "^5.1.3",
37 | "eslint-plugin-react-hooks": "^4.6.0",
38 | "eslint-plugin-react-refresh": "^0.4.4",
39 | "prettier": "^3.2.4",
40 | "rollup-plugin-watch": "^1.0.4",
41 | "typescript": "^5.2.2",
42 | "vite": "^5.0.0"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/ItemsList.tsx:
--------------------------------------------------------------------------------
1 | import { DragEvent, useContext, useEffect, useState } from "react";
2 | import { RecentFilesContext } from "../WorkspaceContext";
3 | import {
4 | foldersTable,
5 | isFolder,
6 | workflowsTable,
7 | } from "../db-tables/WorkspaceDB";
8 | import { Folder, Workflow } from "../types/dbTypes";
9 | import FilesListFolderItem from "./FilesListFolderItem";
10 | import WorkflowListItem from "./WorkflowListItem";
11 | import { Box } from "@chakra-ui/react";
12 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
13 |
14 | export default function ItemsList({
15 | items,
16 | }: {
17 | items: Array;
18 | }) {
19 | const [folderOnTop, setFolderOnTop] = useState(false);
20 | const folders = items.filter(isFolder);
21 | const workflows = items.filter((item): item is Workflow => !isFolder(item));
22 | const parentFolderID = workflows[0]?.parentFolderID;
23 |
24 | const [isDraggingOver, setIsDraggingOver] = useState(false);
25 | const { onRefreshFilesList, draggingFile, refreshFolderStamp } =
26 | useContext(RecentFilesContext);
27 |
28 | const handleDrop = async (e: DragEvent) => {
29 | e.preventDefault();
30 | e.stopPropagation();
31 | if (!draggingFile) return setIsDraggingOver(false);
32 | if (isFolder(draggingFile)) {
33 | if (draggingFile.id === parentFolderID) return setIsDraggingOver(false);
34 | await foldersTable?.update(draggingFile.id, {
35 | parentFolderID: parentFolderID,
36 | });
37 | } else {
38 | await workflowsTable?.updateFolder(draggingFile.id, {
39 | parentFolderID: parentFolderID,
40 | });
41 | }
42 | await onRefreshFilesList?.();
43 | setIsDraggingOver(false);
44 | };
45 |
46 | useEffect(() => {
47 | userSettingsTable?.getSetting("foldersOnTop").then((res) => {
48 | setFolderOnTop(res ?? false);
49 | });
50 | }, [refreshFolderStamp]);
51 |
52 | return (
53 | <>
54 | {folderOnTop &&
55 | folders.map((folder) => (
56 |
57 | ))}
58 | {
63 | e.preventDefault();
64 | e.stopPropagation();
65 | setIsDraggingOver(true);
66 | }}
67 | onDragLeave={(e) => {
68 | e.preventDefault();
69 | e.stopPropagation();
70 | setIsDraggingOver(false);
71 | }}
72 | onDrop={handleDrop}
73 | className="droppable"
74 | >
75 | {folderOnTop
76 | ? workflows.map((workflow) => (
77 |
78 | ))
79 | : items.map((n) => {
80 | if (isFolder(n)) {
81 | return ;
82 | }
83 | return ;
84 | })}
85 |
86 | >
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/ManageTagsModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HStack,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalBody,
8 | ModalCloseButton,
9 | Tag as ChakraTag,
10 | IconButton,
11 | } from "@chakra-ui/react";
12 | import { useEffect, useState } from "react";
13 | import { tagsTable } from "../db-tables/WorkspaceDB";
14 | import { IconTrash } from "@tabler/icons-react";
15 | import { Tag } from "../types/dbTypes";
16 |
17 | export default function ManageTagsModal({ onclose }: { onclose: () => void }) {
18 | const [allTags, setAllTags] = useState([]);
19 | const loadTags = async () => {
20 | const tags = await tagsTable?.listAll();
21 | setAllTags(tags ?? []);
22 | };
23 | useEffect(() => {
24 | loadTags();
25 | }, []);
26 | return (
27 |
28 |
29 |
30 | My Tags
31 |
32 |
33 | {allTags.map((tag) => (
34 |
35 | {tag.name}
36 | {
38 | await tagsTable?.delete(tag.name);
39 | loadTags();
40 | }}
41 | aria-label="delete-tag"
42 | colorScheme="red"
43 | variant={"ghost"}
44 | icon={ }
45 | />
46 |
47 | ))}
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/MultipleSelectionOperation.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, HStack, IconButton, Text, Tooltip } from "@chakra-ui/react";
2 | import { IconListCheck, IconX, IconDownload } from "@tabler/icons-react";
3 | import DeleteConfirm from "../components/DeleteConfirm";
4 | import { ChangeEvent } from "react";
5 | import { workflowsTable } from "../db-tables/WorkspaceDB";
6 | import { downloadWorkflowsZip } from "../utils/downloadWorkflowsZip";
7 |
8 | type Props = {
9 | multipleState: boolean;
10 | selectedKeys: string[];
11 | isSelectedAll: boolean;
12 | changeMultipleState: (isMultiple: boolean) => void;
13 | batchOperationCallback: (type: string, value?: unknown) => void;
14 | };
15 |
16 | export default function MultipleSelectionOperation(props: Props) {
17 | const {
18 | multipleState,
19 | changeMultipleState,
20 | selectedKeys,
21 | isSelectedAll,
22 | batchOperationCallback,
23 | } = props;
24 |
25 | const batchExport = async () => {
26 | const selectedList = (await workflowsTable?.batchQuery(selectedKeys)) ?? [];
27 | downloadWorkflowsZip(selectedList);
28 | };
29 |
30 | const notChecked = selectedKeys.length === 0;
31 |
32 | return (
33 |
34 | {multipleState ? (
35 | <>
36 | ) => {
39 | batchOperationCallback("selectAll", e.target.checked);
40 | }}
41 | />
42 |
43 | }
48 | onClick={batchExport}
49 | />
50 |
51 |
52 | {
58 | await workflowsTable?.batchDeleteFlow(selectedKeys);
59 | batchOperationCallback("batchDelete");
60 | }}
61 | />
62 |
63 |
64 | {selectedKeys.length > 0 && (
65 | {`Selected ${selectedKeys.length}`}
66 | )}
67 |
68 | }
72 | onClick={() => {
73 | changeMultipleState(false);
74 | }}
75 | />
76 |
77 | >
78 | ) : (
79 |
80 | }
85 | onClick={() => {
86 | changeMultipleState(true);
87 | }}
88 | />
89 |
90 | )}
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/MyTagsRow.tsx:
--------------------------------------------------------------------------------
1 | import { Button, HStack, IconButton } from "@chakra-ui/react";
2 | import { useEffect, useState } from "react";
3 | import { Tag } from "../types/dbTypes";
4 | import { tagsTable } from "../db-tables/WorkspaceDB";
5 | import { IconChevronDown, IconChevronUp, IconX } from "@tabler/icons-react";
6 | const MAX_TAGS_TO_SHOW = 6;
7 |
8 | interface Props {
9 | selectedTag?: string;
10 | setSelectedTag: (tag?: string) => void;
11 | }
12 | export default function MyTagsRow({ setSelectedTag, selectedTag }: Props) {
13 | const [showAllTags, setShowAllTags] = useState(false);
14 |
15 | const [tags, setTags] = useState([]);
16 | useEffect(() => {
17 | tagsTable?.listAll().then((tags) => {
18 | setTags(tags);
19 | });
20 | }, []);
21 | return (
22 |
23 | {selectedTag != null && (
24 | }
28 | onClick={() => {
29 | setSelectedTag(undefined);
30 | }}
31 | />
32 | )}
33 | {tags.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW).map((tag) => (
34 | {
43 | setSelectedTag(tag.name);
44 | }}
45 | isActive={selectedTag === tag.name}
46 | >
47 | {tag.name}
48 |
49 | ))}
50 | {(tags.length ?? 0) > MAX_TAGS_TO_SHOW && (
51 | : }
55 | onClick={() => setShowAllTags(!showAllTags)}
56 | />
57 | )}
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/RecentFilesDrawerMenu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconButton,
3 | Menu,
4 | MenuButton,
5 | MenuList,
6 | MenuItem,
7 | useColorMode,
8 | } from "@chakra-ui/react";
9 | import {
10 | IconMenu2,
11 | IconMoon,
12 | IconSettings,
13 | IconSun,
14 | } from "@tabler/icons-react";
15 | import { useState } from "react";
16 | import WorkspaceSettingsModal from "./WorkspaceSettingsModal";
17 | const ICON_SIZE = 16;
18 | type Props = {
19 | // onclose: () => void;
20 | };
21 | export default function RecentFilesDrawerMenu({}: Props) {
22 | const [isSettingsOpen, setIsSettingsOpen] = useState(false);
23 | const { colorMode, toggleColorMode } = useColorMode();
24 |
25 | return (
26 | <>
27 |
28 | }
32 | size={"sm"}
33 | variant="outline"
34 | />
35 |
36 | setIsSettingsOpen(true)}
38 | icon={ }
39 | fontSize={16}
40 | >
41 | Settings
42 |
43 |
48 | ) : (
49 |
50 | )
51 | }
52 | fontSize={16}
53 | >
54 | {colorMode === "light" ? "Dark" : "Light"} Mode
55 |
56 |
57 |
58 | {isSettingsOpen && (
59 | setIsSettingsOpen(false)} />
60 | )}
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/WorkflowListItemActionButtons.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, IconButton, Tooltip } from "@chakra-ui/react";
2 | import DeleteConfirm from "../components/DeleteConfirm";
3 | import MoreActionMenu from "./MoreActionMenu";
4 | import { useContext } from "react";
5 | import { RecentFilesContext } from "../WorkspaceContext";
6 | import { Workflow } from "../types/dbTypes";
7 |
8 | export default function WorkflowListItemActionButtons({
9 | workflow,
10 | }: {
11 | workflow: Workflow;
12 | }) {
13 | const { onDeleteFlow } = useContext(RecentFilesContext);
14 | return (
15 |
16 | {
19 | onDeleteFlow && onDeleteFlow(workflow.id);
20 | }}
21 | />
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/WorkspaceSettingsModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HStack,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalBody,
8 | ModalCloseButton,
9 | StackDivider,
10 | VStack,
11 | } from "@chakra-ui/react";
12 | import { ShortcutSettings } from "../settings/ShortcutSettings";
13 | import TwoWaySyncSettings from "../settings/TwoWaySyncSettings";
14 | import CommonCheckboxSettings from "../settings/CommonCheckboxSettings";
15 | import SelectMyWorkflowsDir from "../settings/SelectMyWorkflowsDir";
16 | import { CommonNumberSetting } from "../settings/CommonNumberSetting";
17 | import CloudHostSetting from "../settings/CloudHostSetting";
18 | import SharekeySetting from "../settings/SharekeySetting";
19 |
20 | export default function WorkspaceSettingsModal({
21 | onClose,
22 | }: {
23 | onClose: () => void;
24 | }) {
25 | return (
26 | <>
27 |
28 |
29 |
30 | Settings
31 |
32 |
33 |
34 | }
36 | spacing={4}
37 | align="stretch"
38 | w="100%"
39 | >
40 |
41 |
42 |
43 |
44 |
48 |
54 |
58 |
59 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | >
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/ui/src/RecentFilesDrawer/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The key value of the sort type in the local cache
3 | */
4 | export const sortTypeLocalStorageKey = "CWM_WORKFLOWS_SORT_TYPE";
5 |
6 | export enum ESortTypes {
7 | /**
8 | * Sort by last opened time, most recently modified first
9 | */
10 | RECENTLY_OPENED = "recentlyOpened",
11 | /**
12 | * Sort by update time, most recently modified first
13 | */
14 | RECENTLY_MODIFIED = "newest",
15 | /**
16 | * Sort by update time, latest modified last
17 | */
18 | OLDEST_MODIFIED = "oldest",
19 | /**
20 | * Sort alphabetically, from A to Z
21 | */
22 | AZ = "name A-Z",
23 | /**
24 | * Sort alphabetically, from Z to A
25 | */
26 | ZA = "name Z-A",
27 | }
28 |
29 | export type ImportWorkflow = {
30 | json: string;
31 | name?: string;
32 | };
33 |
34 | export const importMenuItemList = [
35 | {
36 | title: "Import Workflows",
37 | type: "file",
38 | },
39 | {
40 | title: "Import Folder",
41 | type: "folder",
42 | },
43 | ];
44 |
45 | // Solve TS errors caused by webkitdirectory
46 | declare module "react" {
47 | interface InputHTMLAttributes extends HTMLAttributes {
48 | webkitdirectory?: string;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/ui/src/WorkspaceContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import { Folder, Workflow, WorkflowVersion } from "./types/dbTypes";
3 | import { Session, WorkspaceRoute } from "./types/types";
4 | export type JsonDiff = {
5 | old: Object;
6 | new: Object;
7 | } | null;
8 |
9 | export const WorkspaceContext = createContext<{
10 | curFlowID: string | null;
11 | onDuplicateWorkflow?: (flowID: string, newFlowName?: string) => void;
12 | loadWorkflowID: (
13 | id: string | null,
14 | versionID?: string | null,
15 | forceLoad?: boolean,
16 | ) => void;
17 | setIsDirty: (dirty: boolean) => void;
18 | saveCurWorkflow: (force?: boolean) => Promise;
19 | discardUnsavedChanges: () => Promise;
20 | isDirty: boolean;
21 | loadNewWorkflow: (input?: { json: string; name?: string }) => void;
22 | loadFilePath: (path: string, overwriteCurrent?: boolean) => void;
23 | setRoute: (route: WorkspaceRoute) => void;
24 | route: WorkspaceRoute;
25 | curVersion: WorkflowVersion | null;
26 | setCurVersion: (version: WorkflowVersion | null) => void;
27 | setCurFlowIDAndName: (workflow: Workflow | null) => void;
28 | session: Session | null;
29 | updateSession: (session: Session) => void;
30 | }>({
31 | curFlowID: null,
32 | loadWorkflowID: () => {},
33 | saveCurWorkflow: async () => {},
34 | discardUnsavedChanges: async () => {},
35 | isDirty: false,
36 | loadNewWorkflow: () => {},
37 | loadFilePath: () => {},
38 | setRoute: () => {},
39 | route: "root",
40 | curVersion: null,
41 | setIsDirty: () => {},
42 | setCurVersion: () => {},
43 | setCurFlowIDAndName: () => {},
44 | session: null,
45 | updateSession: () => {},
46 | });
47 |
48 | export const RecentFilesContext = createContext<{
49 | onRefreshFilesList?: () => void;
50 | draggingFile?: Workflow | Folder;
51 | setDraggingFile?: (file: Workflow | Folder) => void;
52 | isMultiSelecting?: boolean;
53 | multiSelectedFlowsID?: string[];
54 | onMultiSelectFlow?: (flowId: string, selected: boolean) => void;
55 | onDeleteFlow?: (flowId: string) => void;
56 | refreshFolderStamp: number;
57 | setRefreshFolderStamp: (stamp: number) => void;
58 | }>({
59 | refreshFolderStamp: 0,
60 | setRefreshFolderStamp: () => {},
61 | });
62 |
--------------------------------------------------------------------------------
/ui/src/apis/TwowaySyncFolderApi.ts:
--------------------------------------------------------------------------------
1 | import { fetchApi } from "../Api";
2 | import { Folder } from "../types/dbTypes";
3 | import { sanitizeRelPath } from "../utils/OsPathUtils";
4 |
5 | export namespace TwowayFolderSyncAPI {
6 | export async function genFolderRelPath({
7 | parentFolderID,
8 | name,
9 | }: {
10 | parentFolderID: string | null;
11 | name: string;
12 | }): Promise {
13 | return sanitizeRelPath(`${parentFolderID ?? ""}/${name}`);
14 | }
15 |
16 | export async function createFolder(folder: Folder) {
17 | await fetchApi("/workspace/folder/create", {
18 | method: "POST",
19 | headers: {
20 | "Content-Type": "application/json",
21 | },
22 | body: JSON.stringify({
23 | path: sanitizeRelPath(folder.id),
24 | }),
25 | });
26 | }
27 | export async function moveFolder(
28 | folderToBeMoved: string,
29 | newParentPath: string,
30 | ) {
31 | const resp = await fetchApi("/workspace/folder/move", {
32 | method: "POST",
33 | headers: {
34 | "Content-Type": "application/json",
35 | },
36 | body: JSON.stringify({
37 | folder: folderToBeMoved,
38 | newParentPath: newParentPath,
39 | }),
40 | });
41 | const result = await resp.json();
42 | if (result.error) {
43 | alert("Failed to move folder: " + result.error);
44 | }
45 | return;
46 | }
47 | export async function deleteFolder(relPath: string) {
48 | try {
49 | const response = await fetchApi("/workspace/folder/delete", {
50 | method: "POST",
51 | headers: {
52 | "Content-Type": "application/json",
53 | },
54 | body: JSON.stringify({
55 | path: relPath,
56 | }),
57 | });
58 | const result = await response.text();
59 | return result;
60 | } catch (error) {
61 | console.error("Error deleting file:", error);
62 | }
63 | }
64 |
65 | export async function genFilesCountInFolder(
66 | folderRelPath: string,
67 | ): Promise {
68 | try {
69 | const response = await fetchApi("/workspace/file/count_files", {
70 | method: "POST",
71 | headers: {
72 | "Content-Type": "application/json",
73 | },
74 | body: JSON.stringify({
75 | path: folderRelPath,
76 | }),
77 | });
78 | const result = await response.json();
79 | if (result.error) {
80 | console.error("Error counting files:", result.error);
81 | }
82 | return result.count;
83 | } catch (error) {
84 | console.error("Error renaming file:", error);
85 | }
86 | return null;
87 | }
88 |
89 | export async function renameFolder(
90 | folderRelPath: string,
91 | newName: string,
92 | ): Promise {
93 | try {
94 | const response = await fetchApi("/workspace/folder/rename", {
95 | method: "POST",
96 | headers: {
97 | "Content-Type": "application/json",
98 | },
99 | body: JSON.stringify({
100 | absPath: folderRelPath,
101 | newName: newName,
102 | }),
103 | });
104 | const result = await response.json();
105 | if (result.error) {
106 | alert("Failed to rename folder: " + result.error);
107 | }
108 | } catch (error) {
109 | console.error("Error renaming file:", error);
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/ui/src/components/Carousel/Carousel.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Flex, IconButton, Image } from "@chakra-ui/react";
3 | import { isImageFormat } from "../../utils.tsx";
4 | import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
5 |
6 | type Media = {
7 | id: string;
8 | imageUrl: string;
9 | };
10 |
11 | interface CarouselProps {
12 | media: Media[];
13 | currentNum: number;
14 | setMediaAct: (media: Media) => void;
15 | }
16 |
17 | const Carousel: React.FC = ({
18 | setMediaAct,
19 | media,
20 | currentNum,
21 | }) => {
22 | const paginate = (newDirection: number) => {
23 | const cur = (currentNum + newDirection + media.length) % media.length;
24 | if (setMediaAct) {
25 | setMediaAct(media[cur]);
26 | }
27 | };
28 |
29 | const imageSize = {
30 | width: "100%", // Adjust the width as needed
31 | height: "100%", // Adjust the height as needed
32 | };
33 |
34 | if (media.length === 0)
35 | return No images found, let's start generating 🪄
;
36 | const item = media.at(currentNum);
37 | if (!item) return null;
38 | return (
39 |
47 | {
56 | window.open(item.imageUrl);
57 | }}
58 | >
59 | {isImageFormat(item.imageUrl) ? (
60 |
67 | ) : (
68 |
77 |
78 |
79 | )}
80 |
81 |
82 | }
88 | onClick={() => paginate(-1)}
89 | position="absolute"
90 | left="0"
91 | zIndex="2"
92 | />
93 | }
99 | onClick={() => paginate(1)}
100 | position="absolute"
101 | right="0"
102 | zIndex="2"
103 | />
104 |
105 | );
106 | };
107 |
108 | export default Carousel;
109 |
--------------------------------------------------------------------------------
/ui/src/components/CopyShareLinkMenuItem.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, MenuItem, Spinner, useToast } from "@chakra-ui/react";
2 | import { IconLink } from "@tabler/icons-react";
3 | import { useContext, useState } from "react";
4 | import { WorkspaceContext } from "../WorkspaceContext";
5 | import { copySharelink } from "./copySharelink";
6 | import { Workflow } from "../types/dbTypes";
7 |
8 | export default function CopyShareLinkMenuItem({
9 | curFlow,
10 | }: {
11 | curFlow: Workflow;
12 | }) {
13 | const { session } = useContext(WorkspaceContext);
14 | const toast = useToast();
15 | const [loading, setloading] = useState(false);
16 |
17 | return (
18 | {
20 | if (!curFlow) {
21 | return;
22 | }
23 | if (!session?.shareKey) {
24 | alert(
25 | "Please set your share key first in Settings to enable cloud share.",
26 | );
27 | return;
28 | }
29 | setloading(true);
30 | const res = await copySharelink(session.shareKey, curFlow!);
31 | if (res?.link) {
32 | navigator.clipboard.writeText(res.link);
33 | toast({
34 | title: "Link copied",
35 | status: "success",
36 | duration: 2000,
37 | });
38 | } else {
39 | toast({
40 | title: "Failed to copy link",
41 | status: "error",
42 | description: res?.error,
43 | duration: null,
44 | });
45 | }
46 | setloading(false);
47 | }}
48 | icon={loading ? : }
49 | iconSpacing={1}
50 | alignItems={"center"}
51 | isDisabled={loading}
52 | >
53 |
54 | Copy share link
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/ui/src/components/CustomMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from "react";
2 | import { Box, useOutsideClick } from "@chakra-ui/react";
3 |
4 | type Props = {
5 | menuButton: React.ReactElement;
6 | options: React.ReactElement;
7 | isOpen: boolean;
8 | onClose: () => void;
9 | };
10 | export default function CustomMenu({
11 | options,
12 | menuButton,
13 | isOpen,
14 | onClose,
15 | }: Props) {
16 | const ref = useRef(null);
17 | useOutsideClick({
18 | ref: ref,
19 | handler: () => onClose(),
20 | });
21 |
22 | return (
23 |
24 | {menuButton}
25 | {isOpen && (
26 |
34 | {options}
35 |
36 | )}
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/ui/src/components/CustomSelector.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from "react";
2 | import { Box, Button, Card } from "@chakra-ui/react";
3 | import { IconChevronDown } from "@tabler/icons-react";
4 |
5 | export interface CustomSelectorOption {
6 | label: string;
7 | value: T;
8 | icon?: React.ReactElement;
9 | }
10 |
11 | type Props = {
12 | options: CustomSelectorOption[];
13 | value: T;
14 | onChange: (value: T) => void;
15 | };
16 | export default function CustomSelector({
17 | options,
18 | value,
19 | onChange,
20 | }: Props) {
21 | const [isOpen, setIsOpen] = useState(false);
22 | const ref = useRef(null);
23 | const selectedOption = options.find((option) => option.value === value);
24 |
25 | useEffect(() => {
26 | const handleClickOutside = (event: any) => {
27 | if (ref.current && !ref.current.contains(event.target)) {
28 | setIsOpen(false);
29 | }
30 | };
31 | document.addEventListener("mousedown", handleClickOutside);
32 | return () => {
33 | document.removeEventListener("mousedown", handleClickOutside);
34 | };
35 | }, [ref]);
36 |
37 | const toggleDropdown = () => setIsOpen(!isOpen);
38 |
39 | return (
40 |
41 | }
44 | leftIcon={selectedOption?.icon}
45 | >
46 | {selectedOption?.label}
47 |
48 | {isOpen && (
49 |
59 | {options.map((option) => (
60 | {
63 | setIsOpen(false);
64 | onChange(option.value);
65 | }}
66 | leftIcon={option.icon}
67 | justifyContent="flex-start"
68 | variant="ghost"
69 | width="full"
70 | >
71 | {option.label}
72 |
73 | ))}
74 |
75 | )}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/ui/src/components/DeleteConfirm.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Popover,
4 | PopoverTrigger,
5 | PopoverContent,
6 | PopoverCloseButton,
7 | PopoverArrow,
8 | PopoverBody,
9 | Text,
10 | IconButton,
11 | Tooltip,
12 | Box,
13 | } from "@chakra-ui/react";
14 | import { IconTrash } from "@tabler/icons-react";
15 |
16 | type Props = {
17 | promptMessage: string;
18 | isDisabled?: boolean;
19 | variant?: string;
20 | tooltipText?: string;
21 | onDelete: () => void;
22 | };
23 |
24 | export default function DeleteConfirm(props: Props) {
25 | const {
26 | promptMessage = "Are you sure you want to delete this?",
27 | variant = "ghost",
28 | onDelete,
29 | isDisabled = false,
30 | tooltipText,
31 | } = props;
32 |
33 | return (
34 |
35 | {({ onClose }) => (
36 | <>
37 |
38 |
39 |
40 | }
44 | isDisabled={isDisabled}
45 | variant={variant}
46 | />
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {promptMessage}
55 |
56 | {
60 | onDelete();
61 | onClose();
62 | }}
63 | >
64 | Yes, delete
65 |
66 |
67 |
68 | >
69 | )}
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/ui/src/components/Draggable.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useState,
3 | useCallback,
4 | MouseEvent,
5 | useRef,
6 | PropsWithChildren,
7 | } from "react";
8 |
9 | const POSITION = { x: 0, y: 0 };
10 |
11 | interface Props {
12 | onDragEnd: (position: { x: number; y: number }) => void;
13 | dragIconId: string;
14 | }
15 |
16 | export default function Draggable({
17 | children,
18 | onDragEnd,
19 | dragIconId,
20 | }: PropsWithChildren) {
21 | const originRef = useRef(POSITION);
22 | const isDraggingRef = useRef(false);
23 | const [translation, setTranslation] = useState(POSITION);
24 |
25 | const handleMouseDown = (e: MouseEvent) => {
26 | if (
27 | !(e.target instanceof Element) ||
28 | !(e.target.parentNode instanceof Element)
29 | ) {
30 | return;
31 | }
32 | if ([e.target?.id, e.target?.parentNode?.id].includes(dragIconId)) {
33 | originRef.current = {
34 | x: e.clientX,
35 | y: e.clientY,
36 | };
37 | isDraggingRef.current = true;
38 | window.addEventListener("mousemove", handleMouseMove);
39 | window.addEventListener("mouseup", handleMouseUp);
40 | const bodyEle = document.getElementsByTagName("body")[0];
41 | bodyEle.style.userSelect = "none";
42 | }
43 | };
44 |
45 | const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
46 | const translation = {
47 | x: e.clientX - originRef.current.x,
48 | y: e.clientY - originRef.current.y,
49 | };
50 |
51 | setTranslation(translation);
52 | }, []);
53 |
54 | const handleMouseUp = useCallback(() => {
55 | setTranslation((state) => {
56 | setTimeout(() => {
57 | onDragEnd(state);
58 | }, 0);
59 | return POSITION;
60 | });
61 | isDraggingRef.current = false;
62 | const bodyEle = document.getElementsByTagName("body")[0];
63 | bodyEle.style.userSelect = "auto";
64 | window.removeEventListener("mousemove", handleMouseMove);
65 | window.removeEventListener("mouseup", handleMouseUp);
66 | }, [onDragEnd, handleMouseMove]);
67 |
68 | const styles = {
69 | transform: `translate(${translation.x}px, ${translation.y}px)`,
70 | zIndex: isDraggingRef.current ? 1000 : 1,
71 | };
72 |
73 | return (
74 |
75 | {children}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/ui/src/components/EditFolderName.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalCloseButton,
8 | ModalBody,
9 | ModalFooter,
10 | FormControl,
11 | FormLabel,
12 | FormErrorMessage,
13 | Input,
14 | } from "@chakra-ui/react";
15 | import { useContext, ChangeEvent, useState } from "react";
16 | import { foldersTable } from "../db-tables/WorkspaceDB";
17 | import { RecentFilesContext } from "../WorkspaceContext";
18 | import { Folder } from "../types/dbTypes";
19 |
20 | type Props = {
21 | folder: Folder;
22 | onclose: () => void;
23 | };
24 |
25 | export default function EditFolderNameModal({ folder, onclose }: Props) {
26 | // const { curFlowID } = useContext(WorkspaceContext);
27 | const [editName, setEditName] = useState(folder.name);
28 | const [submitError, setSubmitError] = useState("");
29 | const { onRefreshFilesList } = useContext(RecentFilesContext);
30 | const handleChange = (event: ChangeEvent) => {
31 | setEditName(event.target.value);
32 | submitError && setSubmitError("");
33 | };
34 |
35 | const onSubmit = async () => {
36 | const trimEditName = editName.trim();
37 | setEditName(trimEditName);
38 | if (trimEditName === folder.name) return onclose();
39 | const uniqName = await foldersTable?.generateUniqueName(
40 | trimEditName,
41 | folder.parentFolderID ?? "",
42 | );
43 | if (uniqName !== trimEditName) {
44 | setSubmitError(
45 | "The name is duplicated, please modify it and submit again.",
46 | );
47 | } else {
48 | await foldersTable?.update(folder.id, {
49 | name: trimEditName,
50 | });
51 | onRefreshFilesList && onRefreshFilesList();
52 | onclose();
53 | }
54 | };
55 |
56 | return (
57 |
58 |
59 |
60 | Edit folder name
61 |
62 |
63 |
64 | Folder name
65 | {
70 | e.code === "Enter" && !submitError && editName && onSubmit();
71 | }}
72 | />
73 | {submitError && {submitError} }
74 |
75 |
76 |
77 |
83 | Save
84 |
85 | Cancel
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/ui/src/components/HoverMenu.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from "react";
2 | import CustomMenu from "./CustomMenu";
3 | import { Box, Menu, MenuList } from "@chakra-ui/react";
4 | type Props = {
5 | menuButton: React.ReactElement;
6 | menuContent: React.ReactElement;
7 | onClose?: () => void;
8 | };
9 | export default function HoverMenu({ menuButton, menuContent, onClose }: Props) {
10 | const closeTimeoutId = useRef();
11 | const [isOpen, setIsOpen] = useState(false);
12 | const delayedClose = () => {
13 | closeTimeoutId.current = setTimeout(() => setIsOpen(false), 400); // delay of 300ms
14 | };
15 |
16 | const onOpen = () => {
17 | setIsOpen(true);
18 | clearTimeout(closeTimeoutId.current);
19 | closeTimeoutId.current = undefined;
20 | };
21 | return (
22 |
32 | {menuButton}
33 |
34 | }
35 | options={
36 |
37 |
43 | {menuContent}
44 |
45 |
46 | }
47 | />
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/ui/src/components/MediaPreview.tsx:
--------------------------------------------------------------------------------
1 | import { Image } from "@chakra-ui/react";
2 | import { isImageFormat } from "../utils";
3 | import { useEffect, useState } from "react";
4 |
5 | type Props = {
6 | mediaLocalPath: string;
7 | size: number;
8 | autoPlay?: boolean;
9 | onBrokenLink?: () => void;
10 | hideBrokenImage?: boolean;
11 | isPreview?: boolean;
12 | objectFit?: "cover" | "contain";
13 | };
14 | export default function MediaPreview({
15 | mediaLocalPath,
16 | size,
17 | isPreview,
18 | autoPlay,
19 | hideBrokenImage,
20 | objectFit,
21 | onBrokenLink,
22 | }: Props) {
23 | const [isVisible, setIsVisible] = useState(true);
24 |
25 | useEffect(() => {
26 | const checkMediaExists = async () => {
27 | try {
28 | const response = await fetch(
29 | isPreview
30 | ? `/workspace/preview_media?filename=${mediaLocalPath}`
31 | : `/workspace/view_media?filename=${mediaLocalPath}`,
32 | );
33 | if (response.status == 404) {
34 | setIsVisible(false);
35 | onBrokenLink?.();
36 | return;
37 | }
38 | } catch (error) {
39 | console.error("Error checking media exists", error);
40 | }
41 | };
42 |
43 | hideBrokenImage && checkMediaExists();
44 | }, []);
45 |
46 | if (!isVisible) return null;
47 | return isImageFormat(mediaLocalPath) ? (
48 |
55 | ) : null;
56 | }
57 |
--------------------------------------------------------------------------------
/ui/src/components/Overlay.tsx:
--------------------------------------------------------------------------------
1 | export function Overlay({
2 | backgroundColor,
3 | }: {
4 | backgroundColor?: string | null;
5 | }) {
6 | const bg =
7 | backgroundColor === undefined
8 | ? "rgba(0,0,0,0.5)"
9 | : backgroundColor ?? undefined;
10 | return (
11 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/ui/src/components/SearchInput.tsx:
--------------------------------------------------------------------------------
1 | import { Box, IconButton, Input } from "@chakra-ui/react";
2 | import { IconSearch, IconX } from "@tabler/icons-react";
3 | import { CSSProperties } from "react";
4 |
5 | type Props = {
6 | searchValue: string;
7 | onUpdateSearchValue: (value: string) => void;
8 | placeholder?: string;
9 | style?: CSSProperties;
10 | };
11 |
12 | const IconSearchStyle: CSSProperties = {
13 | position: "absolute",
14 | marginLeft: "10px",
15 | width: "20px",
16 | height: "20px",
17 | top: "50%",
18 | color: "#A0AEC0",
19 | transform: "translateY(-50%)",
20 | };
21 |
22 | export default function SearchInput(props: Props) {
23 | const { searchValue, onUpdateSearchValue, style } = props;
24 | const isSearchValueNotEmpty = searchValue !== "";
25 |
26 | return (
27 |
28 |
29 | {isSearchValueNotEmpty && (
30 | }
40 | onClick={() => onUpdateSearchValue("")}
41 | aria-label="clear input button"
42 | />
43 | )}
44 | onUpdateSearchValue(target.value)}
51 | />
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/ui/src/components/copySharelink.tsx:
--------------------------------------------------------------------------------
1 | import { userSettingsTable, workflowsTable } from "../db-tables/WorkspaceDB";
2 | import { getNodeDefs } from "../share/shareUtils";
3 | import { EWorkflowPrivacy, Workflow } from "../types/dbTypes";
4 | import { ShareWorkflowData } from "../types/types";
5 | import { app } from "../utils/comfyapp";
6 |
7 | export async function copySharelink(
8 | shareKey: string,
9 | curWorkflow: Workflow,
10 | ): Promise<{
11 | link?: string | null;
12 | error?: string | null;
13 | } | null> {
14 | if (curWorkflow.cloudID) {
15 | const link =
16 | userSettingsTable?.settings?.cloudHost +
17 | "/workflow/" +
18 | curWorkflow.cloudID;
19 | return {
20 | link,
21 | };
22 | }
23 | const prompt = await app.graphToPrompt();
24 | const graph = app.graph.serialize();
25 | if (!graph.extra) {
26 | graph.extra = {};
27 | }
28 | graph.extra.apiPrompt = prompt.output ?? null;
29 | const input: ShareWorkflowData = {
30 | workflow: {
31 | name: curWorkflow?.name ?? "Untitled",
32 | cloudID: curWorkflow?.cloudID,
33 | },
34 | version: {
35 | name: "v1",
36 | json: JSON.stringify(graph),
37 | },
38 | nodeDefs: getNodeDefs(),
39 | privacy: EWorkflowPrivacy.UNLISTED,
40 | };
41 | return await fetch(
42 | userSettingsTable?.settings?.cloudHost + "/api/createCloudflow",
43 | {
44 | method: "POST",
45 | headers: {
46 | "Content-Type": "application/json",
47 | Authorization: `Bearer ${shareKey}`,
48 | },
49 | body: JSON.stringify(input),
50 | },
51 | )
52 | .then((resp) => resp.json())
53 | .then((data) => {
54 | if (data?.data) {
55 | const cloudID = data.data.workflowID;
56 | workflowsTable?.updateMetaInfo(curWorkflow?.id!, {
57 | cloudID,
58 | });
59 |
60 | const link =
61 | userSettingsTable?.settings?.cloudHost + "/workflow/" + cloudID;
62 | return {
63 | link,
64 | };
65 | } else {
66 | return {
67 | error: "Failed to create version. " + (data?.error ?? ""),
68 | };
69 | }
70 | })
71 | .catch((e) => {
72 | return {
73 | error: "Failed to create version. " + (e?.error ?? ""),
74 | };
75 | });
76 | }
77 |
--------------------------------------------------------------------------------
/ui/src/const.ts:
--------------------------------------------------------------------------------
1 | export const LEGACY_COMFYSPACE_TRACKING_FIELD_NAME = "comfyspace_tracking";
2 | export const COMFYSPACE_TRACKING_FIELD_NAME = "workspace_info";
3 | export const COMFYSPACE_AUTH_ENDPOINT = "comfyspace_auth";
4 | export const COMFYSPACE_AUTH_REDIRECT_URL = "http://localhost:8188/";
5 | export const DRAWER_Z_INDEX = 9999999;
6 | export const UPGRADE_TO_2WAY_SYNC_KEY = "upgrade_to_2way_sync";
7 | export const TOPBAR_BUTTON_HEIGHT = 28;
8 | export const SHORTCUT_TRIGGER_EVENT = "SHORTCUT_TRIGGER_EVENT";
9 |
--------------------------------------------------------------------------------
/ui/src/customHooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useDebounce = (value: T, delay: number): T => {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(handler);
13 | };
14 | }, [value, delay]);
15 |
16 | return debouncedValue;
17 | };
18 |
--------------------------------------------------------------------------------
/ui/src/customHooks/useDebounceFn.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from "react";
2 |
3 | export default function useDebounceFn void>(
4 | fn: T,
5 | delay: number,
6 | ): [T, () => void] {
7 | const timeoutRef = useRef(0);
8 |
9 | const cancel = useCallback(() => {
10 | if (timeoutRef.current !== null) {
11 | clearTimeout(timeoutRef.current);
12 | timeoutRef.current = 0;
13 | }
14 | }, []);
15 |
16 | const debouncedFn = useCallback(
17 | (...args: Parameters) => {
18 | cancel();
19 | timeoutRef.current = setTimeout(() => fn(...args), delay);
20 | },
21 | [fn, delay, cancel],
22 | ) as T;
23 |
24 | useEffect(() => {
25 | return () => {
26 | cancel();
27 | };
28 | }, []);
29 |
30 | return [debouncedFn, cancel];
31 | }
32 |
--------------------------------------------------------------------------------
/ui/src/customHooks/useStateRef.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef, useState, SetStateAction, Dispatch } from "react";
2 |
3 | const isFunction = (
4 | setStateAction: SetStateAction,
5 | ): setStateAction is (prevState: S) => S =>
6 | typeof setStateAction === "function";
7 |
8 | type ReadOnlyRefObject = {
9 | readonly current: T;
10 | };
11 |
12 | type UseStateRef = {
13 | (
14 | initialState: S | (() => S),
15 | ): [S, Dispatch>, ReadOnlyRefObject];
16 | (): [
17 | S | undefined,
18 | Dispatch>,
19 | ReadOnlyRefObject,
20 | ];
21 | };
22 |
23 | export const useStateRef: UseStateRef = (initialState?: S | (() => S)) => {
24 | const [state, setState] = useState(initialState);
25 | const ref = useRef(state);
26 |
27 | const dispatch: typeof setState = useCallback((setStateAction: any) => {
28 | ref.current = isFunction(setStateAction)
29 | ? setStateAction(ref.current)
30 | : setStateAction;
31 |
32 | setState(ref.current);
33 | }, []);
34 |
35 | return [state, dispatch, ref];
36 | };
37 |
--------------------------------------------------------------------------------
/ui/src/db-tables/ChangelogsTable.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from "nanoid";
2 | import { Table, userSettingsTable } from "./WorkspaceDB";
3 | import { Changelog } from "../types/dbTypes";
4 | import { TableBase } from "./TableBase";
5 | import { indexdb } from "./indexdb";
6 | const LIMIT = 80;
7 | export class ChangelogsTable extends TableBase {
8 | static readonly TABLE_NAME: Table = "changelogs";
9 | constructor() {
10 | super("changelogs");
11 | }
12 | static async load(): Promise {
13 | const instance = new ChangelogsTable();
14 | return instance;
15 | }
16 |
17 | public async listByWorkflowID(workflowID: string): Promise {
18 | const objects = await indexdb["changelogs"]
19 | .where("workflowID")
20 | .equals(workflowID)
21 | .reverse()
22 | .sortBy("createTime");
23 | return objects;
24 | }
25 | public async getLastestByWorkflowID(workflowID: string): Promise {
26 | const objects = await this.listByWorkflowID(workflowID);
27 | return objects[0];
28 | }
29 | public async create(input: {
30 | json: string;
31 | workflowID: string;
32 | isAutoSave: boolean;
33 | }): Promise {
34 | const latest = await this.getLastestByWorkflowID(input.workflowID);
35 | // only create when there is a change
36 | if (latest != null && latest.json === input.json) {
37 | return null;
38 | }
39 |
40 | const change: Changelog = {
41 | id: nanoid(),
42 | json: input.json,
43 | workflowID: input.workflowID,
44 | createTime: Date.now(),
45 | isAutoSave: input.isAutoSave,
46 | };
47 | await indexdb.changelogs.add(change);
48 | await this.deleteLogsExceedLimit(input.workflowID);
49 | return change;
50 | }
51 | async deleteLogsExceedLimit(workflowID: string) {
52 | const all = await indexdb.changelogs
53 | .where("workflowID")
54 | .equals(workflowID)
55 | .reverse()
56 | .sortBy("createTime");
57 | const limit = userSettingsTable?.settings?.maximumChangelogNumber ?? LIMIT;
58 | if (all.length > limit) {
59 | const toDelete = all.slice(limit);
60 | await indexdb.changelogs.bulkDelete(toDelete.map((c) => c.id));
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/ui/src/db-tables/DiskFileUtils.ts:
--------------------------------------------------------------------------------
1 | import { foldersTable, workflowsTable, userSettingsTable } from "./WorkspaceDB";
2 | import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi";
3 |
4 | export async function genFolderRelPath(
5 | folderId: string | null,
6 | ): Promise {
7 | let filePath = "";
8 | let curFolderID = folderId;
9 | while (curFolderID != null) {
10 | const folder = await foldersTable?.get(curFolderID);
11 | if (folder == null) {
12 | break;
13 | }
14 | const folderName = folder.name;
15 | filePath = `${folderName}/${filePath}`;
16 | curFolderID = folder.parentFolderID ?? null;
17 | }
18 |
19 | return filePath;
20 | }
21 |
22 | export async function getFileCountInFolder(folderId: string): Promise {
23 | const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
24 | if (twoWaySyncEnabled) {
25 | return (await TwowayFolderSyncAPI.genFilesCountInFolder(folderId)) ?? 0;
26 | }
27 | const allFlows = (await workflowsTable?.listAll()) ?? [];
28 | const allFolders = (await foldersTable?.listAll()) ?? [];
29 | const nestedFolderIdStack = [folderId];
30 | let count = 0;
31 |
32 | while (nestedFolderIdStack.length > 0) {
33 | const curFolderId = nestedFolderIdStack.shift();
34 |
35 | if (curFolderId) {
36 | for (const flow of allFlows) {
37 | if (flow.parentFolderID === curFolderId) {
38 | count++;
39 | }
40 | }
41 |
42 | const curNestedFolderIds = allFolders
43 | .filter((f) => f.parentFolderID === curFolderId)
44 | .map((f) => f.id);
45 |
46 | if (curNestedFolderIds.length) {
47 | nestedFolderIdStack.push(...curNestedFolderIds);
48 | }
49 | }
50 | }
51 |
52 | return count;
53 | }
54 |
--------------------------------------------------------------------------------
/ui/src/db-tables/IndexDBUtils.ts:
--------------------------------------------------------------------------------
1 | // Legacy indexdb backup, to be deleted after indexdb migration is done
2 |
3 | const WORKSPACE_KEY = "backup";
4 | const WORKSPACE_TABLE = "workspace";
5 | let indexDB: IDBDatabase | null = null;
6 | // Function to open a database
7 | function openDatabase(): Promise {
8 | return new Promise((resolve, reject) => {
9 | const request = indexedDB.open("WorkspaceDB", 3);
10 |
11 | request.onupgradeneeded = (event) => {
12 | const db = (event.target as IDBOpenDBRequest).result;
13 | // Create an object store if it doesn't exist
14 | if (!db.objectStoreNames.contains(WORKSPACE_TABLE)) {
15 | db.createObjectStore(WORKSPACE_TABLE, { keyPath: "id" });
16 | }
17 | };
18 |
19 | request.onsuccess = (event) => {
20 | resolve((event.target as IDBOpenDBRequest).result);
21 | };
22 |
23 | request.onerror = (event) => {
24 | reject((event.target as IDBOpenDBRequest).error);
25 | };
26 | });
27 | }
28 |
29 | // Function to write data to the database
30 | async function writeWorkspaceTable(data: string): Promise {
31 | try {
32 | if (indexDB == null) indexDB = await openDatabase();
33 |
34 | return new Promise((resolve, reject) => {
35 | if (indexDB == null) return reject("indexDB is null");
36 | const transaction = indexDB.transaction([WORKSPACE_TABLE], "readwrite");
37 | const store = transaction.objectStore(WORKSPACE_TABLE);
38 | const request = store.put({ id: WORKSPACE_KEY, value: data });
39 |
40 | request.onsuccess = () => resolve();
41 | request.onerror = () => reject(request.error);
42 | });
43 | } catch (error) {
44 | console.error("Error while opening database:", error);
45 | }
46 | }
47 |
48 | // Function to read data from the database
49 | async function readDataFromDatabase(): Promise {
50 | try {
51 | if (indexDB == null) indexDB = await openDatabase();
52 |
53 | return new Promise((resolve, reject) => {
54 | if (indexDB == null) return reject("indexDB is null");
55 |
56 | const transaction = indexDB.transaction([WORKSPACE_TABLE], "readonly");
57 | const store = transaction.objectStore(WORKSPACE_TABLE);
58 | const request = store.get(WORKSPACE_KEY);
59 |
60 | request.onsuccess = () => {
61 | if (request.result) {
62 | resolve(request.result.value);
63 | } else {
64 | resolve(undefined); // Key not found
65 | }
66 | };
67 | request.onerror = () => reject(request.error);
68 | });
69 | } catch (error) {
70 | console.error("Error while reading from database:", error);
71 | }
72 | }
73 |
74 | export async function getWorkspaceIndexDB() {
75 | try {
76 | const comfyspaceData = await readDataFromDatabase();
77 |
78 | return comfyspaceData;
79 | } catch (error) {
80 | console.error("Error retrieving data from IndexedDB:", error);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/ui/src/db-tables/MediaTable.ts:
--------------------------------------------------------------------------------
1 | import { Table, workflowsTable } from "./WorkspaceDB";
2 | import { TableBase } from "./TableBase";
3 | import { Media } from "../types/dbTypes";
4 | import { indexdb } from "./indexdb";
5 |
6 | export class MediaTable extends TableBase {
7 | static readonly TABLE_NAME: Table = "media";
8 | constructor() {
9 | super("media");
10 | }
11 | public async listByWorkflowID(workflowID: string): Promise {
12 | const medias = await indexdb["media"]
13 | .where("workflowID")
14 | .equals(workflowID)
15 | .reverse()
16 | .sortBy("createTime");
17 | return medias;
18 | }
19 |
20 | public async create(input: {
21 | localPath: string;
22 | workflowID: string;
23 | }): Promise {
24 | const format = input.localPath.split(".").pop();
25 | if (format == null) return null;
26 |
27 | const md: Media = {
28 | id: input.localPath,
29 | localPath: input.localPath,
30 | workflowID: input.workflowID,
31 | createTime: Date.now(),
32 | };
33 | await workflowsTable?.updateMetaInfo(input.workflowID, {
34 | latestImage: md.localPath,
35 | });
36 | // save indexdb
37 | indexdb.media.put(md);
38 | return md;
39 | }
40 |
41 | async delete(id: string): Promise {
42 | super.delete(id);
43 | if (workflowsTable?.curWorkflow?.coverMediaPath === id) {
44 | workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, {
45 | coverMediaPath: undefined,
46 | });
47 | }
48 | if (workflowsTable?.curWorkflow?.latestImage === id) {
49 | workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, {
50 | latestImage: undefined,
51 | });
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/ui/src/db-tables/ModelsTable.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "../types/dbTypes";
2 | import { Table } from "./WorkspaceDB";
3 | import { TableBase } from "./TableBase";
4 |
5 | export class ModelsTable extends TableBase {
6 | static readonly TABLE_NAME: Table = "models";
7 | constructor() {
8 | super("models");
9 | }
10 | static async load(): Promise {
11 | const instance = new ModelsTable();
12 | return instance;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/ui/src/db-tables/UserSettingsTable.ts:
--------------------------------------------------------------------------------
1 | import type { UserSettings } from "../types/dbTypes";
2 | import { TableBase } from "./TableBase";
3 | import { MODEL_TYPE_TO_FOLDER_MAPPING } from "../model-manager/install-models/util/modelTypes";
4 | import { fetchApi, fetchMyWorkflowsDir } from "../Api";
5 |
6 | export class UserSettingsTable extends TableBase {
7 | public defaultSettings: UserSettings;
8 | public readonly DEFAULT_USER = "guest";
9 | static readonly TABLE_NAME = "userSettings";
10 |
11 | private _settings: UserSettings | undefined = undefined;
12 |
13 | get settings() {
14 | return this._settings;
15 | }
16 |
17 | constructor() {
18 | super("userSettings");
19 | this.defaultSettings = {
20 | id: this.DEFAULT_USER,
21 | topBarStyle: {
22 | top: 0,
23 | left: 0,
24 | },
25 | myWorkflowsDir: "",
26 | shortcuts: {
27 | save: "Shift+S",
28 | saveAs: "Control+Alt+S",
29 | openSpotlightSearch: "Control+P",
30 | },
31 | defaultFolders: MODEL_TYPE_TO_FOLDER_MAPPING,
32 | autoSave: false,
33 | autoSaveDuration: 3,
34 | twoWaySync: false,
35 | foldersOnTop: false,
36 | cloudHost: "https://www.nodecafe.co",
37 | maximumChangelogNumber: 80,
38 | hideCoverImage: false,
39 | disableUnsavedWarning: false,
40 | };
41 | }
42 |
43 | public async getSetting(
44 | key: K,
45 | ): Promise {
46 | const currentUserSettings: UserSettings | undefined = await this.get(
47 | this.DEFAULT_USER,
48 | );
49 | if (key === "shortcuts" && currentUserSettings?.shortcuts) {
50 | return {
51 | ...this.defaultSettings.shortcuts,
52 | ...currentUserSettings.shortcuts,
53 | } as UserSettings[K];
54 | }
55 | return currentUserSettings?.[key] ?? this.defaultSettings[key];
56 | }
57 | public async get(_id: string): Promise {
58 | const obj = await fetchApi("/workspace/get_settings", {
59 | headers: {
60 | "Content-Type": "application/json",
61 | },
62 | }).then((res) => res.json());
63 | return obj as any;
64 | }
65 |
66 | public async upsert(newPairs: Partial) {
67 | const oldSettings =
68 | (await this.get(this.DEFAULT_USER)) ?? this.defaultSettings;
69 | const newSettings = {
70 | ...oldSettings,
71 | ...newPairs,
72 | };
73 | await fetchApi("/workspace/save_settings", {
74 | method: "POST",
75 | body: JSON.stringify(newSettings),
76 | headers: {
77 | "Content-Type": "application/json",
78 | },
79 | });
80 | this._settings = {
81 | ...this.defaultSettings,
82 | ...newSettings,
83 | };
84 | }
85 |
86 | static async load(): Promise {
87 | const instance = new UserSettingsTable();
88 | const myWorkflowsDir = await fetchMyWorkflowsDir();
89 |
90 | instance.defaultSettings.myWorkflowsDir = myWorkflowsDir.path!;
91 |
92 | await instance.get(instance.DEFAULT_USER).then((res) => {
93 | instance._settings = {
94 | ...instance.defaultSettings,
95 | ...res,
96 | };
97 | });
98 | if (
99 | instance._settings?.cloudHost?.includes("nodecafe.org") ||
100 | instance._settings?.cloudHost?.includes("comfyspace.art")
101 | ) {
102 | // overwrite legacy comfyspace.art
103 | await instance.upsert({
104 | cloudHost: instance.defaultSettings.cloudHost,
105 | });
106 | }
107 | return instance;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/ui/src/db-tables/WorkflowVersionsTable.ts:
--------------------------------------------------------------------------------
1 | import { Table } from "./WorkspaceDB";
2 | import { WorkflowVersion } from "../types/dbTypes";
3 | import { TableBase } from "./TableBase";
4 | import { indexdb } from "./indexdb";
5 |
6 | export class WorkflowVersionsTable extends TableBase {
7 | static readonly TABLE_NAME: Table = "workflowVersions";
8 | constructor() {
9 | super("workflowVersions");
10 | }
11 | static async load(): Promise {
12 | const instance = new WorkflowVersionsTable();
13 | return instance;
14 | }
15 |
16 | public async listByWorkflowID(
17 | workflowID: string,
18 | ): Promise {
19 | const objects = (await indexdb[this.tableName]
20 | .where("workflowID")
21 | .equals(workflowID)
22 | .reverse()
23 | .sortBy("createTime")) as WorkflowVersion[];
24 | return objects ?? [];
25 | }
26 |
27 | public async getLatestByWorkflowID(
28 | workflowID: string,
29 | ): Promise {
30 | const objects = await this.listByWorkflowID(workflowID);
31 | if (objects?.length) return objects[0];
32 |
33 | return null;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/ui/src/db-tables/WorkspaceDB.ts:
--------------------------------------------------------------------------------
1 | import { ChangelogsTable } from "./ChangelogsTable";
2 | import { WorkflowsTable } from "./WorkflowsTable";
3 | import { FoldersTable } from "./FoldersTable";
4 | import { MediaTable } from "./MediaTable";
5 | import { UserSettingsTable } from "./UserSettingsTable";
6 | import { TagsTable } from "./tagsTable";
7 | import { indexdb } from "./indexdb";
8 | import { Folder, Workflow } from "../types/dbTypes";
9 | import { WorkflowVersionsTable } from "./WorkflowVersionsTable";
10 |
11 | export type Table =
12 | | "workflows"
13 | | "tags"
14 | | "userSettings"
15 | | "folders"
16 | | "changelogs"
17 | | "media"
18 | | "models"
19 | | "workflowVersions";
20 |
21 | export function isFolder(n: Folder | Workflow): n is Folder {
22 | return "type" in n && n.type === "folder";
23 | }
24 |
25 | export let workflowsTable: WorkflowsTable | null = null;
26 | export let tagsTable: TagsTable | null = null;
27 | export let userSettingsTable: UserSettingsTable | null = null;
28 | export let foldersTable: FoldersTable | null = null;
29 | export let changelogsTable: ChangelogsTable | null = null;
30 | export let mediaTable: MediaTable | null = null;
31 | export let workflowVersionsTable: WorkflowVersionsTable | null = null;
32 |
33 | export async function loadDBs() {
34 | const loadWorkflows = async () => {
35 | workflowsTable = await WorkflowsTable.load();
36 | };
37 | const loadTags = async () => {
38 | tagsTable = await TagsTable.load();
39 | };
40 | const loadUserSettings = async () => {
41 | userSettingsTable = await UserSettingsTable.load();
42 | };
43 | const loadFolders = async () => {
44 | foldersTable = await FoldersTable.load();
45 | };
46 | const loadChangelogs = async () => {
47 | changelogsTable = await ChangelogsTable.load();
48 | };
49 | const loadMedia = async () => {
50 | mediaTable = new MediaTable();
51 | };
52 | const loadWorkflowVersions = async () => {
53 | workflowVersionsTable = await WorkflowVersionsTable.load();
54 | };
55 | await Promise.all([
56 | loadWorkflows(),
57 | loadTags(),
58 | loadUserSettings(),
59 | loadFolders(),
60 | loadChangelogs(),
61 | loadMedia(),
62 | loadWorkflowVersions(),
63 | ]);
64 | }
65 |
--------------------------------------------------------------------------------
/ui/src/db-tables/indexdb.ts:
--------------------------------------------------------------------------------
1 | import Dexie, { Table } from "dexie";
2 | import {
3 | Workflow,
4 | LocalCache,
5 | Changelog,
6 | Folder,
7 | Media,
8 | Model,
9 | Tag,
10 | UserSettings,
11 | WORKSPACE_INDEXDB_NAME,
12 | WorkflowVersion,
13 | } from "../types/dbTypes";
14 |
15 | class ManagerDB extends Dexie {
16 | workflows!: Table;
17 | changelogs!: Table;
18 | media!: Table;
19 | folders!: Table;
20 | tags!: Table;
21 | userSettings!: Table;
22 | models!: Table;
23 | cache!: Table;
24 | workflowVersions!: Table;
25 |
26 | constructor() {
27 | super(WORKSPACE_INDEXDB_NAME);
28 | this.version(1)
29 | .stores({
30 | workflows: "&id, name, parentFolderID", // Primary key and indexed props
31 | changelogs: "&id, workflowID",
32 | media: "&id, workflowID",
33 | folders: "&id, name, parentFolderID",
34 | tags: "&name",
35 | userSettings: "&id",
36 | models: "&id, fileName, fileHash",
37 | cache: "&id",
38 | })
39 | .upgrade((trans) => {});
40 | this.version(2)
41 | .stores({
42 | workflows: "&id, name, parentFolderID,cloudID",
43 | changelogs: "&id, workflowID",
44 | media: "&id, workflowID",
45 | folders: "&id, name, parentFolderID",
46 | tags: "&name",
47 | userSettings: "&id",
48 | models: "&id, fileName, fileHash",
49 | cache: "&id",
50 | workflowVersions: "&id, name, workflowID,cloudID",
51 | })
52 | .upgrade((trans) => {});
53 | }
54 | }
55 |
56 | export const indexdb = new ManagerDB();
57 |
--------------------------------------------------------------------------------
/ui/src/db-tables/tagsTable.ts:
--------------------------------------------------------------------------------
1 | import { Tag } from "../types/dbTypes";
2 | import { Table } from "./WorkspaceDB";
3 | import { TableBase } from "./TableBase";
4 |
5 | export class TagsTable extends TableBase {
6 | static readonly TABLE_NAME: Table = "tags";
7 |
8 | constructor() {
9 | super("tags");
10 | }
11 |
12 | static async load(): Promise {
13 | const instance = new TagsTable();
14 | return instance;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/ui/src/gallery/GalleryContext.ts:
--------------------------------------------------------------------------------
1 | import { type Dispatch, type SetStateAction, createContext } from "react";
2 | import type { Media } from "../types/dbTypes.ts";
3 |
4 | interface GalleryContextProps {
5 | showAllImages: boolean;
6 | curMedia: Media | null;
7 | setCurMedia: (media: Media | null) => void;
8 | setMediaList: Dispatch>;
9 | setShowAllImages: (showAllImages: boolean) => void;
10 | }
11 |
12 | export const GalleryContext = createContext({
13 | showAllImages: false,
14 | curMedia: null,
15 | setCurMedia(): void {},
16 | setMediaList(): void {},
17 | setShowAllImages(): void {},
18 | });
19 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/AllPromptForm/AllPromptForm.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Text, Stack } from "@chakra-ui/react";
2 | import { FormItemComponent } from "../FormItem/FormItemComponent.tsx";
3 | import { isInTopField } from "../MetaBox/MetadataForm.tsx";
4 | import { useCallback, useContext, useEffect, useState } from "react";
5 | import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts";
6 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
7 |
8 | export default function AllPromptForm() {
9 | const { topFields, calcInputList, showNodeName } = useContext(MetaBoxContext);
10 |
11 | const groupInputsByNodeType = useCallback(
12 | (inputList: PromptNodeInputItem[]) => {
13 | const groupedInputs: PromptNodeInputItem[][] = [];
14 | inputList.forEach((input) => {
15 | const lastGroup = groupedInputs[groupedInputs.length - 1];
16 | if (
17 | !lastGroup ||
18 | lastGroup[0].nodeID !== input.nodeID ||
19 | lastGroup[0].classType !== input.classType
20 | ) {
21 | groupedInputs.push([input]);
22 | } else {
23 | lastGroup.push(input);
24 | }
25 | });
26 | return groupedInputs;
27 | },
28 | [],
29 | );
30 |
31 | useEffect(() => {}, [showNodeName]);
32 |
33 | if (!showNodeName) {
34 | return (
35 |
36 | {calcInputList.map((input) => {
37 | if (
38 | isInTopField(topFields, {
39 | name: input.inputName,
40 | promptKey: input.nodeID,
41 | classType: input.classType,
42 | })
43 | ) {
44 | return null;
45 | }
46 | return (
47 |
51 | );
52 | })}
53 |
54 | );
55 | }
56 | const nodes = groupInputsByNodeType(calcInputList);
57 | return (
58 |
59 | {nodes.map((nodeInputs) => {
60 | if (!nodeInputs[0]) {
61 | return null;
62 | }
63 | return (
64 |
68 |
69 | {nodeInputs.map((input) => {
70 | if (
71 | isInTopField(topFields, {
72 | name: input.inputName,
73 | promptKey: input.nodeID,
74 | classType: input.classType,
75 | })
76 | ) {
77 | return null;
78 | }
79 |
80 | return (
81 |
85 | );
86 | })}
87 |
88 |
89 | );
90 | })}
91 |
92 | );
93 | }
94 |
95 | function CustomAccordionPanel({
96 | title,
97 | children,
98 | }: {
99 | title: string;
100 | children: React.ReactNode;
101 | }) {
102 | return (
103 |
104 |
105 | {title}
106 |
107 | {children}
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/CheckboxBase.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { Checkbox, Flex } from "@chakra-ui/react";
3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
4 |
5 | export const CheckboxBase: FC<
6 | {
7 | inputItem: PromptNodeInputItem;
8 | } & {
9 | [key in string]: any;
10 | }
11 | > = (props) => {
12 | const inputItem = props.inputItem;
13 | return (
14 |
15 |
16 | {inputItem.label ?? inputItem.inputName}
17 |
18 | {
21 | props?.updateMetaData?.({
22 | promptKey: props.promptKey,
23 | name: props.name,
24 | value: e.target.checked,
25 | });
26 | }}
27 | />
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/InputBase.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { Flex, Input } from "@chakra-ui/react";
3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
4 |
5 | export const InputBase: FC<
6 | {
7 | inputItem: PromptNodeInputItem;
8 | } & {
9 | [key in string]: any;
10 | }
11 | > = ({ inputItem }) => {
12 | return (
13 |
14 |
15 | {inputItem.label ?? inputItem.inputName}
16 |
17 | {}} />
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/InputSlider.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import {
3 | Flex,
4 | NumberDecrementStepper,
5 | NumberIncrementStepper,
6 | NumberInput,
7 | NumberInputField,
8 | NumberInputStepper,
9 | Slider,
10 | SliderFilledTrack,
11 | SliderThumb,
12 | SliderTrack,
13 | } from "@chakra-ui/react";
14 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
15 |
16 | export const InputSlider: FC<
17 | {
18 | inputItem: PromptNodeInputItem;
19 | } & {
20 | [key in string]: any;
21 | }
22 | > = (props) => {
23 | const inputItem = props.inputItem;
24 | return (
25 |
26 |
27 |
28 | {inputItem.label ?? inputItem.inputName}
29 |
30 | {}}
37 | >
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {}}
53 | >
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/NoSupport.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { Flex } from "@chakra-ui/react";
3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
4 |
5 | export const NoSupport: FC<{ inputItem: PromptNodeInputItem }> = (props) => {
6 | return (
7 |
8 |
9 | {props.inputItem.label ?? props.inputItem.inputName}
10 |
11 | No Support
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/SelectBase.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { Flex, Select } from "@chakra-ui/react";
3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
4 |
5 | export const SelectBase: FC<
6 | {
7 | inputItem: PromptNodeInputItem;
8 | } & {
9 | [key in string]: any;
10 | }
11 | > = (props) => {
12 | const { inputItem } = props;
13 | return (
14 |
15 |
16 | {inputItem.label ?? inputItem.inputName}
17 |
18 | {}}>
19 | {props?.options?.map((v: string, i: number) => (
20 |
24 | {v}
25 |
26 | ))}
27 |
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/TextareaBase.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { Flex, Textarea } from "@chakra-ui/react";
3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts";
4 |
5 | export const TextareaBase: FC<
6 | {
7 | inputItem: PromptNodeInputItem;
8 | } & {
9 | [key in string]: any;
10 | }
11 | > = (props) => {
12 | const inputItem = props.inputItem;
13 | return (
14 |
15 | {inputItem.label ?? inputItem.inputName}
16 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/FormItem/types.ts:
--------------------------------------------------------------------------------
1 | export enum FormItemType {
2 | Input = "Input",
3 | InputSlider = "InputSlider",
4 | Select = "Select",
5 | Textarea = "Textarea",
6 | Checkbox = "Checkbox",
7 | NoSupport = "NoSupport",
8 | }
9 |
10 | export type FormItem = {
11 | type?: FormItemType;
12 | name: string;
13 | label?: string;
14 | value: string | number;
15 | onChange?: (val: any) => void;
16 | classType: string;
17 | promptKey: string | number;
18 | } & {
19 | [key in string]: any;
20 | };
21 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/GalleryCarouselImageViewer.tsx:
--------------------------------------------------------------------------------
1 | import { Media } from "../../types/dbTypes.ts";
2 | import { useContext } from "react";
3 | import { Box, Flex, Grid } from "@chakra-ui/react";
4 | import Carousel from "../../components/Carousel/Carousel.tsx";
5 | import { GalleryRightCol } from "./GalleryRightCol.tsx";
6 | import MediaPreview from "../../components/MediaPreview.tsx";
7 | import { mediaTable } from "../../db-tables/WorkspaceDB.ts";
8 | import { GalleryContext } from "../GalleryContext.ts";
9 |
10 | interface MetaDataInfoProps {
11 | mediaList: Media[];
12 | }
13 | const GALLERY_IMAGE_SIZE = 120;
14 | export function GalleryCarouselImageViewer({ mediaList }: MetaDataInfoProps) {
15 | const { curMedia, setCurMedia } = useContext(GalleryContext);
16 |
17 | return (
18 |
19 |
24 |
25 | ({
27 | id: v.id,
28 | imageUrl: `/workspace/view_media?filename=${v.localPath}`,
29 | }))}
30 | currentNum={mediaList?.findIndex((p) => p.id === curMedia?.id) ?? 0}
31 | setMediaAct={(newMedia) =>
32 | setCurMedia(mediaList?.find((v) => v.id === newMedia.id) ?? null)
33 | }
34 | />
35 |
36 |
37 | {mediaList?.map((media) => (
38 | setCurMedia(media)}
48 | >
49 | {
55 | mediaTable?.delete(media.id);
56 | }}
57 | />
58 |
59 | ))}
60 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/GalleryGridView.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useState } from "react";
2 | import GalleryMediaItem from "./GalleryMediaItem";
3 | import { HStack } from "@chakra-ui/react";
4 | import type { Media } from "../../types/dbTypes";
5 | import { mediaTable } from "../../db-tables/WorkspaceDB";
6 | import { GalleryContext } from "../GalleryContext";
7 |
8 | export default function GalleryGridView({
9 | searchQuery,
10 | }: {
11 | searchQuery: string;
12 | }) {
13 | const [medias, setMedias] = useState([]);
14 | const { setCurMedia, setShowAllImages, setMediaList } =
15 | useContext(GalleryContext);
16 | useEffect(() => {
17 | if (searchQuery === "") {
18 | mediaTable?.listAll().then((data) => {
19 | setMedias(data.slice(0, 50) ?? []);
20 | });
21 | } else {
22 | mediaTable
23 | ?.filter((v) => v.workflowJSON?.includes(searchQuery) ?? false)
24 | .then((data) => {
25 | setMedias(data);
26 | });
27 | }
28 | }, [searchQuery]);
29 |
30 | const onClickMedia = (media: Media) => {
31 | setMediaList([media].concat(medias.filter((v) => v.id !== media.id)));
32 | setCurMedia(media);
33 | setShowAllImages(false);
34 | };
35 | return (
36 |
37 | {medias.map((media) => {
38 | return (
39 |
46 | );
47 | })}
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/GalleryRightCol.tsx:
--------------------------------------------------------------------------------
1 | import { MetaData } from "../utils.ts";
2 | import { Media } from "../../types/dbTypes.ts";
3 | import { Flex } from "@chakra-ui/react";
4 | import MetadataForm from "./MetaBox/MetadataForm.tsx";
5 | import { GalleryRightColHeaderButtons } from "./GalleryRightColHeaderButtons.tsx";
6 |
7 | export type MediaWithMetaData = Media & {
8 | metaData?: MetaData;
9 | };
10 | export const GalleryRightCol = ({ media }: { media?: MediaWithMetaData }) => {
11 | return (
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/MetaBox/MetadataForm.tsx:
--------------------------------------------------------------------------------
1 | import { getMetadataFromUrl } from "../../utils.ts";
2 | import { Flex, HStack, Switch } from "@chakra-ui/react";
3 | import { Media } from "../../../types/dbTypes.ts";
4 | import TopForm from "../TopForm/TopForm.tsx";
5 | import AllPromptForm from "../AllPromptForm/AllPromptForm.tsx";
6 | import { useEffect, useState } from "react";
7 | import { FormItem } from "../FormItem/types.ts";
8 | import {
9 | calcInputListRecursive,
10 | ImagePrompt,
11 | PromptNodeInputItem,
12 | } from "./utils.ts";
13 | import { MetaBoxContext } from "./metaBoxContext.ts";
14 | import { app } from "../../../utils/comfyapp.ts";
15 | export type TopFieldType = {
16 | promptKey: string | number;
17 | class_type?: string;
18 | name: string;
19 | };
20 |
21 | export const isInTopField = (
22 | topFields: TopFieldType[],
23 | item: Pick,
24 | ) => {
25 | return topFields?.some(
26 | (top) =>
27 | top.promptKey === item?.promptKey &&
28 | top.name === item?.name &&
29 | top.class_type === item.classType,
30 | );
31 | };
32 |
33 | export default function MetadataForm({ media }: { media: Media | null }) {
34 | const [calcInputList, setCalcInputList] = useState([]);
35 | const [imagePrompt, setImagePrompt] = useState();
36 | const [showAllInputs, setShowAllInputs] = useState(true);
37 | const [showNodeName, setShowNodeName] = useState(true);
38 |
39 | useEffect(() => {
40 | if (media) {
41 | getMetadataFromUrl(
42 | `/workspace/view_media?filename=${media.localPath}`,
43 | ).then((data) => {
44 | setImagePrompt(data.prompt);
45 | });
46 | } else {
47 | app
48 | .graphToPrompt(app.graph)
49 | .then((data: { output: any; workflow: any }) => {
50 | setImagePrompt(data.output);
51 | });
52 | }
53 | }, [media]);
54 |
55 | useEffect(() => {
56 | if (!imagePrompt) return;
57 | const calcInput = calcInputListRecursive(imagePrompt);
58 | setCalcInputList(calcInput);
59 | }, [imagePrompt]);
60 |
61 | const [topFields, setTopFields] = useState([]);
62 |
63 | const updateTopField = (field: TopFieldType) => {
64 | if (
65 | isInTopField(topFields, {
66 | name: field.name,
67 | promptKey: field?.promptKey,
68 | classType: field?.class_type ?? "",
69 | })
70 | ) {
71 | const topFieldsConfig = topFields.filter(
72 | (v) => v.name !== field.name || v.promptKey !== field.promptKey,
73 | );
74 | setTopFields(topFieldsConfig);
75 | } else {
76 | const topFieldsConfig = [...topFields, field];
77 | setTopFields(topFieldsConfig);
78 | }
79 | };
80 |
81 | return (
82 |
90 |
91 |
92 |
93 | Show all inputs
94 | setShowAllInputs(!showAllInputs)}
97 | />
98 | Show node names
99 | setShowNodeName(!showNodeName)}
102 | />
103 |
104 | {showAllInputs && }
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/MetaBox/metaBoxContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import { TopFieldType } from "./MetadataForm.tsx";
3 | import { PromptNodeInputItem } from "./utils.ts";
4 |
5 | interface MetaBoxContextProps {
6 | topFields: TopFieldType[];
7 | updateTopField?: (field: TopFieldType) => void;
8 | calcInputList: PromptNodeInputItem[];
9 | showNodeName: boolean;
10 | // updateInputValue: (input: PromptNodeInputItem) => void;
11 | }
12 |
13 | export const MetaBoxContext = createContext({
14 | topFields: [],
15 | calcInputList: [],
16 | showNodeName: false,
17 | updateTopField(): void {},
18 | // updateInputValue(): void {},
19 | });
20 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/MetaBox/utils.ts:
--------------------------------------------------------------------------------
1 | type MetaValue = string | number | null | any[];
2 |
3 | export type ImagePrompt = {
4 | [key: string | number]: ImagePromptNodeItem;
5 | };
6 | export type ImagePromptNodeItem = {
7 | class_type: string;
8 | inputs: {
9 | [key: string]: MetaValue | any;
10 | };
11 | nodeID?: string;
12 | children?: string[]; // output links to other node e.g. CLIPTextEncode -> KSampler
13 | };
14 | export type InputResultItem = {
15 | class_type: string;
16 | name: string;
17 | linkId: string;
18 | value: MetaValue;
19 | path: InputResultItem[];
20 | inputInfo: any;
21 | isTop?: boolean;
22 | formLabel?: string;
23 | };
24 |
25 | export type PromptNodeInputItem = {
26 | classType: string; // nodeType
27 | inputName: string;
28 | inputValue: any;
29 | label?: string;
30 | nodeID: string;
31 | children: string[]; // output links to other node e.g. CLIPTextEncode.text -> KSampler.negative
32 | };
33 |
34 | let inputList: PromptNodeInputItem[] = [];
35 | let visitedIDs = new Set();
36 |
37 | function dfs(promptNode: ImagePromptNodeItem, prompt: ImagePrompt) {
38 | if (visitedIDs.has(promptNode.nodeID!)) {
39 | return;
40 | }
41 | visitedIDs.add(promptNode.nodeID!);
42 | Object.entries(promptNode.inputs).forEach(([_inputName, value]) => {
43 | if (Array.isArray(value)) {
44 | const parentID = value[0];
45 | dfs(prompt[parentID], prompt);
46 | }
47 | });
48 | Object.entries(promptNode.inputs).forEach(([inputName, value]) => {
49 | if (!Array.isArray(value)) {
50 | inputList.push({
51 | classType: promptNode.class_type,
52 | inputName,
53 | inputValue: value,
54 | nodeID: promptNode.nodeID!,
55 | children: promptNode.children || [],
56 | });
57 | }
58 | });
59 | return inputList;
60 | }
61 |
62 | export function calcInputListRecursive(
63 | prompt: ImagePrompt,
64 | ): PromptNodeInputItem[] {
65 | inputList = []; // clear result inputlist
66 | visitedIDs = new Set(); // clear visited nodes
67 | for (const key of Object.keys(prompt)) {
68 | prompt[key].nodeID = key;
69 | Object.entries(prompt[key].inputs).forEach(([inputName, value]) => {
70 | if (Array.isArray(value)) {
71 | const parentID = value[0];
72 | const parentNode = prompt[parentID];
73 | if (!parentNode.children) {
74 | parentNode.children = [];
75 | }
76 | // record children output links
77 | parentNode.children.push(inputName);
78 | }
79 | });
80 | }
81 |
82 | for (const key of Object.keys(prompt)) {
83 | dfs(prompt[key], prompt);
84 | }
85 | return inputList;
86 | }
87 |
--------------------------------------------------------------------------------
/ui/src/gallery/components/TopForm/TopForm.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@chakra-ui/react";
2 | import { FormItemComponent } from "../FormItem/FormItemComponent.tsx";
3 | import { useContext } from "react";
4 | import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts";
5 |
6 | export default function TopForm() {
7 | const { topFields, updateTopField, calcInputList } =
8 | useContext(MetaBoxContext);
9 | if (topFields.length === 0) return null;
10 |
11 | return (
12 | <>
13 |
14 | {topFields?.map((field) => {
15 | const input = calcInputList.find(
16 | (input) =>
17 | input.nodeID == field.promptKey &&
18 | input.inputName === field.name &&
19 | input.classType === field.class_type,
20 | );
21 | if (!field.class_type || !input) {
22 | return null;
23 | }
24 | return (
25 |
29 | );
30 | })}
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/ui/src/gallery/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getPngMetadata,
3 | getVideoMetadata,
4 | getWebpMetadata,
5 | isVideoName,
6 | } from "../utils/mediaMetadataUtils.ts";
7 | import { FC } from "react";
8 | import { Media } from "../types/dbTypes.ts";
9 |
10 | export type MetaData = { prompt: T; workflow: T };
11 |
12 | export type MetaBoxTypeCom = FC<{ metaData: MetaData; media: Media }>;
13 |
14 | export function getMetadataFromUrl(url: string) {
15 | const extension = url.split(".").pop();
16 | return fetch(url)
17 | .then((response) => {
18 | if (!response.ok) {
19 | throw new Error("Network response was not ok");
20 | }
21 | return response.blob();
22 | })
23 | .then(async (blob) => {
24 | const fileName = url.split("/").pop() ?? "";
25 | const fileObj = new File([blob], fileName);
26 | if (isVideoName(url)) {
27 | return getVideoMetadata(fileObj) as Promise;
28 | }
29 | if (extension === "webp") {
30 | return getWebpMetadata(fileObj) as Promise;
31 | }
32 | const metaData = (await getPngMetadata(fileObj)) as MetaData;
33 | const prompt = JSON.parse(metaData?.prompt);
34 | const workflow = JSON.parse(metaData?.workflow);
35 | return {
36 | prompt,
37 | workflow,
38 | } as MetaData;
39 | });
40 | }
41 |
42 | export const ifString = (t: any) => {
43 | if (typeof t === "string") {
44 | return t;
45 | }
46 | };
47 |
48 | export function clipboard(text: string) {
49 | navigator.clipboard
50 | .writeText(text)
51 | .then(function () {})
52 | .catch(function (err) {
53 | console.error("Unable to copy to clipboard: ", err);
54 | });
55 | }
56 |
57 | export function getNodesInfo() {
58 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
59 | // @ts-expect-error
60 | return LiteGraph.registered_node_types;
61 | }
62 |
--------------------------------------------------------------------------------
/ui/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | height: 100%;
28 | }
29 |
30 | h1 {
31 | font-size: 3.2em;
32 | line-height: 1.1;
33 | }
34 |
35 | @media (prefers-color-scheme: light) {
36 | :root {
37 | color: #213547;
38 | background-color: #ffffff;
39 | }
40 | a:hover {
41 | color: #747bff;
42 | }
43 | button {
44 | background-color: #f9f9f9;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/ui/src/model-manager/ManagerContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import { Route } from "./types";
3 |
4 | export const ManagerContext = createContext<{
5 | setRoute: (route: Route) => void;
6 | }>({
7 | setRoute: () => {},
8 | });
9 |
--------------------------------------------------------------------------------
/ui/src/model-manager/api/modelsApi.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error ComfyUI imports
2 | import { api } from "/scripts/api.js";
3 |
4 | export type InstallModelsApiInput = {
5 | save_path: string;
6 | filename: string;
7 | url: string;
8 | file_hash?: string;
9 | force_filename?: boolean; // true to use filename as downloaded name, false to fetch from the response header
10 | };
11 | let cancelInstall = false;
12 |
13 | export const setCancelInstall = (value: boolean) => {
14 | cancelInstall = value;
15 | };
16 | export const installModelsApi = async (target: InstallModelsApiInput) => {
17 | try {
18 | const response = await api.fetchApi("/model_manager/install_model", {
19 | method: "POST",
20 | headers: { "Content-Type": "application/json" },
21 | body: JSON.stringify(target),
22 | });
23 | // ****NON Streaming version*****
24 | const text = await response.text();
25 | window.dispatchEvent(
26 | new CustomEvent("model_install_message", {
27 | detail: text,
28 | }),
29 | );
30 | } catch (error) {
31 | console.error("Failed to connect to the server:", error);
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/ui/src/model-manager/comfy-types/comfy.d.ts:
--------------------------------------------------------------------------------
1 | import { LGraphNode, IWidget } from "./litegraph";
2 | import { ComfyApp } from "../../scripts/app";
3 |
4 | export interface ComfyExtension {
5 | /**
6 | * The name of the extension
7 | */
8 | name: string;
9 | /**
10 | * Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
11 | * @param app The ComfyUI app instance
12 | */
13 | init?(app: ComfyApp): Promise;
14 | /**
15 | * Allows any additonal setup, called after the application is fully set up and running
16 | * @param app The ComfyUI app instance
17 | */
18 | setup?(app: ComfyApp): Promise;
19 | /**
20 | * Called before nodes are registered with the graph
21 | * @param defs The collection of node definitions, add custom ones or edit existing ones
22 | * @param app The ComfyUI app instance
23 | */
24 | addCustomNodeDefs?(
25 | defs: Record,
26 | app: ComfyApp,
27 | ): Promise;
28 | /**
29 | * Allows the extension to add custom widgets
30 | * @param app The ComfyUI app instance
31 | * @returns An array of {[widget name]: widget data}
32 | */
33 | getCustomWidgets?(
34 | app: ComfyApp,
35 | ): Promise<
36 | Record<
37 | string,
38 | (
39 | node,
40 | inputName,
41 | inputData,
42 | app,
43 | ) => { widget?: IWidget; minWidth?: number; minHeight?: number }
44 | >
45 | >;
46 | /**
47 | * Allows the extension to add additional handling to the node before it is registered with LGraph
48 | * @param nodeType The node class (not an instance)
49 | * @param nodeData The original node object info config object
50 | * @param app The ComfyUI app instance
51 | */
52 | beforeRegisterNodeDef?(
53 | nodeType: typeof LGraphNode,
54 | nodeData: ComfyObjectInfo,
55 | app: ComfyApp,
56 | ): Promise;
57 | /**
58 | * Allows the extension to register additional nodes with LGraph after standard nodes are added
59 | * @param app The ComfyUI app instance
60 | */
61 | registerCustomNodes?(app: ComfyApp): Promise;
62 | /**
63 | * Allows the extension to modify a node that has been reloaded onto the graph.
64 | * If you break something in the backend and want to patch workflows in the frontend
65 | * This is the place to do this
66 | * @param node The node that has been loaded
67 | * @param app The ComfyUI app instance
68 | */
69 | loadedGraphNode?(node: LGraphNode, app: ComfyApp);
70 | /**
71 | * Allows the extension to run code after the constructor of the node
72 | * @param node The node that has been created
73 | * @param app The ComfyUI app instance
74 | */
75 | nodeCreated?(node: LGraphNode, app: ComfyApp);
76 | }
77 |
78 | export type ComfyObjectInfo = {
79 | name: string;
80 | display_name?: string;
81 | description?: string;
82 | category: string;
83 | input?: {
84 | required?: Record;
85 | optional?: Record;
86 | };
87 | output?: string[];
88 | output_name: string[];
89 | };
90 |
91 | export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any];
92 |
--------------------------------------------------------------------------------
/ui/src/model-manager/components/Overlay.tsx:
--------------------------------------------------------------------------------
1 | export function Overlay({
2 | backgroundColor,
3 | }: {
4 | backgroundColor?: string | null;
5 | }) {
6 | const bg =
7 | backgroundColor === undefined
8 | ? "rgba(0,0,0,0.5)"
9 | : backgroundColor ?? undefined;
10 | return (
11 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/ui/src/model-manager/hooks/useDebaunce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useDebounce = (value: T, delay: number): T => {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(handler);
13 | };
14 | }, [value, delay]);
15 |
16 | return debouncedValue;
17 | };
18 |
--------------------------------------------------------------------------------
/ui/src/model-manager/hooks/useUpdateModels.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { getAllModelsList } from "../../Api";
3 | import type { ModelsListRespItem, ModelsListRespItemFromApi } from "../types";
4 | // @ts-ignore
5 | import { api } from "/scripts/api.js";
6 |
7 | export const useUpdateModels = () => {
8 | // all model types
9 | const [modelTypeList, setModelTypeList] = useState(["checkpoints"]);
10 |
11 | // all models
12 | const [modelsList, setModelsList] = useState([]);
13 |
14 | // loading status
15 | const [loading, setLoading] = useState(true);
16 |
17 | useEffect(() => {
18 | initData();
19 | api.addEventListener(
20 | "model_list",
21 | async (e: { detail: ModelsListRespItemFromApi[] }) => {
22 | updateModels(e.detail);
23 | },
24 | );
25 | }, []);
26 |
27 | const initData = async () => {
28 | const file_list = await getAllModelsList();
29 | updateModels(file_list);
30 | };
31 |
32 | const updateModels = async (file_list?: ModelsListRespItemFromApi[]) => {
33 | if (!file_list) return;
34 | setLoading(false);
35 | const modelTypeList = Array.from(
36 | new Set(file_list.map((item) => item.model_type)),
37 | );
38 | // checkpoints must be in first
39 | const index = modelTypeList.indexOf("checkpoints");
40 | if (index >= 0) {
41 | modelTypeList.splice(index, 1);
42 | }
43 | modelTypeList.unshift("checkpoints");
44 | setModelTypeList(modelTypeList);
45 | setModelsList(
46 | file_list.map((item) => ({ ...item, date: new Date(item.date * 1000) })),
47 | );
48 | };
49 |
50 | return {
51 | modelTypeList,
52 | modelsList,
53 | loading,
54 | };
55 | };
56 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/AddApiKeyPopover.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Popover,
3 | PopoverTrigger,
4 | PopoverContent,
5 | PopoverArrow,
6 | PopoverCloseButton,
7 | useDisclosure,
8 | Button,
9 | Input,
10 | Stack,
11 | Text,
12 | } from "@chakra-ui/react";
13 | import { useState } from "react";
14 | import { setCivitApiKey } from "../../utils/civitUtils";
15 |
16 | export default function AddApiKeyPopover() {
17 | const [apiKeyInput, setApiKeyInput] = useState("");
18 | const { onOpen, onClose, isOpen } = useDisclosure();
19 |
20 | const saveApiKey = () => {
21 | setCivitApiKey(apiKeyInput);
22 | onClose();
23 | };
24 |
25 | return (
26 |
33 |
34 |
35 | Set Civitai API Key
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | Some Civitai.com models require user login to download, you will
44 | nedd a Civitai API key to download in that case
45 |
46 | setApiKeyInput(e.target.value)}
49 | placeholder="API Key"
50 | />
51 |
52 | Save
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/ChooseFolder.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | AlertDialog,
4 | AlertDialogOverlay,
5 | AlertDialogContent,
6 | AlertDialogHeader,
7 | AlertDialogBody,
8 | AlertDialogFooter,
9 | Select,
10 | Text,
11 | Input,
12 | Stack,
13 | } from "@chakra-ui/react";
14 | import { useEffect, useRef, useState } from "react";
15 | import { getAllFoldersList } from "../../Api";
16 |
17 | interface ChooseFolderProps {
18 | isOpen: boolean;
19 | fileSelected?: boolean;
20 | onClose: () => void;
21 | selectFolder: (folderPath: string, url: string) => void;
22 | }
23 | export default function ChooseFolder({
24 | isOpen,
25 | fileSelected,
26 | onClose,
27 | selectFolder,
28 | }: ChooseFolderProps) {
29 | const [folderPath, setFolderPath] = useState("");
30 | const [foldersList, setFoldersList] = useState([]);
31 | const [url, setUrl] = useState("");
32 | const cancelRef = useRef(null);
33 |
34 | useEffect(() => {
35 | initData();
36 | }, []);
37 |
38 | const initData = async () => {
39 | const folders_list = await getAllFoldersList();
40 | if (folders_list) {
41 | ["custom_nodes", "config", "saved_prompts"].forEach((folder) => {
42 | delete folders_list[folder];
43 | });
44 | setFoldersList(Object.values(folders_list).flatMap((folder) => folder));
45 | }
46 | };
47 |
48 | return (
49 |
54 |
55 |
56 |
57 | Choose Folder
58 |
59 |
60 |
61 |
62 | {!fileSelected && (
63 | <>
64 | Model download url
65 | setUrl(e.target.value)}
68 | value={url}
69 | />
70 | >
71 | )}
72 | Choose model install folder
73 | setFolderPath(e.target.value)}
77 | >
78 | {foldersList.map((folderPath) => (
79 |
80 | {folderPath}
81 |
82 | ))}
83 |
84 |
85 |
86 |
87 |
88 |
89 | Cancel
90 |
91 | selectFolder(folderPath, url)}
93 | ml={3}
94 | isDisabled={url.length === 0}
95 | >
96 | Confirm
97 |
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/InstallModelSearchBar.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Flex, Input } from "@chakra-ui/react";
2 |
3 | export default function InstallModelSearchBar({
4 | searchQuery,
5 | setSearchQuery,
6 | onSearch,
7 | }: {
8 | searchQuery: string;
9 | setSearchQuery: (query: string) => void;
10 | onSearch: () => void;
11 | }) {
12 | return (
13 |
14 | setSearchQuery(e.target.value)}
19 | onKeyUp={(e) => {
20 | e.code === "Enter" && onSearch();
21 | }}
22 | />
23 | onSearch()} colorScheme="teal">
24 | Search
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/InstallModelsButton.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { Button } from "@chakra-ui/react";
3 | import { WorkspaceContext } from "../../WorkspaceContext";
4 |
5 | export default function InstallModelsButton() {
6 | const { setRoute } = useContext(WorkspaceContext);
7 | return (
8 | <>
9 | setRoute("installModels")}
13 | >
14 | Install Models
15 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/InstallProgress.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import {
3 | Button,
4 | HStack,
5 | Progress,
6 | Stack,
7 | Text,
8 | useColorMode,
9 | useToast,
10 | } from "@chakra-ui/react";
11 | import { useState, useEffect } from "react";
12 | import { cancelDownload } from "../../Api";
13 | import { api } from "../../utils/comfyapp";
14 |
15 | type Queue = { save_path: string; progress: number };
16 |
17 | export default function InstallProgress() {
18 | const { colorMode } = useColorMode();
19 | const toast = useToast();
20 | const [queue, setQueue] = useState([]);
21 |
22 | useEffect(() => {
23 | api.addEventListener("download_progress", (e: { detail: Queue[] }) => {
24 | setQueue(e.detail);
25 | });
26 | api.addEventListener("download_error", (e: { detail: string }) => {
27 | toast({
28 | title: "Download Error",
29 | description: e.detail,
30 | status: "error",
31 | duration: 4000,
32 | isClosable: true,
33 | });
34 | });
35 | }, []);
36 |
37 | return (
38 |
49 | {queue.map(({ save_path, progress }) => (
50 |
51 |
52 | {save_path.replace(/^.*[\\/]/, "")}
53 |
54 |
60 |
61 | {progress.toFixed(1)}%
62 |
63 | cancelDownload(save_path)}
68 | >
69 | Cancel
70 |
71 |
72 | ))}
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/util/getModelFromCivitAPI.ts:
--------------------------------------------------------------------------------
1 | import { indexdb } from "../../../db-tables/indexdb";
2 | import { CivitiModel } from "../../types";
3 | import { CACHE_EXPIRY_DAYS, MODEL_TYPE } from "./modelTypes";
4 |
5 | type CivitModelQueryParams = {
6 | types?: MODEL_TYPE;
7 | query?: string;
8 | limit?: string;
9 | };
10 |
11 | export async function getModelFromCivitAPi(
12 | types?: MODEL_TYPE,
13 | query?: string,
14 | ): Promise {
15 | const params: CivitModelQueryParams = {
16 | limit: "30",
17 | types,
18 | };
19 | if (query) {
20 | params.query = query;
21 | }
22 |
23 | const queryString = new URLSearchParams(params).toString();
24 | const fullURL = `https://civitai.com/api/v1/models?${queryString}`;
25 |
26 | const cacheEntry = await indexdb.cache?.get(fullURL);
27 | if (cacheEntry?.value != null) {
28 | try {
29 | const { data, timestamp } = JSON.parse(cacheEntry?.value);
30 | // Check if cached data is still valid
31 | const ageInDays = (Date.now() - timestamp) / (1000 * 60 * 60 * 24);
32 | if (ageInDays < CACHE_EXPIRY_DAYS) {
33 | return data;
34 | }
35 | } catch (e) {
36 | console.error("err fetching cache", e);
37 | }
38 | }
39 |
40 | const data = await fetch(fullURL);
41 | const json = await data.json();
42 | indexdb.cache.put({
43 | id: fullURL,
44 | value: JSON.stringify({
45 | data: json.items,
46 | timestamp: Date.now(),
47 | }),
48 | });
49 | return json.items;
50 | }
51 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/util/getModelFromSearch.ts:
--------------------------------------------------------------------------------
1 | import { SearchHit, SearchResponse } from "../../civitSearchTypes";
2 | import { MODEL_TYPE } from "./modelTypes";
3 |
4 | export async function getModelFromSearch(
5 | q: string,
6 | type?: MODEL_TYPE,
7 | ): Promise {
8 | const params: any = {
9 | queries: [
10 | {
11 | q: q,
12 | indexUid: "models_v9",
13 | facets: [
14 | "category.name",
15 | "checkpointType",
16 | "fileFormats",
17 | "lastVersionAtUnix",
18 | "tags.name",
19 | "type",
20 | "user.username",
21 | "version.baseModel",
22 | ],
23 | attributesToHighlight: ["*"],
24 | highlightPreTag: "__ais-highlight__",
25 | highlightPostTag: "__/ais-highlight__",
26 | limit: 80,
27 | offset: 0,
28 | },
29 | ],
30 | };
31 | if (type) {
32 | params.queries[0].filter = [[`"type"="${type}"`]];
33 | }
34 | const data = await fetch(import.meta.env.VITE_CMODEL_SEARCH_URL as string, {
35 | headers: {
36 | "Content-Type": "application/json",
37 | Authorization: import.meta.env.VITE_CMODEL_APP_KEY as string,
38 | },
39 | method: "POST",
40 | body: JSON.stringify(params),
41 | });
42 | const json: SearchResponse = (await data.json())?.results?.at(0);
43 | const hits = json.hits ?? [];
44 | return hits;
45 | }
46 |
--------------------------------------------------------------------------------
/ui/src/model-manager/install-models/util/modelTypes.ts:
--------------------------------------------------------------------------------
1 | import { SearchHit, SearchModelVersion } from "../../civitSearchTypes";
2 | import { CivitiModel, CivitiModelVersion } from "../../types";
3 |
4 | export const ALL_MODEL_TYPES = [
5 | "Checkpoint",
6 | "TextualInversion",
7 | "Hypernetwork",
8 | "LORA",
9 | "Controlnet",
10 | "Upscaler",
11 | "VAE",
12 | // "Poses",
13 | // "MotionModule",
14 | // "LoCon",
15 | // "AestheticGradient",
16 | // "Wildcards",
17 | ] as const; // `as const` makes the array readonly and its elements literal types
18 |
19 | export const CACHE_EXPIRY_DAYS = 2;
20 |
21 | // Infer MODEL_TYPE from the ALL_MODEL_TYPES array
22 | export type MODEL_TYPE = (typeof ALL_MODEL_TYPES)[number];
23 | export const MODEL_TYPE_TO_FOLDER_MAPPING: Record = {
24 | Checkpoint: "checkpoints",
25 | TextualInversion: "embeddings",
26 | Hypernetwork: "hypernetworks",
27 | LORA: "loras",
28 | Controlnet: "controlnet",
29 | Upscaler: "upscale_models",
30 | VAE: "vae",
31 | };
32 |
33 | export type FileEssential = {
34 | id: number;
35 | name?: string;
36 | SHA256?: string;
37 | sizeKB?: number;
38 | };
39 |
40 | export type apiResponse = CivitiModel | SearchHit;
41 |
42 | export function isCivitModel(
43 | model: CivitiModel | SearchHit,
44 | ): model is CivitiModel {
45 | return (model as CivitiModel).modelVersions !== undefined;
46 | }
47 |
48 | export function isCivitVersion(
49 | version: CivitiModelVersion | SearchModelVersion,
50 | ): version is CivitiModelVersion {
51 | return (version as CivitiModelVersion).files?.[0] !== undefined;
52 | }
53 |
54 | export function getFileEssential(
55 | version: CivitiModelVersion | SearchModelVersion,
56 | modelName?: string,
57 | ): FileEssential;
58 | export function getFileEssential(
59 | version?: CivitiModelVersion | SearchModelVersion,
60 | modelName?: string,
61 | ): FileEssential | undefined;
62 |
63 | export function getFileEssential(
64 | version?: CivitiModelVersion | SearchModelVersion,
65 | modelName?: string,
66 | ): FileEssential | undefined {
67 | if (!version) return;
68 | let fileData: FileEssential;
69 | if (isCivitVersion(version)) {
70 | fileData = {
71 | id: version.id,
72 | SHA256: version.files?.[0].hashes?.SHA256,
73 | name: version.files?.[0].name,
74 | sizeKB: version.files?.[0]?.sizeKB,
75 | };
76 | } else {
77 | fileData = {
78 | id: version.id,
79 | SHA256: version.hashes?.[2],
80 | name: `${modelName} ${version.name}`,
81 | };
82 | }
83 |
84 | return fileData;
85 | }
86 |
--------------------------------------------------------------------------------
/ui/src/model-manager/models-list-drawer/ModelsList.tsx:
--------------------------------------------------------------------------------
1 | import { Grid, GridItem } from "@chakra-ui/react";
2 | import { ModelsListRespItem } from "../types";
3 | import { ModelItem } from "./ModelItem";
4 |
5 | interface Props {
6 | list: ModelsListRespItem[];
7 | }
8 |
9 | export function ModelsList({ list }: Props) {
10 | return (
11 |
12 | {list.map((v) => (
13 |
14 |
15 |
16 | ))}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/ui/src/model-manager/models-list-drawer/ModelsTags.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Wrap, WrapItem } from "@chakra-ui/react";
2 |
3 | interface Props {
4 | selectedModel: string;
5 | setSelectedModel: (v: string) => void;
6 | modelTypeList: string[];
7 | }
8 |
9 | export function ModelsTags({
10 | selectedModel,
11 | setSelectedModel,
12 | modelTypeList,
13 | }: Props) {
14 | const clickHanlder = (v: string) => {
15 | setSelectedModel(v);
16 | };
17 |
18 | return (
19 |
20 | {modelTypeList.map((v) => (
21 |
22 | clickHanlder(v)}
26 | size={"sm"}
27 | >
28 | {v}
29 |
30 |
31 | ))}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/ui/src/model-manager/topbar/InstallMissingModelsButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@chakra-ui/react";
2 | // @ts-ignore
3 | import { api } from "/scripts/api.js";
4 | import { useEffect, useState } from "react";
5 | import MissingModelsListDrawer, {
6 | MissingModel,
7 | } from "../missing-models-drawer/MissingModelsListDrawer";
8 | import { COMFYSPACE_TRACKING_FIELD_NAME } from "../../const";
9 | import { app } from "../../utils/comfyapp";
10 | interface Props {}
11 |
12 | interface NodeError {
13 | errors: Array<{
14 | type: string;
15 | message: string;
16 | details: string;
17 | extra_info: {
18 | input_name: string;
19 | input_config: string[][];
20 | received_value: string;
21 | };
22 | }>;
23 | dependent_outputs: string[];
24 | class_type: string;
25 | }
26 |
27 | export default function InstallMissingModelsButton({}: Props) {
28 | const [showMyModels, setShowMyModels] = useState(false);
29 | const [missingModels, setMissingModels] = useState([]);
30 | useEffect(() => {
31 | // monkey patch queue prompt api to catch errors
32 | const queuePrompt = app.queuePrompt as Function;
33 | app.queuePrompt = async function () {
34 | try {
35 | await queuePrompt.apply(this, arguments);
36 | } finally {
37 | const deps = app.graph.extra?.[COMFYSPACE_TRACKING_FIELD_NAME]?.deps;
38 | if (!deps) {
39 | return;
40 | }
41 | const nodeErrors = (app.lastNodeErrors ?? {}) as Record<
42 | string,
43 | NodeError
44 | >;
45 | setMissingModels(
46 | Object.values(nodeErrors).flatMap((nodeError) =>
47 | nodeError?.errors
48 | ?.filter((error) => error?.type === "value_not_in_list")
49 | ?.map((error) => {
50 | const { input_name, received_value } = error.extra_info;
51 | return {
52 | class_type: nodeError.class_type,
53 | input_name,
54 | received_value,
55 | };
56 | }),
57 | ),
58 | );
59 | }
60 | };
61 | }, []);
62 | if (missingModels.length === 0) {
63 | return null;
64 | }
65 | return (
66 | <>
67 | setShowMyModels(true)}
72 | colorScheme="teal"
73 | >
74 | Install Missing ({missingModels.length})
75 |
76 | {showMyModels && (
77 | setShowMyModels(false)}
79 | missingModels={missingModels}
80 | />
81 | )}
82 | >
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/ui/src/model-manager/topbar/ModelDropEventListener.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, DragEvent } from "react";
2 | import { app } from "../../utils/comfyapp";
3 |
4 | export default function ModelDropEventListener() {
5 | const handleModelDrop = async (
6 | e: DragEvent & { canvasX: number; canvasY: number },
7 | ) => {
8 | const eventName = e.dataTransfer.getData("eventName");
9 | if (eventName !== "WorkspaceManagerAddNode") {
10 | return;
11 | }
12 | const modelRelativePath = e.dataTransfer.getData("modelRelativePath");
13 | const nodeType = e.dataTransfer.getData("nodeType");
14 | // @ts-ignore
15 | const node = LiteGraph.createNode(nodeType);
16 | node.pos = [e.canvasX, e.canvasY];
17 | node.configure({ widgets_values: [modelRelativePath] });
18 | app.graph.add(node);
19 | };
20 |
21 | useEffect(() => {
22 | app.canvasEl.addEventListener("drop", handleModelDrop);
23 | return () => {
24 | app.canvasEl.removeEventListener("drop", handleModelDrop);
25 | };
26 | }, []);
27 |
28 | return null;
29 | }
30 |
--------------------------------------------------------------------------------
/ui/src/model-manager/topbar/ModelManagerTopbar.tsx:
--------------------------------------------------------------------------------
1 | import { Button, DarkMode, Stack } from "@chakra-ui/react";
2 | import { lazy, useEffect, useContext, Suspense } from "react";
3 | import ModelsListDrawer from "../models-list-drawer/ModelsListDrawer";
4 | import "./index.css";
5 | import { WorkspaceContext } from "../../WorkspaceContext";
6 | // @ts-ignore
7 | import { api } from "/scripts/api.js";
8 |
9 | import { Model } from "../../types/dbTypes";
10 | import { fetchCivitModelFromHashKey } from "../../utils/civitUtils";
11 | import { indexdb } from "../../db-tables/indexdb";
12 | import type { ModelsListRespItemFromApi } from "../types";
13 | import InatallModelsModal from "../install-models/InstallModelsModal";
14 | import { TOPBAR_BUTTON_HEIGHT } from "../../const";
15 |
16 | export default function ModelManagerTopbar() {
17 | const { setRoute, route } = useContext(WorkspaceContext);
18 |
19 | useEffect(() => {
20 | api.addEventListener(
21 | "model_list",
22 | async (e: { detail: ModelsListRespItemFromApi[] }) => {
23 | const modelsPromises = e.detail?.map(async (item) => {
24 | const existing = await indexdb.models.get(
25 | item.model_name + "@" + item.model_type,
26 | );
27 | // avoid overwriting existing models cuz it may have downloadUrl info
28 | if (existing?.fileHash) return existing;
29 | let newModel: Model = {
30 | id: item.model_name + "@" + item.model_type,
31 | modelName: null,
32 | fileHash: item.file_hash ?? null,
33 | fileFolder: item.model_type,
34 | fileName: item.model_name + item.model_extension,
35 | };
36 | if (!item.file_hash) return newModel;
37 | const json = await fetchCivitModelFromHashKey(item.file_hash);
38 | newModel = {
39 | ...newModel,
40 | modelName: json.modelName ?? null,
41 | civitModelID: json.civitModelID,
42 | civitModelVersionID: json.civitModelVersionID,
43 | imageUrl: json.imageUrl ?? null,
44 | };
45 | return newModel;
46 | });
47 | const models = (await Promise.all(modelsPromises)).filter(
48 | (model) => model != null,
49 | );
50 | // clear first so deleted models can be removed?
51 | await indexdb.models.clear();
52 | await indexdb.models.bulkPut(
53 | models.filter((model) => model != null) as Model[],
54 | );
55 | window.dispatchEvent(new CustomEvent("model_list_updated"));
56 | },
57 | );
58 | }, []);
59 |
60 | return (
61 |
62 | setRoute("modelList")}
69 | px={1}
70 | height={TOPBAR_BUTTON_HEIGHT + "px"}
71 | >
72 | Models
73 |
74 | {route === "modelList" && (
75 | setRoute("root")} />
76 | )}
77 | {route === "installModels" && (
78 | setRoute("root")}
81 | />
82 | )}
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/ui/src/model-manager/topbar/index.css:
--------------------------------------------------------------------------------
1 | .drag-model-manager-top-bar-icon {
2 | visibility: hidden !important;
3 | }
4 | .model-manager-top-bar:hover .drag-model-manager-top-bar-icon {
5 | visibility: visible !important;
6 | }
--------------------------------------------------------------------------------
/ui/src/model-manager/utils.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { deleteFile } from "./Api";
3 | export function formatTimestamp(
4 | unixTimestamp: number,
5 | showSec: boolean = false,
6 | ) {
7 | // Create a new Date object from the UNIX timestamp
8 | const date = new Date(unixTimestamp);
9 |
10 | // Get the day, month, year, hours, and minutes from the Date object
11 | const day = String(date.getDate()).padStart(2, "0");
12 | const month = String(date.getMonth() + 1).padStart(2, "0"); // Month is 0-indexed
13 | const year = date.getFullYear();
14 | const hours = String(date.getHours()).padStart(2, "0");
15 | const minutes = String(date.getMinutes()).padStart(2, "0");
16 | const seconds = String(date.getSeconds()).padStart(2, "0");
17 | // Format the date and time string
18 | const res = `${month}-${day}-${year} ${hours}:${minutes}`;
19 | if (showSec) {
20 | return res + `:${seconds}`;
21 | }
22 | return res;
23 | }
24 |
25 | export function KBtoGB(kilobytes: number, decimalPlaces: number = 1) {
26 | const KB_IN_MB = 1024;
27 | const MB_IN_GB = 1024;
28 | // Convert KB to MB
29 | const sizeInMB = kilobytes / KB_IN_MB;
30 |
31 | // If size is larger than or equal to 1 GB, format it in GB
32 | if (sizeInMB >= MB_IN_GB) {
33 | return (sizeInMB / MB_IN_GB).toFixed(decimalPlaces) + " GB";
34 | }
35 | // Otherwise, format it in MB
36 | else {
37 | return sizeInMB.toFixed(decimalPlaces) + " MB";
38 | }
39 | }
40 |
41 | // fro folder_path.py
42 | export const modelExtensions = [
43 | "ckpt",
44 | "pt",
45 | "bin",
46 | "pth",
47 | "safetensors",
48 | "onnx",
49 | ];
50 |
--------------------------------------------------------------------------------
/ui/src/settings/AutosaveSetting.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, Stack, useToast } from "@chakra-ui/react";
2 | import { useState, useEffect } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 | import { CommonNumberSetting } from "./CommonNumberSetting";
5 |
6 | export default function AutosaveSetting() {
7 | const [checked, setChecked] = useState(false);
8 | const toast = useToast();
9 |
10 | const getSetting = () => {
11 | userSettingsTable?.getSetting("autoSave").then((res) => {
12 | setChecked(!!res);
13 | });
14 | };
15 |
16 | useEffect(() => {
17 | getSetting();
18 | }, []);
19 |
20 | return (
21 |
22 | {
25 | await userSettingsTable?.upsert({ autoSave: e.target.checked });
26 | getSetting();
27 | toast({
28 | title: "Setting saved",
29 | status: "success",
30 | duration: 3000,
31 | isClosable: true,
32 | });
33 | }}
34 | >
35 | Enable auto save workflow
36 |
37 | {checked && (
38 |
42 | )}
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/ui/src/settings/CloudHostSetting.tsx:
--------------------------------------------------------------------------------
1 | import { Input, Stack, useToast, Text, Flex, Button } from "@chakra-ui/react";
2 | import { useEffect, useState } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 |
5 | export default function CloudHostSetting() {
6 | const toast = useToast();
7 | const [text, setText] = useState("");
8 |
9 | const getSettings = () => {
10 | userSettingsTable?.getSetting("cloudHost").then((res) => {
11 | setText(res ?? "");
12 | });
13 | };
14 |
15 | useEffect(() => {
16 | getSettings();
17 | }, []);
18 | const submitChange = async (text: string) => {
19 | text = text.trim();
20 | if (text.endsWith("/")) text = text.slice(0, -1);
21 | await userSettingsTable?.upsert({ cloudHost: text });
22 | getSettings();
23 | toast({
24 | title: "Setting saved. Please refresh to see the changes.",
25 | status: "success",
26 | duration: 4000,
27 | });
28 | };
29 |
30 | return (
31 |
32 |
33 | For enterprise paid user only. Hosting site of the shared workflow.
34 |
35 |
36 | setText(e.target.value)}
39 | onBlur={() => submitChange(text)}
40 | onKeyDown={(e) => {
41 | if (e.key === "Enter") {
42 | submitChange(text);
43 | }
44 | }}
45 | />
46 | {
49 | const cloudHost = userSettingsTable?.defaultSettings.cloudHost!;
50 | setText(cloudHost);
51 | submitChange(cloudHost);
52 | }}
53 | >
54 | Reset
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/ui/src/settings/CommonCheckboxSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, Stack, useToast } from "@chakra-ui/react";
2 | import { useState, useEffect } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 | import { UserSettings } from "../types/dbTypes";
5 |
6 | interface Props {
7 | settingKey: keyof UserSettings;
8 | text: string;
9 | defaultChecked?: boolean;
10 | }
11 |
12 | export default function CommonCheckboxSettings({
13 | settingKey,
14 | text,
15 | defaultChecked = false,
16 | }: Props) {
17 | const [checked, setChecked] = useState(defaultChecked);
18 | const toast = useToast();
19 |
20 | const getSetting = () => {
21 | userSettingsTable?.getSetting(settingKey).then((res) => {
22 | setChecked(!!res);
23 | });
24 | };
25 |
26 | useEffect(() => {
27 | getSetting();
28 | }, []);
29 |
30 | return (
31 |
32 | {
35 | setChecked(e.target.checked);
36 | await userSettingsTable?.upsert({ [settingKey]: e.target.checked });
37 | getSetting();
38 | toast({
39 | title: "Setting saved. Please refresh to see the changes.",
40 | status: "success",
41 | duration: 3000,
42 | isClosable: true,
43 | });
44 | }}
45 | >
46 | {text}
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/ui/src/settings/CommonNumberSetting.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Stack,
3 | Text,
4 | NumberInput,
5 | NumberInputField,
6 | useToast,
7 | } from "@chakra-ui/react";
8 | import { useState, useEffect } from "react";
9 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
10 | import type { UserSettings } from "../types/dbTypes";
11 |
12 | export function CommonNumberSetting({
13 | label,
14 | settingKey,
15 | }: {
16 | label: string;
17 | settingKey: keyof UserSettings;
18 | }) {
19 | const [inputNumber, setInputNumber] = useState(200);
20 | const toast = useToast();
21 | const onInputChange = (newValue: string) => {
22 | if (!newValue.length) {
23 | setInputNumber(0);
24 | return;
25 | }
26 | setInputNumber(parseInt(newValue));
27 | };
28 |
29 | const onBlur = async () => {
30 | if (inputNumber < 0) {
31 | setInputNumber(0);
32 | }
33 | await userSettingsTable?.upsert({
34 | [settingKey]: Number(inputNumber),
35 | });
36 | toast({
37 | title: "Setting saved",
38 | status: "success",
39 | duration: 3000,
40 | isClosable: true,
41 | });
42 | };
43 | const loadCurrentSetting = () => {
44 | userSettingsTable?.getSetting(settingKey).then((res) => {
45 | setInputNumber(res as number);
46 | });
47 | };
48 |
49 | useEffect(() => {
50 | loadCurrentSetting();
51 | }, []);
52 |
53 | return (
54 |
55 | {label}
56 |
57 | onInputChange(e)}
65 | onBlur={() => {
66 | onBlur();
67 | }}
68 | onKeyUp={(e) => {
69 | if (e.key === "Enter") {
70 | onBlur();
71 | }
72 | }}
73 | >
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/ui/src/settings/EnableTwowaySyncConfirm.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Heading, Stack } from "@chakra-ui/react";
2 | import { downloadWorkflowsZip } from "../utils/downloadWorkflowsZip";
3 | import { workflowsTable } from "../db-tables/WorkspaceDB";
4 |
5 | export default function EnableTwoWaySyncConfirm({
6 | myWorkflowsDir,
7 | }: {
8 | myWorkflowsDir: string;
9 | }) {
10 | return (
11 |
12 | 🦄 Upgrade to 2.0 - enable two way sync!
13 | Your workflows will be synced to and from path:
14 |
15 | {myWorkflowsDir}
16 |
17 |
18 | You can manually put workflow files into this folder using File Explorer
19 | to sync them with your workspace. You can change this path anytime in
20 | Settings {">"} Workspace Save Directory
21 |
22 |
23 |
24 | Please download all your workflows before enabling two way sync as a
25 | backup!
26 | {" "}
27 | So you can manually import some workflows into your workspace in case
28 | something unexpected happens.
29 |
30 | {
35 | const workflows = await workflowsTable?.listAll();
36 | downloadWorkflowsZip(workflows ?? []);
37 | }}
38 | >
39 | 👉 Download All My Workflows
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/ui/src/settings/FolderOnTopSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, Stack } from "@chakra-ui/react";
2 | import { useState, useEffect, ChangeEvent, useContext } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 | import { RecentFilesContext } from "../WorkspaceContext";
5 |
6 | export default function FolderOnTopSettings() {
7 | const { setRefreshFolderStamp } = useContext(RecentFilesContext);
8 | const [checked, setChecked] = useState(false);
9 |
10 | const getSettings = () => {
11 | userSettingsTable?.getSetting("foldersOnTop").then((res) => {
12 | setChecked(!!res);
13 | });
14 | };
15 |
16 | const onFolderOnTopChange = (e: ChangeEvent) => {
17 | const state = e.target.checked;
18 | userSettingsTable
19 | ?.upsert({
20 | foldersOnTop: state,
21 | })
22 | .then(() => {
23 | getSettings();
24 | setRefreshFolderStamp(Date.now());
25 | });
26 | };
27 |
28 | useEffect(() => {
29 | getSettings();
30 | }, []);
31 |
32 | return (
33 |
34 |
35 | Folders always on top
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/ui/src/settings/SharekeySetting.tsx:
--------------------------------------------------------------------------------
1 | import { Input, Stack, useToast, Text, Flex, Button } from "@chakra-ui/react";
2 | import { useContext, useEffect, useState } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 | import { WorkspaceContext } from "../WorkspaceContext";
5 | import { saveShareKey } from "../utils/saveShareKey";
6 |
7 | export default function SharekeySetting() {
8 | const toast = useToast();
9 | const { session, updateSession } = useContext(WorkspaceContext);
10 | const [text, setText] = useState("");
11 | const cloudDomain = new URL(userSettingsTable?.settings?.cloudHost!).hostname;
12 |
13 | useEffect(() => {
14 | setText(session?.shareKey ?? "");
15 | }, [session]);
16 | const submitChange = async (text: string) => {
17 | saveShareKey(text);
18 | updateSession({ username: session?.username ?? null, shareKey: text });
19 | toast({
20 | title: "Setting saved. Please refresh to see the changes.",
21 | status: "success",
22 | duration: 4000,
23 | });
24 | };
25 |
26 | return (
27 |
28 | Cloud Backup [Beta]
29 |
30 |
35 | 👉Copy your share key from here
36 | {" "}
37 | and paste it below to start saving workflow versions to cloud at{" "}
38 |
39 | {cloudDomain}
40 |
41 |
42 |
43 |
44 | setText(e.target.value)}
48 | onBlur={() => submitChange(text)}
49 | onKeyDown={(e) => {
50 | if (e.key === "Enter") {
51 | submitChange(text);
52 | }
53 | }}
54 | />
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/ui/src/settings/ShowNsfwModelThumbnailSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox } from "@chakra-ui/react";
2 | import { useEffect, ChangeEvent, useState } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 | import { indexdb } from "../db-tables/indexdb";
5 | import { Model } from "../types/dbTypes";
6 | import { fetchCivitModelFromHashKey } from "../utils/civitUtils";
7 |
8 | export default function ShowNsfwModelThumbnailSettings() {
9 | const [checkedState, setChecked] = useState(false);
10 |
11 | const updateShowThumbnail = () => {
12 | userSettingsTable?.getSetting("showNsfwModelThumbnail").then((res) => {
13 | setChecked(res ?? false);
14 | });
15 | };
16 |
17 | useEffect(() => {
18 | updateShowThumbnail();
19 | }, []);
20 |
21 | const onShowThumbnailsChange = (e: ChangeEvent) => {
22 | const state = e.target.checked;
23 | userSettingsTable
24 | ?.upsert({
25 | showNsfwModelThumbnail: state,
26 | })
27 | .then(() => {
28 | updateShowThumbnail();
29 | reFetchThumbnails();
30 | window.dispatchEvent(new Event("showNsfwModelThumbnail"));
31 | });
32 | };
33 |
34 | const reFetchThumbnails = async () => {
35 | const models = await indexdb.models.toArray();
36 | for (let i = 0; i < models.length; i += 5) {
37 | // fetch 5 at a time, to avoid rate limit
38 | const slice = models.slice(i, i + 5);
39 | await Promise.all(slice.map(getThumbnail));
40 | }
41 | };
42 | const getThumbnail = async (model: Model) => {
43 | try {
44 | if (model.fileHash == null) return;
45 | const json = await fetchCivitModelFromHashKey(model.fileHash);
46 | const imageUrl = json.imageUrl;
47 | indexdb.models.update(model.id, {
48 | imageUrl: imageUrl ?? null,
49 | });
50 | } catch (e) {}
51 | };
52 |
53 | return (
54 |
55 | Show NSFW
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/ui/src/settings/TwoWaySyncSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, Stack, Text } from "@chakra-ui/react";
2 | import { useState, useEffect } from "react";
3 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
4 | import { useDialog } from "../components/AlertDialogProvider";
5 | import EnableTwowaySyncConfirm from "./EnableTwowaySyncConfirm";
6 |
7 | export default function TwoWaySyncSettings() {
8 | const [checked, setChecked] = useState(false);
9 | const [savingDir, setSavingDir] = useState("");
10 | const { showDialog } = useDialog();
11 | const getTwoWaySync = () => {
12 | userSettingsTable?.getSetting("myWorkflowsDir").then((res) => {
13 | setSavingDir(res ?? "undefined");
14 | });
15 | userSettingsTable?.getSetting("twoWaySync").then((res) => {
16 | setChecked(res ?? false);
17 | });
18 | };
19 |
20 | useEffect(() => {
21 | getTwoWaySync();
22 | }, []);
23 | const onTwoWaySyncChange = async (e: any) => {
24 | // setChecked(e.target.checked);
25 | if (!e.target.checked) {
26 | await userSettingsTable?.upsert({ twoWaySync: e.target.checked });
27 | getTwoWaySync();
28 | return;
29 | }
30 | const myWorkflowsDir =
31 | await userSettingsTable?.getSetting("myWorkflowsDir");
32 | showDialog(
33 | ,
36 | [
37 | {
38 | label: "I have downloaded all my workflows and ready to enable",
39 | onClick: async () => {
40 | await userSettingsTable?.upsert({ twoWaySync: true });
41 | getTwoWaySync();
42 | },
43 | colorScheme: "red",
44 | },
45 | ],
46 | );
47 | };
48 |
49 | return (
50 |
51 |
52 | Only for legacy two way sync users to get back their data. [DO NOT
53 | DISABLE]
54 |
55 |
56 | Enable two way sync
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/ui/src/share/ShareDialogWorkflowVersionRadio.tsx:
--------------------------------------------------------------------------------
1 | import { Button, HStack, Radio, Text } from "@chakra-ui/react";
2 | import { WorkflowVersion } from "../types/dbTypes";
3 | import { formatTimestamp } from "../utils";
4 | import { IconCloud, IconExternalLink } from "@tabler/icons-react";
5 |
6 | export default function ShareDialogWorkflowVersionRadio({
7 | version,
8 | cloudHost,
9 | cloudWorkflowID,
10 | }: {
11 | version: WorkflowVersion;
12 | cloudHost: string;
13 | cloudWorkflowID: string | null;
14 | }) {
15 | const ver = version;
16 | const content = (
17 |
18 | {ver.name}
19 | {formatTimestamp(ver.createTime, false)}
20 | {ver.cloudID && cloudWorkflowID && (
21 |
27 | }
32 | rightIcon={ }
33 | >
34 | Shared
35 |
36 |
37 | )}
38 |
39 | );
40 | if (!version.cloudID) {
41 | return (
42 |
43 | {content}
44 |
45 | );
46 | }
47 |
48 | return <>{content}>;
49 | }
50 |
--------------------------------------------------------------------------------
/ui/src/share/SharedTopbarButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, DarkMode, Tag } from "@chakra-ui/react";
2 | import { IconCloud } from "@tabler/icons-react";
3 | import { workflowsTable } from "../db-tables/WorkspaceDB";
4 | import { useContext, useEffect, useState } from "react";
5 | import { WorkspaceContext } from "../WorkspaceContext";
6 | import { WorkflowPrivacy } from "../types/dbTypes";
7 | import { PrivacyLabel, fetchCloudWorkflowPrivacy } from "./shareUtils";
8 |
9 | export function SharedTopbarButton({}) {
10 | const [cloudURL, setCloudURL] = useState();
11 | const [privacy, setPrivacy] = useState();
12 | const { curFlowID } = useContext(WorkspaceContext);
13 |
14 | useEffect(() => {
15 | if (curFlowID) {
16 | workflowsTable?.get(curFlowID).then((flow) => {
17 | if (flow?.cloudID) {
18 | setCloudURL(flow.cloudOrigin + "/workflow/" + flow.cloudID);
19 | fetchCloudWorkflowPrivacy(flow).then((privacy) => {
20 | setPrivacy(privacy);
21 | });
22 | } else {
23 | setCloudURL(undefined);
24 | }
25 | });
26 | }
27 | }, [curFlowID]);
28 | if (!cloudURL) return null;
29 |
30 | return (
31 |
32 |
33 |
34 | {privacy === "PUBLIC" ? "🌐" : privacy === "UNLISTED" ? "🔗" : "🔒"}
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/ui/src/share/shareUtils.tsx:
--------------------------------------------------------------------------------
1 | import { IconLink, IconLock, IconWorld } from "@tabler/icons-react";
2 | import { CustomSelectorOption } from "../components/CustomSelector";
3 | import { Workflow, WorkflowPrivacy } from "../types/dbTypes";
4 | import { app } from "../utils/comfyapp";
5 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
6 |
7 | export function generateRandomKey(length: number) {
8 | // Generate a random array of bytes
9 | const array = new Uint8Array(length);
10 | window.crypto.getRandomValues(array);
11 |
12 | // Convert the bytes to a hex string
13 | return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
14 | "",
15 | );
16 | }
17 |
18 | export const privacyOptions: CustomSelectorOption[] = [
19 | { label: "Private", value: "PRIVATE", icon: },
20 | {
21 | label: "Unlisted, anyone with the link can view",
22 | value: "UNLISTED",
23 | icon: ,
24 | },
25 | { label: "Public", value: "PUBLIC", icon: },
26 | ];
27 |
28 | export const getNodeDefs = () => {
29 | const allNodes = app.graph._nodes;
30 |
31 | const nodeDefs = {};
32 | for (let n of allNodes) {
33 | // @ts-ignore
34 | if (n.type in LiteGraph.registered_node_types) {
35 | const nodeDef = structuredClone(
36 | // @ts-ignore
37 | LiteGraph.registered_node_types[n.type].nodeData,
38 | );
39 | if (nodeDef == null) {
40 | continue;
41 | }
42 | for (let key in nodeDef.input) {
43 | // key = required | optional
44 | const fields = nodeDef.input[key];
45 | for (let fieldKey in fields) {
46 | const keyConfig = fields[fieldKey];
47 | if (Array.isArray(keyConfig[0])) {
48 | // for list input type, do not upload the values to protect user's privacy
49 | keyConfig[0] = ["value_1", "value_2"];
50 | }
51 | }
52 | }
53 | // TODO: js only nodes has no nodeData field, need to handle this....
54 | // @ts-ignore
55 | nodeDefs[n.type] = nodeDef;
56 | }
57 | }
58 | return nodeDefs;
59 | };
60 |
61 | export async function fetchCloudWorkflowPrivacy(
62 | workflow: Workflow,
63 | ): Promise {
64 | const cloudOrigin = userSettingsTable?.settings?.cloudHost;
65 | return fetch(cloudOrigin + `/api/getWorkflow?id=${workflow.cloudID}`)
66 | .then((res) => res.json())
67 | .then((json) => {
68 | const privacy = json.data?.privacy;
69 | return privacy ?? "PRIVATE";
70 | });
71 | }
72 |
73 | export function PrivacyLabel({
74 | privacy,
75 | showEmoji = false,
76 | }: {
77 | privacy: WorkflowPrivacy;
78 | showEmoji?: boolean;
79 | verboseText?: boolean;
80 | }) {
81 | let text;
82 | if (privacy === "PRIVATE") {
83 | text = "Private";
84 | if (showEmoji) {
85 | text = "🔒 Private";
86 | }
87 | } else if (privacy === "UNLISTED") {
88 | text = "Unlisted";
89 | if (showEmoji) {
90 | text = "🔗 Unlisted";
91 | }
92 | } else if (privacy === "PUBLIC") {
93 | text = "Public";
94 | if (showEmoji) {
95 | text = "🌐 Public";
96 | }
97 | }
98 | return {text} ;
99 | }
100 |
101 | export function getCurDateString() {
102 | const currentDate = new Date();
103 | // const year = currentDate.getFullYear();
104 | const month = String(currentDate.getMonth() + 1).padStart(2, "0"); // Months are 0-based in JS
105 | const day = String(currentDate.getDate()).padStart(2, "0");
106 |
107 | return `${month}-${day}`;
108 | }
109 |
--------------------------------------------------------------------------------
/ui/src/spacejson/ResourceDepsForm.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, Heading, Image, Spinner, Stack } from "@chakra-ui/react";
2 | import { DepsResult } from "./handleDownloadSpaceJson";
3 |
4 | export default function ResourceDepsForm({
5 | deps,
6 | uploadingImage,
7 | }: {
8 | deps: DepsResult | null;
9 | setDeps: (deps: DepsResult) => void;
10 | uploadingImage: boolean;
11 | }) {
12 | const imageDepsArr = Object.values(deps?.images ?? {});
13 | if (!deps) {
14 | return ;
15 | }
16 |
17 | return (
18 |
19 | {imageDepsArr.length > 0 && (
20 |
21 |
22 | Images ({imageDepsArr.length})
23 | {/* Will be uploaded as url */}
24 | Will be uploaded as url
25 |
26 | {uploadingImage && (
27 |
28 | Uploading
29 |
30 | )}
31 | {imageDepsArr.map((image) => (
32 |
33 | {image.filename}
34 |
38 |
39 | ))}
40 |
41 | )}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/ui/src/topbar/TabBroadcastChannel.ts:
--------------------------------------------------------------------------------
1 | export const broadcastChannel = new BroadcastChannel(
2 | "comfyui_workspace_manager_channel",
3 | );
4 |
5 | // broadcastChannel.addEventListener("message", (event) => {
6 | // console.log("Received message:", event.data);
7 | // });
8 |
9 | // broadcastChannel.postMessage("Hello from another tab!");
10 |
--------------------------------------------------------------------------------
/ui/src/topbar/Topbar.css:
--------------------------------------------------------------------------------
1 | .dragPanelIcon {
2 | display: none !important;
3 | }
4 | .workspaceManagerPanel:hover .dragPanelIcon {
5 | display: block !important;
6 | }
--------------------------------------------------------------------------------
/ui/src/topbar/TopbarNewWorkflowButton.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, Button } from "@chakra-ui/react";
2 | import { IconDeviceFloppy } from "@tabler/icons-react";
3 | import { useContext } from "react";
4 | import { WorkspaceContext } from "../WorkspaceContext";
5 | import { COMFYSPACE_TRACKING_FIELD_NAME, TOPBAR_BUTTON_HEIGHT } from "../const";
6 | import { app } from "../utils/comfyapp";
7 | import { workflowsTable } from "../db-tables/WorkspaceDB";
8 | import { nanoid } from "nanoid";
9 |
10 | export default function TopbarNewWorkflowButton() {
11 | const { setCurFlowIDAndName, curFlowID } = useContext(WorkspaceContext);
12 | const saveNewWorkflow = async () => {
13 | const workflowName = prompt("Enter workflow name", "Untitled");
14 | if (!workflowName) return;
15 | const id = nanoid();
16 | const graph = app.graph.serialize();
17 | if (!graph.extra) {
18 | graph.extra = {};
19 | }
20 | if (!graph.extra[COMFYSPACE_TRACKING_FIELD_NAME]) {
21 | graph.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
22 | }
23 | graph.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = id;
24 | const flow = await workflowsTable?.createFlow({
25 | id,
26 | json: JSON.stringify(graph),
27 | name: workflowName,
28 | });
29 | flow && setCurFlowIDAndName(flow);
30 | };
31 | if (curFlowID) {
32 | return null;
33 | }
34 | return (
35 |
36 | saveNewWorkflow()}
43 | px={1}
44 | >
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/ui/src/topbar/VersionNameTopbar.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonGroup, Flex } from "@chakra-ui/react";
2 | import { IconTriangleInvertedFilled } from "@tabler/icons-react";
3 | import { useContext, useEffect } from "react";
4 | import { WorkspaceContext } from "../WorkspaceContext";
5 |
6 | export default function VersionNameTopbar({}: {}) {
7 | const { setRoute, curVersion, isDirty, setCurVersion } =
8 | useContext(WorkspaceContext);
9 | useEffect(() => {
10 | if (isDirty && curVersion && curVersion?.name.endsWith("*") === false) {
11 | setCurVersion?.({ ...curVersion, name: curVersion.name + "*" });
12 | }
13 | }, [isDirty]);
14 | if (!curVersion) {
15 | return null;
16 | }
17 | return (
18 |
19 |
20 | {/* } /> */}
21 | }
23 | onClick={() => setRoute("versionHistory")}
24 | >
25 | {curVersion.name}
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/ui/src/types/comfy.d.ts:
--------------------------------------------------------------------------------
1 | import { LGraphNode, IWidget } from "./litegraph";
2 | import { ComfyApp } from "../../scripts/app";
3 |
4 | export interface ComfyExtension {
5 | /**
6 | * The name of the extension
7 | */
8 | name: string;
9 | /**
10 | * Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
11 | * @param app The ComfyUI app instance
12 | */
13 | init?(app: ComfyApp): Promise;
14 | /**
15 | * Allows any additonal setup, called after the application is fully set up and running
16 | * @param app The ComfyUI app instance
17 | */
18 | setup?(app: ComfyApp): Promise;
19 | /**
20 | * Called before nodes are registered with the graph
21 | * @param defs The collection of node definitions, add custom ones or edit existing ones
22 | * @param app The ComfyUI app instance
23 | */
24 | addCustomNodeDefs?(
25 | defs: Record,
26 | app: ComfyApp,
27 | ): Promise;
28 | /**
29 | * Allows the extension to add custom widgets
30 | * @param app The ComfyUI app instance
31 | * @returns An array of {[widget name]: widget data}
32 | */
33 | getCustomWidgets?(
34 | app: ComfyApp,
35 | ): Promise<
36 | Record<
37 | string,
38 | (
39 | node,
40 | inputName,
41 | inputData,
42 | app,
43 | ) => { widget?: IWidget; minWidth?: number; minHeight?: number }
44 | >
45 | >;
46 | /**
47 | * Allows the extension to add additional handling to the node before it is registered with LGraph
48 | * @param nodeType The node class (not an instance)
49 | * @param nodeData The original node object info config object
50 | * @param app The ComfyUI app instance
51 | */
52 | beforeRegisterNodeDef?(
53 | nodeType: typeof LGraphNode,
54 | nodeData: ComfyObjectInfo,
55 | app: ComfyApp,
56 | ): Promise;
57 | /**
58 | * Allows the extension to register additional nodes with LGraph after standard nodes are added
59 | * @param app The ComfyUI app instance
60 | */
61 | registerCustomNodes?(app: ComfyApp): Promise;
62 | /**
63 | * Allows the extension to modify a node that has been reloaded onto the graph.
64 | * If you break something in the backend and want to patch workflows in the frontend
65 | * This is the place to do this
66 | * @param node The node that has been loaded
67 | * @param app The ComfyUI app instance
68 | */
69 | loadedGraphNode?(node: LGraphNode, app: ComfyApp);
70 | /**
71 | * Allows the extension to run code after the constructor of the node
72 | * @param node The node that has been created
73 | * @param app The ComfyUI app instance
74 | */
75 | nodeCreated?(node: LGraphNode, app: ComfyApp);
76 | }
77 |
78 | export type ComfyObjectInfo = {
79 | name: string;
80 | display_name?: string;
81 | description?: string;
82 | category: string;
83 | input?: {
84 | required?: Record;
85 | optional?: Record;
86 | };
87 | output?: string[];
88 | output_name: string[];
89 | };
90 |
91 | export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any];
92 |
--------------------------------------------------------------------------------
/ui/src/types/types.ts:
--------------------------------------------------------------------------------
1 | import { EWorkflowPrivacy } from "./dbTypes";
2 |
3 | export type WorkspaceRoute =
4 | | "root"
5 | | "customNodes"
6 | | "recentFlows"
7 | | "gallery"
8 | | "versionHistory"
9 | | "saveAsModal"
10 | | "modelList"
11 | | "spotlightSearch"
12 | | "downloadSpaceJson"
13 | | "installModels"
14 | | "share";
15 |
16 | export type Session = {
17 | username: string | null;
18 | shareKey: string;
19 | };
20 |
21 | export type ShareWorkflowData = {
22 | version: {
23 | name: string;
24 | json: string;
25 | };
26 | workflow: {
27 | name: string;
28 | cloudID?: string | null;
29 | };
30 | nodeDefs: Object;
31 | privacy: EWorkflowPrivacy;
32 | };
33 |
--------------------------------------------------------------------------------
/ui/src/types/workspace.d.ts:
--------------------------------------------------------------------------------
1 | import { SHORTCUT_TRIGGER_EVENT } from "../const";
2 | import { EOtherKeys, EShortcutKeys } from "./dbTypes";
3 |
4 | interface ShortcutTriggerDetail {
5 | shortcutType: EShortcutKeys | EOtherKeys;
6 | }
7 |
8 | declare global {
9 | interface WindowEventMap {
10 | [SHORTCUT_TRIGGER_EVENT]: CustomEvent;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/ui/src/utils/OsPathUtils.ts:
--------------------------------------------------------------------------------
1 | import type { Workflow } from "../types/dbTypes";
2 |
3 | export const serverInfo: {
4 | os: "Windows" | "Linux" | "Darwin" | "Java" | null;
5 | } = {
6 | os: null,
7 | };
8 |
9 | export const getPathSep = () => (serverInfo.os === "Windows" ? "\\" : "/");
10 |
11 | export function joinRelPath(...segments: string[]) {
12 | const rel = segments.filter((segment) => segment !== "").join("/");
13 | return sanitizeRelPath(rel);
14 | }
15 |
16 | export function sanitizeRelPath(path: string) {
17 | const segments = path
18 | .split("/")
19 | .filter((segment) => segment !== "")
20 | .join("/");
21 | // Replace backslashes with forward slashes to handle Windows paths
22 | let sanitizedPath = segments.replace(/\\/g, "/");
23 | // Remove leading slashes to ensure the path is treated as relative
24 | sanitizedPath = sanitizedPath.replace(/^\/+/, "");
25 |
26 | if (serverInfo.os === "Windows") {
27 | sanitizedPath = segments.replace(/\//g, "\\");
28 | }
29 |
30 | return sanitizedPath;
31 | }
32 |
33 | export function getWorkflowRelPath(workflow: Workflow) {
34 | return joinRelPath(workflow.parentFolderID ?? "", workflow.name + ".json");
35 | }
36 |
37 | export function getFolderRelPath(folder: { id: string }) {
38 | return folder.id;
39 | }
40 |
--------------------------------------------------------------------------------
/ui/src/utils/civitUtils.ts:
--------------------------------------------------------------------------------
1 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
2 |
3 | const CIVIT_API_KEY_STORAGE_KEY = "WORKSPACE_CIVIT_API_KEY_STORAGE_KEY";
4 | export function getCivitApiKey() {
5 | return localStorage.getItem(CIVIT_API_KEY_STORAGE_KEY);
6 | }
7 |
8 | export function setCivitApiKey(apiKey: string) {
9 | localStorage.setItem(CIVIT_API_KEY_STORAGE_KEY, apiKey);
10 | }
11 |
12 | export function getCivitModelDownloadUrl(modelVersionID: string) {
13 | return `https://civitai.com/api/download/models/${modelVersionID}`;
14 | }
15 |
16 | export function getCivitModelPageUrl(modelID: string, modelVersionID?: string) {
17 | if (!modelVersionID) {
18 | return `https://civitai.com/models/${modelID}`;
19 | }
20 | return `https://civitai.com/models/${modelID}?modelVersionId=${modelVersionID}`;
21 | }
22 |
23 | export function getHgModelInfoUrlFromDownloadUrl(downloadUrl: string) {
24 | if (!downloadUrl.includes("huggingface")) return;
25 | const infoPageUrl = downloadUrl.replace("/resolve/", "/blob/");
26 | return infoPageUrl;
27 | }
28 |
29 | interface ResponsePartial {
30 | id: number;
31 | modelId: number;
32 | name: string;
33 | model: {
34 | name: string;
35 | type: string;
36 | nsfw: boolean;
37 | };
38 | images: {
39 | url: string;
40 | nsfwLevel: number;
41 | width: number;
42 | height: number;
43 | hash: string;
44 | }[];
45 | }
46 |
47 | export async function fetchCivitModelFromHashKey(filehash: string): Promise<{
48 | modelName?: string;
49 | civitModelID?: string;
50 | civitModelVersionID?: string;
51 | imageUrl?: string;
52 | }> {
53 | try {
54 | const url = `https://civitai.com/api/v1/model-versions/by-hash/${filehash}`;
55 | const resp = await fetch(url);
56 | const json: ResponsePartial = await resp.json();
57 | let image_url: string | undefined;
58 | const showNsfwThumbnail = await userSettingsTable?.getSetting(
59 | "showNsfwModelThumbnail",
60 | );
61 | if (showNsfwThumbnail === true) {
62 | image_url = json?.images?.[0]?.url;
63 | } else if (!json.model.nsfw) {
64 | const sfwImage = json.images.find((i) => i.nsfwLevel == 1);
65 | image_url = sfwImage?.url;
66 | }
67 |
68 | return {
69 | modelName: json.model.name,
70 | civitModelID: String(json.modelId),
71 | civitModelVersionID: String(json.id),
72 | imageUrl: image_url ?? undefined,
73 | };
74 | } catch (e) {
75 | return {};
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/ui/src/utils/comfyapp.ts:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | export let app: any | null = null;
3 | const api_base = location.pathname.split("/").slice(0, -1).join("/");
4 |
5 | export let api: any | null = null;
6 |
7 | export async function waitForApp() {
8 | await Promise.all([
9 | import(api_base + "/scripts/api.js").then((apiJs) => {
10 | api = apiJs?.api;
11 | }),
12 |
13 | import(api_base + "/scripts/app.js").then((appJs) => {
14 | app = appJs?.app;
15 | }),
16 | ]);
17 | }
18 |
--------------------------------------------------------------------------------
/ui/src/utils/deepJsonDiffCheck.ts:
--------------------------------------------------------------------------------
1 | export const deepJsonDiffCheck = (oldData: any, newData: any) => {
2 | const oldDataNodes = oldData["nodes"] == null ? [] : oldData["nodes"];
3 | const newDataNodes = newData["nodes"] == null ? [] : newData["nodes"];
4 | oldData["nodes"] = [];
5 | newData["nodes"] = [];
6 | let equal = JSON.stringify(oldData) === JSON.stringify(newData);
7 | if (equal) {
8 | const array2Map = (map: any, obj: any) => {
9 | const oldOrder = obj["order"];
10 | const oldPos = obj["pos"];
11 | delete obj["order"];
12 | delete obj["pos"];
13 | map.set(obj["id"], JSON.stringify(obj));
14 | map.set(obj["id"] + "_pos", oldPos);
15 | obj["order"] = oldOrder;
16 | obj["pos"] = oldPos;
17 | return map;
18 | };
19 | const withoutDeviation = (a: any, b: any, c: any) => {
20 | return Math.abs(a - b) > c;
21 | };
22 | if (oldDataNodes.length == newDataNodes.length) {
23 | const oldDataNodesMap = oldDataNodes.reduce(array2Map, new Map());
24 | const newDataNodesMap = newDataNodes.reduce(array2Map, new Map());
25 | for (let [key, value] of oldDataNodesMap) {
26 | if (!newDataNodesMap.has(key)) {
27 | equal = false;
28 | break;
29 | }
30 | if (typeof key === 'string' && key.includes("_pos")) {
31 | const newDataPos = newDataNodesMap.get(key);
32 | if (newDataPos == null || value == null || withoutDeviation(newDataPos[0], value[0], 5) || withoutDeviation(newDataPos[1], value[1], 5)) {
33 | equal = false;
34 | break;
35 | }
36 | } else if (newDataNodesMap.get(key) !== value) {
37 | equal = false;
38 | break;
39 | }
40 | }
41 | }
42 | }
43 | oldData["nodes"] = oldDataNodes;
44 | newData["nodes"] = newDataNodes;
45 | return equal;
46 | };
--------------------------------------------------------------------------------
/ui/src/utils/downloadJsonFile.ts:
--------------------------------------------------------------------------------
1 | export function downloadJsonFile(jsonStr: string, fileName: string) {
2 | const blob = new Blob([jsonStr], { type: "application/json" });
3 | const url = URL.createObjectURL(blob);
4 |
5 | const a = document.createElement("a");
6 | a.href = url;
7 | a.download = `${fileName}.json`;
8 | document.body.appendChild(a);
9 | a.click();
10 | document.body.removeChild(a);
11 | URL.revokeObjectURL(url);
12 | }
13 |
--------------------------------------------------------------------------------
/ui/src/utils/downloadWorkflowsZip.ts:
--------------------------------------------------------------------------------
1 | import { Workflow } from "../types/dbTypes";
2 | import { formatTimestamp } from "../utils";
3 | import JSZip from "JSZip";
4 | import { genFolderRelPath } from "../db-tables/DiskFileUtils";
5 | import { sanitizeRelPath } from "../utils/OsPathUtils";
6 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
7 |
8 | export const downloadWorkflowsZip = async (selectedList: Array) => {
9 | const exportName = `ComfyUI workspace workflows ${formatTimestamp(Date.now())}`;
10 | const zip = new JSZip();
11 | const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
12 | for (const workflow of selectedList) {
13 | let folderPath;
14 | if (twoWaySyncEnabled) {
15 | folderPath = workflow.parentFolderID ?? "";
16 | } else {
17 | folderPath = await genFolderRelPath(workflow.parentFolderID ?? null).then(
18 | async (path) => {
19 | return sanitizeRelPath(path ?? "");
20 | },
21 | );
22 | }
23 | // Ensure the folder path exists in the zip
24 | const folder = zip.folder(folderPath);
25 | // Adding the JSON file to the corresponding folder
26 | folder?.file(`${workflow.name}.json`, workflow.json);
27 | }
28 |
29 | zip.generateAsync({ type: "blob" }).then(function (content) {
30 | const a = document.createElement("a");
31 | a.href = window.URL.createObjectURL(content);
32 | a.download = `${exportName}.zip`;
33 | document.body.appendChild(a);
34 | a.click();
35 | document.body.removeChild(a);
36 | });
37 | };
38 |
--------------------------------------------------------------------------------
/ui/src/utils/encryptUtils.ts:
--------------------------------------------------------------------------------
1 | // utils/encryptUtils
2 | const defaultSalt = import.meta.env.VITE_SHARE_KEY_ENCODE_KEY;
3 |
4 | export function encodeKey(text: string, salt: string = defaultSalt): string {
5 | const randomLength = Math.floor(Math.random() * 13) + 1; // Random length between 1 and 13
6 | const randomPadding = Math.random()
7 | .toString(36)
8 | .substring(2, 2 + randomLength);
9 | const input = randomPadding + salt + text;
10 | return btoa(encodeURIComponent(input));
11 | }
12 |
13 | export function decodeKey(encoded: string, salt: string = defaultSalt): string {
14 | const decoded = decodeURIComponent(atob(encoded));
15 | const saltIndex = decoded.indexOf(salt);
16 | if (saltIndex === -1) throw new Error("Invalid encoded string or salt");
17 | return decoded.slice(saltIndex + salt.length);
18 | }
19 |
--------------------------------------------------------------------------------
/ui/src/utils/findSfwImage.ts:
--------------------------------------------------------------------------------
1 | import { SearchHit } from "../model-manager/civitSearchTypes";
2 | import { isCivitModel } from "../model-manager/install-models/util/modelTypes";
3 | import { CivitiModel } from "../model-manager/types";
4 |
5 | interface ImageLike {
6 | nsfw?: "None" | "Soft" | "Mature" | "X";
7 | nsfwLevel?: number;
8 | }
9 | export function findSfwImage(
10 | images?: T[],
11 | fallback?: boolean,
12 | ): T | undefined {
13 | if (!images || images.length === 0) {
14 | return;
15 | }
16 | let sfw = images.find(
17 | (image) => image.nsfw === "None" || image.nsfwLevel === 1,
18 | );
19 |
20 | if (fallback) {
21 | sfw = sfw ?? images[0];
22 | }
23 |
24 | return sfw;
25 | }
26 |
27 | export function findSfwImageFromModel(
28 | model: CivitiModel | SearchHit,
29 | IMAGE_SIZE = 280,
30 | fallback?: boolean,
31 | ): string | undefined {
32 | if (!model) {
33 | return;
34 | }
35 | if (model.images) {
36 | const imageurl = `https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/${findSfwImage(model.images, fallback)?.url}/width=${IMAGE_SIZE}/`;
37 | return imageurl;
38 | } else if (isCivitModel(model)) {
39 | return findSfwImage(model.modelVersions?.at(0)?.images, fallback)?.url;
40 | }
41 | return undefined;
42 | }
43 |
--------------------------------------------------------------------------------
/ui/src/utils/jsonUtils.ts:
--------------------------------------------------------------------------------
1 | type JsonValue =
2 | | string
3 | | number
4 | | boolean
5 | | { [key: string]: JsonValue }
6 | | JsonValue[]
7 | | undefined;
8 |
9 | export function deepCompare(
10 | json1: JsonValue,
11 | json2: JsonValue,
12 | path: string = "",
13 | ): boolean {
14 | if (
15 | typeof json1 === "object" &&
16 | json1 !== null &&
17 | typeof json2 === "object" &&
18 | json2 !== null
19 | ) {
20 | const keys1 = Object.keys(json1);
21 | const keys2 = Object.keys(json2);
22 | const allKeys = new Set([...keys1, ...keys2]);
23 |
24 | for (const key of allKeys) {
25 | const newPath = path ? `${path}.${key}` : key;
26 | const value1 = (json1 as { [key: string]: JsonValue })[key];
27 | const value2 = (json2 as { [key: string]: JsonValue })[key];
28 |
29 | if (
30 | (value1 === undefined && !keys2.includes(key)) ||
31 | (value2 === undefined && !keys1.includes(key))
32 | ) {
33 | continue; // Skip comparison if the value is undefined in one and missing in the other
34 | }
35 |
36 | if (!keys1.includes(key)) {
37 | console.log(
38 | `${newPath}: Missing in first JSON keys1`,
39 | keys1,
40 | "keys2",
41 | keys2,
42 | value1,
43 | value2,
44 | );
45 | return false;
46 | } else if (!keys2.includes(key)) {
47 | console.log(
48 | `${newPath}: Missing in 222 JSON keys2`,
49 | keys1,
50 | "keys2",
51 | keys2,
52 | value1,
53 | value2,
54 | );
55 | return false;
56 | } else {
57 | const isEqual = deepCompare(value1, value2, newPath);
58 | if (!isEqual) {
59 | return false;
60 | }
61 | }
62 | }
63 | } else if (json1 !== json2 && json1 !== undefined && json2 !== undefined) {
64 | console.log(`${path}: ${json1} != ${json2}`);
65 | return false;
66 | }
67 | return true;
68 | }
69 |
--------------------------------------------------------------------------------
/ui/src/utils/privacyUtils.ts:
--------------------------------------------------------------------------------
1 | import type { EWorkflowPrivacy } from "../types/dbTypes";
2 |
3 | export function getPrivacyEmoji(privacy: EWorkflowPrivacy) {
4 | switch (privacy) {
5 | case "PRIVATE":
6 | return "🔒";
7 | case "PUBLIC":
8 | return "🌐";
9 | case "UNLISTED":
10 | return "🔗";
11 | default:
12 | return "";
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/ui/src/utils/saveShareKey.ts:
--------------------------------------------------------------------------------
1 | import { encodeKey } from "./encryptUtils";
2 |
3 | export function saveShareKey(sharekey: string) {
4 | const key = encodeKey(sharekey);
5 | localStorage.setItem("workspace_manager_shareKey", key);
6 | }
7 |
--------------------------------------------------------------------------------
/ui/src/utils/showAlert.css:
--------------------------------------------------------------------------------
1 | /* Styles for the modal */
2 | .modal {
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | width: 100%;
7 | height: 100%;
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 | background-color: rgba(0, 0, 0, 0.5);
12 | }
13 |
14 | /* Styles for the dialog */
15 | .dialog {
16 | padding: 20px;
17 | border-radius: 5px;
18 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
19 | max-width: 80%;
20 | background-color: #fff;
21 | border-color: #000; /* Default, will be changed */
22 | color: #000; /* Default, will be changed */
23 | }
24 |
25 | /* Specific styles for different alert levels */
26 | .dialog.error {
27 | border-color: #ff3860;
28 | color: #ff3860;
29 | }
30 |
31 | .dialog.info {
32 | border-color: #209cee;
33 | color: #209cee;
34 | }
35 |
36 | .dialog.success {
37 | border-color: #23d160;
38 | color: #23d160;
39 | }
40 |
--------------------------------------------------------------------------------
/ui/src/utils/showAlert.ts:
--------------------------------------------------------------------------------
1 | import "./showAlert.css";
2 |
3 | // Function to show a modal alert
4 | export function showAlert({
5 | message,
6 | level,
7 | }: {
8 | message: string;
9 | level: "error" | "info" | "success";
10 | }): void {
11 | // Create the modal container
12 | const modal = document.createElement("div");
13 | modal.classList.add("wokspace_modal");
14 |
15 | // Create the dialog
16 | const dialog = document.createElement("div");
17 | dialog.classList.add("workspace_dialog");
18 | dialog.classList.add(level); // Add class based on the alert level
19 |
20 | // Create the message paragraph
21 | const messageP = document.createElement("p");
22 | messageP.textContent = message;
23 | dialog.appendChild(messageP);
24 | modal.appendChild(dialog);
25 | document.body.appendChild(modal);
26 |
27 | // Function to remove the modal when clicked outside
28 | const removeModal = (event: MouseEvent) => {
29 | if (event.target === modal) {
30 | modal.removeEventListener("click", removeModal);
31 | document.body.removeChild(modal);
32 | }
33 | };
34 |
35 | // Attach the event listener to the modal
36 | modal.addEventListener("click", removeModal);
37 | }
38 |
--------------------------------------------------------------------------------
/ui/src/utils/twowaySyncUtils.ts:
--------------------------------------------------------------------------------
1 | import { ESortTypes } from "../RecentFilesDrawer/types";
2 | import {
3 | ScanLocalFile,
4 | ScanLocalFolder,
5 | scanLocalFiles,
6 | } from "../apis/TwowaySyncApi";
7 | import { isFolder } from "../db-tables/WorkspaceDB";
8 | import { indexdb } from "../db-tables/indexdb";
9 | import { Folder, Workflow } from "../types/dbTypes";
10 | import { sortFileItem } from "../utils";
11 | import { sanitizeRelPath } from "./OsPathUtils";
12 |
13 | export async function scanMyWorkflowsDir(
14 | parentFolderID: string | null,
15 | sortBy?: ESortTypes,
16 | ): Promise<(Workflow | Folder)[]> {
17 | const parentRelPath = sanitizeRelPath(parentFolderID ?? "");
18 | // folderID is the relative path of the folder in two way sync mode
19 | const fileList = await scanLocalFiles(parentRelPath);
20 |
21 | const allFilesPromises = fileList.map(async (file) => {
22 | const fileName = file.name;
23 | const relPath = [parentRelPath, fileName].join("/");
24 | if (scanFileIsFolder(file)) {
25 | // is folder
26 | const folder: Folder = {
27 | id: sanitizeRelPath(relPath),
28 | name: file.name,
29 | parentFolderID: parentRelPath,
30 | type: "folder",
31 | createTime: Date.now(),
32 | updateTime: Date.now(),
33 | };
34 | return folder;
35 | } else {
36 | // is workflow
37 |
38 | const existingWorkflow = await indexdb.workflows
39 | ?.get(file.id)
40 | .catch((err) => {
41 | console.error(
42 | "Error getting workflow from indexdb",
43 | file.id,
44 | relPath,
45 | err,
46 | );
47 | return null;
48 | });
49 |
50 | const newWorkflow: Workflow = {
51 | cloudID: file.cloudID,
52 | coverMediaPath: file.coverMediaPath,
53 | saveLock: file.saveLock,
54 | ...existingWorkflow,
55 | id: file.id,
56 | json: file.json ?? "{}",
57 | name: fileName.replace(/\.json$/, ""),
58 | parentFolderID: parentRelPath,
59 | createTime: file.createTime,
60 | // setting updateTime will result latestVersionChcek() always fail if in
61 | // workflow.get() not pull updateTime from file
62 | updateTime: file.updateTime,
63 | };
64 | return newWorkflow;
65 | }
66 | });
67 |
68 | const result = await Promise.all(allFilesPromises);
69 | indexdb.workflows.bulkPut(
70 | result.filter((item) => !isFolder(item)) as Workflow[],
71 | );
72 | const all = result.filter((item) => item != null) as (Workflow | Folder)[];
73 | return sortFileItem(all, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
74 | }
75 | function scanFileIsFolder(
76 | scanFile: ScanLocalFile | ScanLocalFolder,
77 | ): scanFile is ScanLocalFolder {
78 | return "type" in scanFile && scanFile.type === "folder";
79 | }
80 |
--------------------------------------------------------------------------------
/ui/src/versionHistory/CreateVersionLogin.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Flex,
4 | Input,
5 | Modal,
6 | ModalBody,
7 | ModalContent,
8 | Stack,
9 | useToast,
10 | } from "@chakra-ui/react";
11 | import { userSettingsTable } from "../db-tables/WorkspaceDB";
12 | import { useContext } from "react";
13 | import { WorkspaceContext } from "../WorkspaceContext";
14 | import { saveShareKey } from "../utils/saveShareKey";
15 |
16 | export default function CreateVersionLogin({
17 | onClose,
18 | }: {
19 | onClose: () => void;
20 | }) {
21 | const cloudHost = userSettingsTable?.settings?.cloudHost;
22 | const toast = useToast();
23 | const { updateSession } = useContext(WorkspaceContext);
24 | return (
25 |
26 |
27 |
28 |
29 |
35 | ✨New Version Control Exprience [beta]
36 |
37 |
38 | We have a new version control experience. Now your versions will
39 | be stored privately and securely in cloud at{" "}
40 | www.nodecafe.co
41 |
42 | Why cloud?
43 |
44 | Previously workflow versions are stored locally in your browser
45 | storage. But people reported inconsistent versions when they
46 | switched browser or device. Therefore we are moving to cloud. So
47 | you can view your workflow versions from any devices, even your
48 | phone.
49 |
50 | 🤩Get Started
51 |
52 |
53 | Login
54 |
55 | then copy your share key to below
56 |
57 | {
60 | if (
61 | !e.target.value ||
62 | e.target.value === "" ||
63 | e.target.value.length < 20
64 | ) {
65 | return;
66 | }
67 |
68 | saveShareKey(e.target.value);
69 | updateSession({
70 | shareKey: e.target.value,
71 | username: null,
72 | });
73 | toast({
74 | title: "Share key saved. You can create versions now.",
75 | status: "success",
76 | duration: 5000,
77 | });
78 | }}
79 | />
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/ui/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src", "public/comfyui", "public/entry.js"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/ui/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | const rewriteImportPlugin = ({ isDev }) => {
5 | return {
6 | name: "rewrite-import-plugin", // this name will show up in warnings and errors
7 | resolveId(source) {
8 | if (!isDev) {
9 | return;
10 | }
11 | if (source === "/scripts/app.js") {
12 | // Change the path to the new host
13 | return "http://127.0.0.1:8188/scripts/app.js";
14 | }
15 | if (source === "/scripts/api.js") {
16 | return "http://127.0.0.1:8188/scripts/api.js";
17 | }
18 | return null; // Other imports should not be affected
19 | },
20 | };
21 | };
22 |
23 | // https://vitejs.dev/config/
24 | export default defineConfig(({ mode }) => ({
25 | envDir: ".",
26 | build: {
27 | watch: {
28 | include: ["src/**"],
29 | },
30 | // minify: false, // ___DEBUG__MODE only
31 | // sourcemap: true, // ___DEBUG___MODE only
32 | emptyOutDir: true,
33 | rollupOptions: {
34 | // externalize deps that shouldn't be bundled into your library
35 | external: ["/scripts/app.js", "/scripts/api.js"],
36 | input: {
37 | input: "/src/main.tsx",
38 | },
39 | output: {
40 | // Provide global variables to use in the UMD build for externalized deps
41 | globals: {
42 | app: "app",
43 | Litegraph: "LiteGraph",
44 | },
45 | dir: "../dist",
46 | // assetFileNames: "[name]-[hash][extname]",
47 | entryFileNames: "workspace_web/[name].js",
48 | chunkFileNames: `workspace_web/[name]-[hash].js`,
49 | assetFileNames: `workspace_web/assets/[name]-[hash].[ext]`,
50 | },
51 | },
52 | },
53 | plugins: [react(), rewriteImportPlugin({ isDev: mode === "development" })],
54 | }));
55 |
--------------------------------------------------------------------------------