The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | <img width="318" alt="Screenshot 2024-03-12 at 12 35 24 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/722a96ca-82a3-4126-83fd-2951de0a18cb">
10 | 
11 | 1. F12 -> Application -> IndexedDB -> delete current indexdb
12 | 
13 | <img width="731" alt="Screenshot 2024-03-12 at 12 30 48 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4c4f0f6a-e402-4fd5-94cd-b00ff6f2a96f">
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 | <img width="723" alt="Screenshot 2024-03-12 at 12 33 08 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/c9ca969d-fdee-4c1e-bfc5-c646ce241fd8">
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.![image](https://github.com/11cafe/comfyui-workspace-manager/assets/26196917/ef7eabc5-8683-4f9a-93f3-e3ba2b0d3449)
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 | <!doctype html>
 2 | <html lang="en">
 3 |   <head>
 4 |     <meta charset="UTF-8" />
 5 |     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
 6 |     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 7 |   </head>
 8 |   <body style="background: #575353;height: 300px;">
 9 |     <div id="root"></div>
10 |     <script type="module" src="/src/main.tsx"></script>
11 |   </body>
12 | </html>
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<Folder | Workflow>;
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 |           <FilesListFolderItem key={folder.id} folder={folder} />
57 |         ))}
58 |       <Box
59 |         border={
60 |           isDraggingOver ? "2px dashed #718096" : "2px dashed transparent"
61 |         }
62 |         onDragOver={(e) => {
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 |               <WorkflowListItem key={workflow.id} workflow={workflow} />
78 |             ))
79 |           : items.map((n) => {
80 |               if (isFolder(n)) {
81 |                 return <FilesListFolderItem folder={n} key={n.id} />;
82 |               }
83 |               return <WorkflowListItem key={n.id} workflow={n} />;
84 |             })}
85 |       </Box>
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<Tag[]>([]);
19 |   const loadTags = async () => {
20 |     const tags = await tagsTable?.listAll();
21 |     setAllTags(tags ?? []);
22 |   };
23 |   useEffect(() => {
24 |     loadTags();
25 |   }, []);
26 |   return (
27 |     <Modal isOpen={true} onClose={onclose}>
28 |       <ModalOverlay />
29 |       <ModalContent>
30 |         <ModalHeader>My Tags</ModalHeader>
31 |         <ModalCloseButton />
32 |         <ModalBody>
33 |           {allTags.map((tag) => (
34 |             <HStack>
35 |               <ChakraTag>{tag.name}</ChakraTag>
36 |               <IconButton
37 |                 onClick={async () => {
38 |                   await tagsTable?.delete(tag.name);
39 |                   loadTags();
40 |                 }}
41 |                 aria-label="delete-tag"
42 |                 colorScheme="red"
43 |                 variant={"ghost"}
44 |                 icon={<IconTrash />}
45 |               />
46 |             </HStack>
47 |           ))}
48 |         </ModalBody>
49 |       </ModalContent>
50 |     </Modal>
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 |     <HStack spacing={2} wrap={"wrap"} mb={0}>
34 |       {multipleState ? (
35 |         <>
36 |           <Checkbox
37 |             isChecked={isSelectedAll}
38 |             onChange={(e: ChangeEvent<HTMLInputElement>) => {
39 |               batchOperationCallback("selectAll", e.target.checked);
40 |             }}
41 |           />
42 |           <Tooltip hasArrow label="Download Zip" placement="bottom">
43 |             <IconButton
44 |               isDisabled={notChecked}
45 |               aria-label="Batch export"
46 |               size={"sm"}
47 |               icon={<IconDownload size={21} />}
48 |               onClick={batchExport}
49 |             />
50 |           </Tooltip>
51 |           <Tooltip hasArrow label="Batch deletion">
52 |             <DeleteConfirm
53 |               isDisabled={notChecked}
54 |               variant="solid"
55 |               promptMessage={`Are you sure you want to delete these ${selectedKeys.length} checked workflows?`}
56 |               tooltipText="Delete selected"
57 |               onDelete={async () => {
58 |                 await workflowsTable?.batchDeleteFlow(selectedKeys);
59 |                 batchOperationCallback("batchDelete");
60 |               }}
61 |             />
62 |           </Tooltip>
63 | 
64 |           {selectedKeys.length > 0 && (
65 |             <Text fontWeight={600}>{`Selected ${selectedKeys.length}`}</Text>
66 |           )}
67 |           <Tooltip hasArrow label="Exit multi-select">
68 |             <IconButton
69 |               aria-label="Close multi-select operation"
70 |               size={"sm"}
71 |               icon={<IconX />}
72 |               onClick={() => {
73 |                 changeMultipleState(false);
74 |               }}
75 |             />
76 |           </Tooltip>
77 |         </>
78 |       ) : (
79 |         <Tooltip hasArrow label="Select multiple" placement="bottom-start">
80 |           <IconButton
81 |             aria-label="Start multi-select operation"
82 |             size={"sm"}
83 |             variant={"outline"}
84 |             icon={<IconListCheck size={21} />}
85 |             onClick={() => {
86 |               changeMultipleState(true);
87 |             }}
88 |           />
89 |         </Tooltip>
90 |       )}
91 |     </HStack>
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<Tag[]>([]);
16 |   useEffect(() => {
17 |     tagsTable?.listAll().then((tags) => {
18 |       setTags(tags);
19 |     });
20 |   }, []);
21 |   return (
22 |     <HStack spacing={2} wrap={"wrap"} mb={0}>
23 |       {selectedTag != null && (
24 |         <IconButton
25 |           aria-label="Close"
26 |           size={"sm"}
27 |           icon={<IconX />}
28 |           onClick={() => {
29 |             setSelectedTag(undefined);
30 |           }}
31 |         />
32 |       )}
33 |       {tags.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW).map((tag) => (
34 |         <Button
35 |           key={tag.name}
36 |           variant="solid"
37 |           width={"auto"}
38 |           flexShrink={0}
39 |           size={"sm"}
40 |           borderRadius={15}
41 |           py={4}
42 |           onClick={() => {
43 |             setSelectedTag(tag.name);
44 |           }}
45 |           isActive={selectedTag === tag.name}
46 |         >
47 |           {tag.name}
48 |         </Button>
49 |       ))}
50 |       {(tags.length ?? 0) > MAX_TAGS_TO_SHOW && (
51 |         <IconButton
52 |           aria-label="Show-all-tags"
53 |           size={"sm"}
54 |           icon={showAllTags ? <IconChevronUp /> : <IconChevronDown />}
55 |           onClick={() => setShowAllTags(!showAllTags)}
56 |         />
57 |       )}
58 |     </HStack>
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 |       <Menu isLazy>
28 |         <MenuButton
29 |           as={IconButton}
30 |           aria-label="Options"
31 |           icon={<IconMenu2 />}
32 |           size={"sm"}
33 |           variant="outline"
34 |         />
35 |         <MenuList>
36 |           <MenuItem
37 |             onClick={() => setIsSettingsOpen(true)}
38 |             icon={<IconSettings size={16} />}
39 |             fontSize={16}
40 |           >
41 |             Settings
42 |           </MenuItem>
43 |           <MenuItem
44 |             onClick={toggleColorMode}
45 |             icon={
46 |               colorMode === "light" ? (
47 |                 <IconMoon size={ICON_SIZE} />
48 |               ) : (
49 |                 <IconSun size={ICON_SIZE} />
50 |               )
51 |             }
52 |             fontSize={16}
53 |           >
54 |             {colorMode === "light" ? "Dark" : "Light"} Mode
55 |           </MenuItem>
56 |         </MenuList>
57 |       </Menu>
58 |       {isSettingsOpen && (
59 |         <WorkspaceSettingsModal onClose={() => 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 |     <Flex justifyContent={"flex-end"}>
16 |       <DeleteConfirm
17 |         promptMessage="Are you sure you want to delete this workflow?"
18 |         onDelete={() => {
19 |           onDeleteFlow && onDeleteFlow(workflow.id);
20 |         }}
21 |       />
22 |       <MoreActionMenu workflow={workflow} />
23 |     </Flex>
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 |       <Modal isOpen={true} onClose={onClose} size={"xl"}>
28 |         <ModalOverlay />
29 |         <ModalContent>
30 |           <ModalHeader>Settings</ModalHeader>
31 |           <ModalCloseButton />
32 |           <ModalBody pb={14}>
33 |             <HStack>
34 |               <VStack
35 |                 divider={<StackDivider borderColor="gray.500" />}
36 |                 spacing={4}
37 |                 align="stretch"
38 |                 w="100%"
39 |               >
40 |                 <SelectMyWorkflowsDir />
41 |                 <ShortcutSettings />
42 |                 <TwoWaySyncSettings />
43 |                 <SharekeySetting />
44 |                 <CommonCheckboxSettings
45 |                   settingKey="foldersOnTop"
46 |                   text={`Folders always on top`}
47 |                 />
48 |                 <CommonNumberSetting
49 |                   label={`Maximum number of save change history to store. This does not include
50 |         versions that you created by "Create Version", which are always stored
51 |         and has no limit.`}
52 |                   settingKey="maximumChangelogNumber"
53 |                 />
54 |                 <CommonCheckboxSettings
55 |                   settingKey="disableUnsavedWarning"
56 |                   text={`Disable the "Save or Discard" warning when closing a workflow with unsaved changes. ⛔️Be careful, if you disable this reminder and forget to save your workflow, you will lose your changes.`}
57 |                 />
58 | 
59 |                 <CommonCheckboxSettings
60 |                   settingKey="hideCoverImage"
61 |                   text="Hide thumbnail image in workflow list"
62 |                 />
63 |                 <CloudHostSetting />
64 |               </VStack>
65 |             </HStack>
66 |           </ModalBody>
67 |         </ModalContent>
68 |       </Modal>
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<T> extends HTMLAttributes<T> {
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<void>;
19 |   discardUnsavedChanges: () => Promise<void>;
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<string> {
 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<number | null> {
 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<void> {
 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<CarouselProps> = ({
 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 <p>No images found, let's start generating 🪄</p>;
 36 |   const item = media.at(currentNum);
 37 |   if (!item) return null;
 38 |   return (
 39 |     <Flex
 40 |       justifyContent="center"
 41 |       alignItems="center"
 42 |       position="relative"
 43 |       width={imageSize.width}
 44 |       height={imageSize.height}
 45 |       overflow="hidden"
 46 |     >
 47 |       <div
 48 |         key={item.id}
 49 |         style={{
 50 |           position: "absolute",
 51 |           cursor: "pointer",
 52 |           width: "100%",
 53 |           height: "100%",
 54 |         }}
 55 |         onClick={() => {
 56 |           window.open(item.imageUrl);
 57 |         }}
 58 |       >
 59 |         {isImageFormat(item.imageUrl) ? (
 60 |           <Image
 61 |             src={item.imageUrl}
 62 |             alt={`image-${item.id}`}
 63 |             width={"100%"}
 64 |             height={"100%"}
 65 |             objectFit="contain"
 66 |           />
 67 |         ) : (
 68 |           <video
 69 |             style={{ objectFit: "contain" }}
 70 |             width={"100%"}
 71 |             height={"100%"}
 72 |             src={item.imageUrl}
 73 |             loop={true}
 74 |             autoPlay={true}
 75 |             muted={true}
 76 |           >
 77 |             <track kind="captions" />
 78 |           </video>
 79 |         )}
 80 |       </div>
 81 | 
 82 |       <IconButton
 83 |         size={"sm"}
 84 |         color={"white"}
 85 |         bgColor="whiteAlpha.400"
 86 |         aria-label="Previous image"
 87 |         icon={<IconChevronLeft />}
 88 |         onClick={() => paginate(-1)}
 89 |         position="absolute"
 90 |         left="0"
 91 |         zIndex="2"
 92 |       />
 93 |       <IconButton
 94 |         size={"sm"}
 95 |         color={"white"}
 96 |         bgColor="whiteAlpha.400"
 97 |         aria-label="Next image"
 98 |         icon={<IconChevronRight />}
 99 |         onClick={() => paginate(1)}
100 |         position="absolute"
101 |         right="0"
102 |         zIndex="2"
103 |       />
104 |     </Flex>
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 |     <MenuItem
19 |       onClick={async () => {
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 ? <Spinner /> : <IconLink size={20} />}
49 |       iconSpacing={1}
50 |       alignItems={"center"}
51 |       isDisabled={loading}
52 |     >
53 |       <HStack>
54 |         <p>Copy share link</p>
55 |       </HStack>
56 |     </MenuItem>
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<HTMLDivElement>(null);
17 |   useOutsideClick({
18 |     ref: ref,
19 |     handler: () => onClose(),
20 |   });
21 | 
22 |   return (
23 |     <Box position="relative">
24 |       <Box>{menuButton}</Box>
25 |       {isOpen && (
26 |         <Box
27 |           ref={ref}
28 |           mt="8px"
29 |           shadow="md"
30 |           p="2"
31 |           position="absolute"
32 |           zIndex="dropdown"
33 |         >
34 |           {options}
35 |         </Box>
36 |       )}
37 |     </Box>
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<T> {
 6 |   label: string;
 7 |   value: T;
 8 |   icon?: React.ReactElement;
 9 | }
10 | 
11 | type Props<T> = {
12 |   options: CustomSelectorOption<T>[];
13 |   value: T;
14 |   onChange: (value: T) => void;
15 | };
16 | export default function CustomSelector<T>({
17 |   options,
18 |   value,
19 |   onChange,
20 | }: Props<T>) {
21 |   const [isOpen, setIsOpen] = useState(false);
22 |   const ref = useRef<HTMLDivElement>(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 |     <Box position="relative">
41 |       <Button
42 |         onClick={toggleDropdown}
43 |         rightIcon={<IconChevronDown />}
44 |         leftIcon={selectedOption?.icon}
45 |       >
46 |         {selectedOption?.label}
47 |       </Button>
48 |       {isOpen && (
49 |         <Card
50 |           gap={4}
51 |           ref={ref}
52 |           mt="2"
53 |           shadow="md"
54 |           borderWidth="1px"
55 |           p="2"
56 |           position="absolute"
57 |           zIndex={100}
58 |         >
59 |           {options.map((option) => (
60 |             <Button
61 |               key={option.label}
62 |               onClick={() => {
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 |             </Button>
73 |           ))}
74 |         </Card>
75 |       )}
76 |     </Box>
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 |     <Popover isLazy={true} placement="auto" gutter={0}>
35 |       {({ onClose }) => (
36 |         <>
37 |           <PopoverTrigger>
38 |             <Box>
39 |               <Tooltip hasArrow label={tooltipText} placement="bottom">
40 |                 <IconButton
41 |                   aria-label="Delete confirm"
42 |                   size={"sm"}
43 |                   icon={<IconTrash color="#F56565" size={"20px"} />}
44 |                   isDisabled={isDisabled}
45 |                   variant={variant}
46 |                 />
47 |               </Tooltip>
48 |             </Box>
49 |           </PopoverTrigger>
50 |           <PopoverContent>
51 |             <PopoverCloseButton onClick={onClose} />
52 |             <PopoverBody>
53 |               <Text mb={4} pr={4} fontWeight={600}>
54 |                 {promptMessage}
55 |               </Text>
56 |               <Button
57 |                 colorScheme="red"
58 |                 size={"sm"}
59 |                 onClick={() => {
60 |                   onDelete();
61 |                   onClose();
62 |                 }}
63 |               >
64 |                 Yes, delete
65 |               </Button>
66 |             </PopoverBody>
67 |           </PopoverContent>
68 |         </>
69 |       )}
70 |     </Popover>
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<Props>) {
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 |     <div style={styles} onMouseDown={handleMouseDown}>
75 |       {children}
76 |     </div>
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<HTMLInputElement>) => {
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 |     <Modal isOpen={true} onClose={onclose}>
58 |       <ModalOverlay />
59 |       <ModalContent>
60 |         <ModalHeader>Edit folder name</ModalHeader>
61 |         <ModalCloseButton />
62 |         <ModalBody>
63 |           <FormControl isInvalid={!!submitError}>
64 |             <FormLabel>Folder name</FormLabel>
65 |             <Input
66 |               value={editName}
67 |               onChange={handleChange}
68 |               autoFocus
69 |               onKeyUp={(e) => {
70 |                 e.code === "Enter" && !submitError && editName && onSubmit();
71 |               }}
72 |             />
73 |             {submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
74 |           </FormControl>
75 |         </ModalBody>
76 |         <ModalFooter>
77 |           <Button
78 |             colorScheme="teal"
79 |             mr={3}
80 |             onClick={onSubmit}
81 |             isDisabled={!editName || !!submitError}
82 |           >
83 |             Save
84 |           </Button>
85 |           <Button onClick={onclose}>Cancel</Button>
86 |         </ModalFooter>
87 |       </ModalContent>
88 |     </Modal>
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<number>();
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 |     <CustomMenu
23 |       isOpen={isOpen}
24 |       onClose={delayedClose}
25 |       menuButton={
26 |         <Box
27 |           aria-label="menu"
28 |           onClick={onOpen}
29 |           onMouseEnter={onOpen}
30 |           onMouseLeave={delayedClose}
31 |         >
32 |           {menuButton}
33 |         </Box>
34 |       }
35 |       options={
36 |         <Menu isOpen={true}>
37 |           <MenuList
38 |             minWidth={150}
39 |             zIndex={1000}
40 |             onMouseEnter={onOpen}
41 |             onMouseLeave={delayedClose}
42 |           >
43 |             {menuContent}
44 |           </MenuList>
45 |         </Menu>
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 |     <Image
49 |       borderRadius={3}
50 |       boxSize={`${size}px`}
51 |       objectFit={objectFit ?? "cover"}
52 |       src={`/workspace/view_media?filename=${mediaLocalPath}`}
53 |       alt="workflow image renamed or moved from output folder"
54 |     />
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 |     <div
12 |       style={{
13 |         position: "fixed",
14 |         top: 0,
15 |         bottom: 0,
16 |         left: 0,
17 |         right: 0,
18 |         backgroundColor: bg,
19 |         // zIndex: 100,
20 |       }}
21 |     ></div>
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 |     <Box style={{ position: "relative", ...style }} mb={2}>
28 |       <IconSearch style={IconSearchStyle} />
29 |       {isSearchValueNotEmpty && (
30 |         <IconButton
31 |           size="xs"
32 |           position="absolute"
33 |           right="5px"
34 |           top="50%"
35 |           transform="translateY(-50%)"
36 |           cursor="pointer"
37 |           background="none"
38 |           zIndex="100"
39 |           icon={<IconX width={15} height={15} />}
40 |           onClick={() => onUpdateSearchValue("")}
41 |           aria-label="clear input button"
42 |         />
43 |       )}
44 |       <Input
45 |         placeholder={props.placeholder ?? "Search"}
46 |         size={"sm"}
47 |         paddingLeft={"35px"}
48 |         paddingBlock={"5px"}
49 |         value={searchValue}
50 |         onChange={({ target }) => onUpdateSearchValue(target.value)}
51 |       />
52 |     </Box>
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 = <T>(value: T, delay: number): T => {
 4 |   const [debouncedValue, setDebouncedValue] = useState<T>(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<T extends (...args: any[]) => 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<T>) => {
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 = <S>(
 4 |   setStateAction: SetStateAction<S>,
 5 | ): setStateAction is (prevState: S) => S =>
 6 |   typeof setStateAction === "function";
 7 | 
 8 | type ReadOnlyRefObject<T> = {
 9 |   readonly current: T;
10 | };
11 | 
12 | type UseStateRef = {
13 |   <S>(
14 |     initialState: S | (() => S),
15 |   ): [S, Dispatch<SetStateAction<S>>, ReadOnlyRefObject<S>];
16 |   <S = undefined>(): [
17 |     S | undefined,
18 |     Dispatch<SetStateAction<S | undefined>>,
19 |     ReadOnlyRefObject<S | undefined>,
20 |   ];
21 | };
22 | 
23 | export const useStateRef: UseStateRef = <S>(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<Changelog> {
 8 |   static readonly TABLE_NAME: Table = "changelogs";
 9 |   constructor() {
10 |     super("changelogs");
11 |   }
12 |   static async load(): Promise<ChangelogsTable> {
13 |     const instance = new ChangelogsTable();
14 |     return instance;
15 |   }
16 | 
17 |   public async listByWorkflowID(workflowID: string): Promise<Changelog[]> {
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<Changelog> {
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<Changelog | null> {
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<string> {
 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<number> {
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<IDBDatabase> {
 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<void> {
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<string | undefined> {
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<Media> {
 7 |   static readonly TABLE_NAME: Table = "media";
 8 |   constructor() {
 9 |     super("media");
10 |   }
11 |   public async listByWorkflowID(workflowID: string): Promise<Media[]> {
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<Media | null> {
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<void> {
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<Model> {
 6 |   static readonly TABLE_NAME: Table = "models";
 7 |   constructor() {
 8 |     super("models");
 9 |   }
10 |   static async load(): Promise<ModelsTable> {
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<UserSettings> {
  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<K extends keyof UserSettings>(
 44 |     key: K,
 45 |   ): Promise<UserSettings[K]> {
 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<UserSettings | undefined> {
 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<UserSettings>) {
 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<UserSettingsTable> {
 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<WorkflowVersion> {
 7 |   static readonly TABLE_NAME: Table = "workflowVersions";
 8 |   constructor() {
 9 |     super("workflowVersions");
10 |   }
11 |   static async load(): Promise<WorkflowVersionsTable> {
12 |     const instance = new WorkflowVersionsTable();
13 |     return instance;
14 |   }
15 | 
16 |   public async listByWorkflowID(
17 |     workflowID: string,
18 |   ): Promise<WorkflowVersion[]> {
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<WorkflowVersion | null> {
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<Workflow, string>;
17 |   changelogs!: Table<Changelog, string>;
18 |   media!: Table<Media, string>;
19 |   folders!: Table<Folder, string>;
20 |   tags!: Table<Tag, string>;
21 |   userSettings!: Table<UserSettings, string>;
22 |   models!: Table<Model, string>;
23 |   cache!: Table<LocalCache, string>;
24 |   workflowVersions!: Table<WorkflowVersion, string>;
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<Tag> {
 6 |   static readonly TABLE_NAME: Table = "tags";
 7 | 
 8 |   constructor() {
 9 |     super("tags");
10 |   }
11 | 
12 |   static async load(): Promise<TagsTable> {
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<SetStateAction<Media[]>>;
 9 |   setShowAllImages: (showAllImages: boolean) => void;
10 | }
11 | 
12 | export const GalleryContext = createContext<GalleryContextProps>({
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 |       <Stack>
 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 |             <FormItemComponent
 48 |               key={`form${input.nodeID}${input.inputName}`}
 49 |               inputItem={input}
 50 |             />
 51 |           );
 52 |         })}
 53 |       </Stack>
 54 |     );
 55 |   }
 56 |   const nodes = groupInputsByNodeType(calcInputList);
 57 |   return (
 58 |     <Stack>
 59 |       {nodes.map((nodeInputs) => {
 60 |         if (!nodeInputs[0]) {
 61 |           return null;
 62 |         }
 63 |         return (
 64 |           <CustomAccordionPanel
 65 |             title={nodeInputs[0].classType + " #" + nodeInputs[0].nodeID}
 66 |             key={nodeInputs[0].nodeID}
 67 |           >
 68 |             <Flex px={2} gap={1} direction={"column"}>
 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 |                   <FormItemComponent
 82 |                     key={`form${input.nodeID}${input.inputName}`}
 83 |                     inputItem={input}
 84 |                   />
 85 |                 );
 86 |               })}
 87 |             </Flex>
 88 |           </CustomAccordionPanel>
 89 |         );
 90 |       })}
 91 |     </Stack>
 92 |   );
 93 | }
 94 | 
 95 | function CustomAccordionPanel({
 96 |   title,
 97 |   children,
 98 | }: {
 99 |   title: string;
100 |   children: React.ReactNode;
101 | }) {
102 |   return (
103 |     <Box border="1px" borderColor="gray.500" borderRadius="md" p={4}>
104 |       <Text size="sm" mb={1} color={"GrayText"}>
105 |         {title}
106 |       </Text>
107 |       <div>{children}</div>
108 |     </Box>
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 |     <Flex gap={2}>
15 |       <Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
16 |         {inputItem.label ?? inputItem.inputName}
17 |       </Flex>
18 |       <Checkbox
19 |         defaultChecked={!!inputItem.inputValue}
20 |         onChange={(e) => {
21 |           props?.updateMetaData?.({
22 |             promptKey: props.promptKey,
23 |             name: props.name,
24 |             value: e.target.checked,
25 |           });
26 |         }}
27 |       />
28 |     </Flex>
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 |     <Flex gap={2}>
14 |       <Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
15 |         {inputItem.label ?? inputItem.inputName}
16 |       </Flex>
17 |       <Input value={inputItem.inputValue} onChange={(e) => {}} />
18 |     </Flex>
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 |     <Flex gap={1} direction={"column"}>
26 |       <Flex gap={2}>
27 |         <Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
28 |           {inputItem.label ?? inputItem.inputName}
29 |         </Flex>
30 |         <NumberInput
31 |           width={"100%"}
32 |           step={props?.step}
33 |           value={inputItem.inputValue}
34 |           min={props?.min}
35 |           max={props?.max}
36 |           onChange={(val) => {}}
37 |         >
38 |           <NumberInputField />
39 |           <NumberInputStepper>
40 |             <NumberIncrementStepper />
41 |             <NumberDecrementStepper />
42 |           </NumberInputStepper>
43 |         </NumberInput>
44 |       </Flex>
45 |       <Flex>
46 |         <Slider
47 |           maxWidth={"400px"}
48 |           step={props?.step}
49 |           value={Number(inputItem.inputValue)}
50 |           min={props?.min ?? 0}
51 |           max={props?.max ?? 100}
52 |           onChange={(val) => {}}
53 |         >
54 |           <SliderTrack>
55 |             <SliderFilledTrack />
56 |           </SliderTrack>
57 |           <SliderThumb />
58 |         </Slider>
59 |       </Flex>
60 |     </Flex>
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 |     <Flex gap={2}>
 8 |       <Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
 9 |         {props.inputItem.label ?? props.inputItem.inputName}
10 |       </Flex>
11 |       <Flex>No Support</Flex>
12 |     </Flex>
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 |     <Flex gap={2}>
15 |       <Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
16 |         {inputItem.label ?? inputItem.inputName}
17 |       </Flex>
18 |       <Select value={inputItem.inputValue} onChange={(e) => {}}>
19 |         {props?.options?.map((v: string, i: number) => (
20 |           <option
21 |             key={`select${inputItem.nodeID}${inputItem.inputName}${i}`}
22 |             value={v}
23 |           >
24 |             {v}
25 |           </option>
26 |         ))}
27 |       </Select>
28 |     </Flex>
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 |     <Flex gap={1} direction={"column"}>
15 |       <Flex>{inputItem.label ?? inputItem.inputName}</Flex>
16 |       <Textarea value={inputItem.inputValue} onChange={(e) => {}} rows={5} />
17 |     </Flex>
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 |     <Flex gap={3} h={"100%"}>
19 |       <Grid
20 |         gridTemplateRows={mediaList.length <= 6 ? "1fr 20%" : "1fr"}
21 |         flex={1}
22 |         gap={2}
23 |       >
24 |         <div style={{ height: "56vh" }}>
25 |           <Carousel
26 |             media={mediaList.map((v) => ({
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 |         </div>
36 |         <Flex wrap={"wrap"}>
37 |           {mediaList?.map((media) => (
38 |             <Box
39 |               display={"inline-block"}
40 |               p={1}
41 |               borderRadius={"4px"}
42 |               key={`image-bottom-${media.id}`}
43 |               width={`${GALLERY_IMAGE_SIZE + 3}px`}
44 |               height={`${GALLERY_IMAGE_SIZE + 3}px`}
45 |               cursor={"pointer"}
46 |               border={curMedia?.id === media.id ? "1px solid gray" : ""}
47 |               onClick={() => setCurMedia(media)}
48 |             >
49 |               <MediaPreview
50 |                 mediaLocalPath={media.localPath}
51 |                 size={GALLERY_IMAGE_SIZE}
52 |                 objectFit="contain"
53 |                 hideBrokenImage
54 |                 onBrokenLink={() => {
55 |                   mediaTable?.delete(media.id);
56 |                 }}
57 |               />
58 |             </Box>
59 |           ))}
60 |         </Flex>
61 |       </Grid>
62 |       <GalleryRightCol media={curMedia ?? undefined} />
63 |     </Flex>
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<Media[]>([]);
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 |     <HStack wrap={"wrap"}>
37 |       {medias.map((media) => {
38 |         return (
39 |           <GalleryMediaItem
40 |             key={media.id}
41 |             selectedID={[]}
42 |             media={media}
43 |             isSelecting={false}
44 |             onClickMedia={onClickMedia}
45 |           />
46 |         );
47 |       })}
48 |     </HStack>
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 |     <Flex overflowY={"auto"} mb={4} direction={"column"} gap={2} flex={1}>
13 |       <GalleryRightColHeaderButtons media={media} />
14 |       <MetadataForm media={media ?? null} />
15 |     </Flex>
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<FormItem, "name" | "promptKey" | "classType">,
 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<PromptNodeInputItem[]>([]);
 35 |   const [imagePrompt, setImagePrompt] = useState<ImagePrompt>();
 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<TopFieldType[]>([]);
 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 |     <MetaBoxContext.Provider
 83 |       value={{
 84 |         topFields,
 85 |         showNodeName,
 86 |         calcInputList,
 87 |         updateTopField,
 88 |       }}
 89 |     >
 90 |       <Flex direction={"column"} align={"stretch"} gap={5}>
 91 |         <TopForm />
 92 |         <HStack>
 93 |           <p>Show all inputs</p>
 94 |           <Switch
 95 |             isChecked={showAllInputs}
 96 |             onChange={(e) => setShowAllInputs(!showAllInputs)}
 97 |           />
 98 |           <p>Show node names</p>
 99 |           <Switch
100 |             isChecked={showNodeName}
101 |             onChange={(e) => setShowNodeName(!showNodeName)}
102 |           />
103 |         </HStack>
104 |         {showAllInputs && <AllPromptForm />}
105 |       </Flex>
106 |     </MetaBoxContext.Provider>
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<MetaBoxContextProps>({
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<string>();
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<string>(); // 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 |       <Flex px={2} gap={2} direction={"column"}>
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 |             <FormItemComponent
26 |               key={`formTopField${field.promptKey}${field.name}`}
27 |               inputItem={input}
28 |             />
29 |           );
30 |         })}
31 |       </Flex>
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<T = any> = { 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<MetaData>;
28 |       }
29 |       if (extension === "webp") {
30 |         return getWebpMetadata(fileObj) as Promise<MetaData>;
31 |       }
32 |       const metaData = (await getPngMetadata(fileObj)) as MetaData<string>;
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<void>;
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<void>;
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<string, ComfyObjectInfo>,
26 |     app: ComfyApp,
27 |   ): Promise<void>;
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<void>;
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<void>;
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<string, ComfyObjectInfoConfig>;
85 |     optional?: Record<string, ComfyObjectInfoConfig>;
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 |     <div
12 |       style={{
13 |         position: "fixed",
14 |         top: 0,
15 |         bottom: 0,
16 |         left: 0,
17 |         right: 0,
18 |         backgroundColor: bg,
19 |         // zIndex: 100,
20 |       }}
21 |     ></div>
22 |   );
23 | }
24 | 


--------------------------------------------------------------------------------
/ui/src/model-manager/hooks/useDebaunce.ts:
--------------------------------------------------------------------------------
 1 | import { useEffect, useState } from "react";
 2 | 
 3 | export const useDebounce = <T>(value: T, delay: number): T => {
 4 |   const [debouncedValue, setDebouncedValue] = useState<T>(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<string[]>(["checkpoints"]);
10 | 
11 |   // all models
12 |   const [modelsList, setModelsList] = useState<ModelsListRespItem[]>([]);
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 |     <Popover
27 |       isOpen={isOpen}
28 |       onOpen={onOpen}
29 |       onClose={onClose}
30 |       placement="right"
31 |       closeOnBlur={false}
32 |     >
33 |       <PopoverTrigger>
34 |         <Button size={"sm"} py={1} mr={8}>
35 |           Set Civitai API Key
36 |         </Button>
37 |       </PopoverTrigger>
38 |       <PopoverContent p={5}>
39 |         <PopoverArrow />
40 |         <PopoverCloseButton />
41 |         <Stack spacing={4}>
42 |           <Text fontSize={14}>
43 |             Some Civitai.com models require user login to download, you will
44 |             nedd a Civitai API key to download in that case
45 |           </Text>
46 |           <Input
47 |             value={apiKeyInput}
48 |             onChange={(e) => setApiKeyInput(e.target.value)}
49 |             placeholder="API Key"
50 |           />
51 |           <Button size={"sm"} py={1} mr={8} onClick={saveApiKey}>
52 |             Save
53 |           </Button>
54 |         </Stack>
55 |       </PopoverContent>
56 |     </Popover>
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<string[]>([]);
 31 |   const [url, setUrl] = useState<string>("");
 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 |     <AlertDialog
 50 |       isOpen={isOpen}
 51 |       leastDestructiveRef={cancelRef}
 52 |       onClose={onClose}
 53 |     >
 54 |       <AlertDialogOverlay>
 55 |         <AlertDialogContent>
 56 |           <AlertDialogHeader fontSize="lg" fontWeight="bold">
 57 |             Choose Folder
 58 |           </AlertDialogHeader>
 59 | 
 60 |           <AlertDialogBody>
 61 |             <Stack spacing={4}>
 62 |               {!fileSelected && (
 63 |                 <>
 64 |                   <Text>Model download url</Text>
 65 |                   <Input
 66 |                     placeholder="https://civitai.com/api/download/models/311399"
 67 |                     onChange={(e) => setUrl(e.target.value)}
 68 |                     value={url}
 69 |                   />
 70 |                 </>
 71 |               )}
 72 |               <Text>Choose model install folder</Text>
 73 |               <Select
 74 |                 placeholder="Select option"
 75 |                 value={folderPath}
 76 |                 onChange={(e) => setFolderPath(e.target.value)}
 77 |               >
 78 |                 {foldersList.map((folderPath) => (
 79 |                   <option key={folderPath} value={folderPath}>
 80 |                     {folderPath}
 81 |                   </option>
 82 |                 ))}
 83 |               </Select>
 84 |             </Stack>
 85 |           </AlertDialogBody>
 86 | 
 87 |           <AlertDialogFooter>
 88 |             <Button ref={cancelRef} onClick={onClose}>
 89 |               Cancel
 90 |             </Button>
 91 |             <Button
 92 |               onClick={() => selectFolder(folderPath, url)}
 93 |               ml={3}
 94 |               isDisabled={url.length === 0}
 95 |             >
 96 |               Confirm
 97 |             </Button>
 98 |           </AlertDialogFooter>
 99 |         </AlertDialogContent>
100 |       </AlertDialogOverlay>
101 |     </AlertDialog>
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 |     <Flex gap={1} alignItems={"center"} grow={1}>
14 |       <Input
15 |         placeholder="Search models in CivitAI"
16 |         width={"60%"}
17 |         value={searchQuery}
18 |         onChange={(e) => setSearchQuery(e.target.value)}
19 |         onKeyUp={(e) => {
20 |           e.code === "Enter" && onSearch();
21 |         }}
22 |       />
23 |       <Button size={"sm"} onClick={() => onSearch()} colorScheme="teal">
24 |         Search
25 |       </Button>
26 |     </Flex>
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 |       <Button
10 |         size={"sm"}
11 |         colorScheme="teal"
12 |         onClick={() => setRoute("installModels")}
13 |       >
14 |         Install Models
15 |       </Button>
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<Queue[]>([]);
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 |     <Stack
39 |       spacing={5}
40 |       pos="absolute"
41 |       bottom="0"
42 |       left="0"
43 |       width="50%"
44 |       zIndex={80}
45 |       backgroundColor={colorMode === "light" ? "white" : "#242424"}
46 |       paddingX={5}
47 |       pt={2}
48 |     >
49 |       {queue.map(({ save_path, progress }) => (
50 |         <HStack key={save_path}>
51 |           <Text fontSize={16} width="40%">
52 |             {save_path.replace(/^.*[\\/]/, "")}
53 |           </Text>
54 |           <Progress
55 |             isIndeterminate={!progress}
56 |             hasStripe
57 |             width="50%"
58 |             value={progress}
59 |           />
60 |           <Text fontSize={16} width="10%">
61 |             {progress.toFixed(1)}%
62 |           </Text>
63 |           <Button
64 |             variant="outline"
65 |             size="sm"
66 |             colorScheme="red"
67 |             onClick={() => cancelDownload(save_path)}
68 |           >
69 |             Cancel
70 |           </Button>
71 |         </HStack>
72 |       ))}
73 |     </Stack>
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<CivitiModel[]> {
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<SearchHit[]> {
 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<MODEL_TYPE, string> = {
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 |     <Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={1} marginTop={2}>
12 |       {list.map((v) => (
13 |         <GridItem key={v.model_name + v.date.getTime()}>
14 |           <ModelItem data={v} />
15 |         </GridItem>
16 |       ))}
17 |     </Grid>
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 |     <Wrap>
20 |       {modelTypeList.map((v) => (
21 |         <WrapItem key={v}>
22 |           <Button
23 |             colorScheme="blue"
24 |             variant={selectedModel === v ? "solid" : "outline"}
25 |             onClick={() => clickHanlder(v)}
26 |             size={"sm"}
27 |           >
28 |             {v}
29 |           </Button>
30 |         </WrapItem>
31 |       ))}
32 |     </Wrap>
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<MissingModel[]>([]);
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 |       <Button
68 |         size={"sm"}
69 |         aria-label="missing models"
70 |         px={2}
71 |         onClick={() => setShowMyModels(true)}
72 |         colorScheme="teal"
73 |       >
74 |         Install Missing ({missingModels.length})
75 |       </Button>
76 |       {showMyModels && (
77 |         <MissingModelsListDrawer
78 |           onClose={() => 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<HTMLCanvasElement> & { 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 |     <Stack style={{ position: "relative" }}>
62 |       <Button
63 |         size={"sm"}
64 |         backgroundColor={"#434554"}
65 |         color={"white"}
66 |         colorScheme="blue"
67 |         aria-label="My models"
68 |         onClick={() => setRoute("modelList")}
69 |         px={1}
70 |         height={TOPBAR_BUTTON_HEIGHT + "px"}
71 |       >
72 |         Models
73 |       </Button>
74 |       {route === "modelList" && (
75 |         <ModelsListDrawer onClose={() => setRoute("root")} />
76 |       )}
77 |       {route === "installModels" && (
78 |         <InatallModelsModal
79 |           modelType="Checkpoint"
80 |           onclose={() => setRoute("root")}
81 |         />
82 |       )}
83 |     </Stack>
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 |     <Stack>
22 |       <Checkbox
23 |         isChecked={checked}
24 |         onChange={async (e) => {
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 |       </Checkbox>
37 |       {checked && (
38 |         <CommonNumberSetting
39 |           label="Auto save duration (auto save workflow every X seconds)"
40 |           settingKey="autoSaveDuration"
41 |         />
42 |       )}
43 |     </Stack>
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 |     <Stack>
32 |       <Text>
33 |         For enterprise paid user only. Hosting site of the shared workflow.
34 |       </Text>
35 |       <Flex gap={2} alignItems={"center"}>
36 |         <Input
37 |           value={text}
38 |           onChange={(e) => setText(e.target.value)}
39 |           onBlur={() => submitChange(text)}
40 |           onKeyDown={(e) => {
41 |             if (e.key === "Enter") {
42 |               submitChange(text);
43 |             }
44 |           }}
45 |         />
46 |         <Button
47 |           size={"sm"}
48 |           onClick={() => {
49 |             const cloudHost = userSettingsTable?.defaultSettings.cloudHost!;
50 |             setText(cloudHost);
51 |             submitChange(cloudHost);
52 |           }}
53 |         >
54 |           Reset
55 |         </Button>
56 |       </Flex>
57 |     </Stack>
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 |     <Stack>
32 |       <Checkbox
33 |         isChecked={checked}
34 |         onChange={async (e) => {
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 |       </Checkbox>
48 |     </Stack>
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<number>(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 |     <Stack>
55 |       <Text>{label}</Text>
56 | 
57 |       <NumberInput
58 |         defaultValue={200}
59 |         min={0}
60 |         keepWithinRange={true}
61 |         placeholder="Enter a number"
62 |         maxWidth={"300px"}
63 |         value={inputNumber}
64 |         onChange={(e) => onInputChange(e)}
65 |         onBlur={() => {
66 |           onBlur();
67 |         }}
68 |         onKeyUp={(e) => {
69 |           if (e.key === "Enter") {
70 |             onBlur();
71 |           }
72 |         }}
73 |       >
74 |         <NumberInputField />
75 |       </NumberInput>
76 |     </Stack>
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 |     <Stack gap={3}>
12 |       <Heading size={"md"}>🦄 Upgrade to 2.0 - enable two way sync!</Heading>
13 |       <p>Your workflows will be synced to and from path:</p>
14 |       <p>
15 |         <b>{myWorkflowsDir}</b>
16 |       </p>
17 |       <p>
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 |       </p>
22 |       <p>
23 |         <b>
24 |           Please download all your workflows before enabling two way sync as a
25 |           backup!
26 |         </b>{" "}
27 |         So you can manually import some workflows into your workspace in case
28 |         something unexpected happens.
29 |       </p>
30 |       <Button
31 |         colorScheme="teal"
32 |         variant={"outline"}
33 |         width={"fit-content"}
34 |         onClick={async () => {
35 |           const workflows = await workflowsTable?.listAll();
36 |           downloadWorkflowsZip(workflows ?? []);
37 |         }}
38 |       >
39 |         👉 Download All My Workflows
40 |       </Button>
41 |     </Stack>
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<HTMLInputElement>) => {
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 |     <Stack>
34 |       <Checkbox isChecked={checked} onChange={onFolderOnTopChange}>
35 |         Folders always on top
36 |       </Checkbox>
37 |     </Stack>
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 |     <Stack gap={3}>
28 |       <p style={{ fontWeight: "bold" }}>Cloud Backup [Beta]</p>
29 |       <p>
30 |         <a
31 |           href={userSettingsTable?.settings?.cloudHost + "/auth/shareKey"}
32 |           target="_blank"
33 |           style={{ textDecoration: "underline" }}
34 |         >
35 |           <Button size={"sm"}>👉Copy your share key from here</Button>
36 |         </a>{" "}
37 |         and paste it below to start saving workflow versions to cloud at{" "}
38 |         <a href={userSettingsTable?.settings?.cloudHost!} target="_blank">
39 |           {cloudDomain}
40 |         </a>
41 |       </p>
42 | 
43 |       <Flex alignItems={"center"}>
44 |         <Input
45 |           value={text}
46 |           placeholder="Paste your share key here"
47 |           onChange={(e) => setText(e.target.value)}
48 |           onBlur={() => submitChange(text)}
49 |           onKeyDown={(e) => {
50 |             if (e.key === "Enter") {
51 |               submitChange(text);
52 |             }
53 |           }}
54 |         />
55 |       </Flex>
56 |     </Stack>
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<HTMLInputElement>) => {
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 |     <Checkbox isChecked={checkedState} onChange={onShowThumbnailsChange}>
55 |       Show NSFW
56 |     </Checkbox>
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 |       <EnableTwowaySyncConfirm
34 |         myWorkflowsDir={myWorkflowsDir ?? "undefined"}
35 |       />,
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 |     <Stack>
51 |       <Text>
52 |         Only for legacy two way sync users to get back their data. [DO NOT
53 |         DISABLE]
54 |       </Text>
55 |       <Checkbox isChecked={checked} onChange={onTwoWaySyncChange}>
56 |         Enable two way sync
57 |       </Checkbox>
58 |     </Stack>
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 |     <HStack spacing={4} alignItems={"center"}>
18 |       <Text>{ver.name}</Text>
19 |       <Text color={"GrayText"}>{formatTimestamp(ver.createTime, false)}</Text>
20 |       {ver.cloudID && cloudWorkflowID && (
21 |         <a
22 |           href={cloudHost + "/workflow/" + cloudWorkflowID + "/" + ver.cloudID}
23 |           target="_blank"
24 |           rel="noopener noreferrer"
25 |           style={{ textDecoration: "none" }}
26 |         >
27 |           <Button
28 |             size={"xs"}
29 |             colorScheme="teal"
30 |             variant={"outline"}
31 |             leftIcon={<IconCloud />}
32 |             rightIcon={<IconExternalLink size={18} />}
33 |           >
34 |             Shared
35 |           </Button>
36 |         </a>
37 |       )}
38 |     </HStack>
39 |   );
40 |   if (!version.cloudID) {
41 |     return (
42 |       <Radio key={ver.id} value={ver.id} mb={5}>
43 |         {content}
44 |       </Radio>
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<string>();
11 |   const [privacy, setPrivacy] = useState<WorkflowPrivacy>();
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 |     <a href={cloudURL} style={{ textDecoration: "none" }} target="_blank">
32 |       <DarkMode>
33 |         <Tag>
34 |           {privacy === "PUBLIC" ? "🌐" : privacy === "UNLISTED" ? "🔗" : "🔒"}
35 |         </Tag>
36 |       </DarkMode>
37 |     </a>
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<WorkflowPrivacy>[] = [
 19 |   { label: "Private", value: "PRIVATE", icon: <IconLock /> },
 20 |   {
 21 |     label: "Unlisted, anyone with the link can view",
 22 |     value: "UNLISTED",
 23 |     icon: <IconLink />,
 24 |   },
 25 |   { label: "Public", value: "PUBLIC", icon: <IconWorld /> },
 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<WorkflowPrivacy> {
 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 <span>{text}</span>;
 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 <Spinner size="md" color="teal.400" />;
15 |   }
16 | 
17 |   return (
18 |     <Stack gap={5}>
19 |       {imageDepsArr.length > 0 && (
20 |         <Stack>
21 |           <HStack>
22 |             <Heading size={"sm"}>Images ({imageDepsArr.length})</Heading>
23 |             {/* <Tag colorScheme="yellow">Will be uploaded as url</Tag> */}
24 |             <p style={{ color: "GrayText" }}>Will be uploaded as url</p>
25 |           </HStack>
26 |           {uploadingImage && (
27 |             <span>
28 |               <Spinner size="md" color="teal.400" /> Uploading
29 |             </span>
30 |           )}
31 |           {imageDepsArr.map((image) => (
32 |             <Stack key={image.filename}>
33 |               <p>{image.filename}</p>
34 |               <Image
35 |                 width={250}
36 |                 src={`/workspace/view_media?filename=${image.filename}&isPreview=true&isInput=true`}
37 |               />
38 |             </Stack>
39 |           ))}
40 |         </Stack>
41 |       )}
42 |     </Stack>
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 |     <Tooltip label={"Save workflow"}>
36 |       <Button
37 |         size={"sm"}
38 |         variant={"outline"}
39 |         colorScheme="teal"
40 |         aria-label="new workflow"
41 |         height={TOPBAR_BUTTON_HEIGHT + "px"}
42 |         onClick={() => saveNewWorkflow()}
43 |         px={1}
44 |       >
45 |         <IconDeviceFloppy size={16} color={"white"} />
46 |       </Button>
47 |     </Tooltip>
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 |     <Flex alignItems="center">
19 |       <ButtonGroup size="sm" isAttached variant="outline">
20 |         {/* <IconButton aria-label="Add to friends" icon={<IconPlus size={18} />} /> */}
21 |         <Button
22 |           rightIcon={<IconTriangleInvertedFilled size={10} />}
23 |           onClick={() => setRoute("versionHistory")}
24 |         >
25 |           {curVersion.name}
26 |         </Button>
27 |       </ButtonGroup>
28 |     </Flex>
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<void>;
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<void>;
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<string, ComfyObjectInfo>,
26 |     app: ComfyApp,
27 |   ): Promise<void>;
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<void>;
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<void>;
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<string, ComfyObjectInfoConfig>;
85 |     optional?: Record<string, ComfyObjectInfoConfig>;
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<ShortcutTriggerDetail>;
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<Workflow>) => {
 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<T extends ImageLike>(
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 |     <Modal isOpen={true} onClose={onClose} size={"xl"}>
26 |       <ModalContent width={"90vw"}>
27 |         <ModalBody>
28 |           <Stack p={"10px 10px"} gap={3}>
29 |             <h2
30 |               style={{
31 |                 fontSize: "1.2rem",
32 |                 fontWeight: "bold",
33 |               }}
34 |             >
35 |               ✨New Version Control Exprience [beta]
36 |             </h2>
37 |             <p>
38 |               We have a new version control experience. Now your versions will
39 |               be stored <b>privately</b> and securely in cloud at{" "}
40 |               <a href="www.nodecafe.co">www.nodecafe.co</a>
41 |             </p>
42 |             <h3 style={{ fontWeight: "bold" }}>Why cloud?</h3>
43 |             <p>
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 |             </p>
50 |             <h3 style={{ fontWeight: "bold" }}>🤩Get Started</h3>
51 |             <Flex alignItems={"center"} gap={2} mb={3}>
52 |               <a href={cloudHost + "/auth/shareKey"} target="_blank">
53 |                 <Button colorScheme="blue">Login</Button>
54 |               </a>
55 |               <p> then copy your share key to below</p>
56 |             </Flex>
57 |             <Input
58 |               placeholder="Paste your share key here"
59 |               onChange={(e) => {
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 |           </Stack>
81 |         </ModalBody>
82 |       </ModalContent>
83 |     </Modal>
84 |   );
85 | }
86 | 


--------------------------------------------------------------------------------
/ui/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | /// <reference types="vite/client" />
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 | 


--------------------------------------------------------------------------------