61 changed files with 1684 additions and 836 deletions
-20
View File
@@ -1,20 +0,0 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
+31 -12
View File
@@ -4,12 +4,13 @@ A ComfyUI workflow and model manager extension to **organize and manage all your
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
[New!] We provide 👭**team workspace** now! If you need to **share workflows, backup workflow versions for enterprise and teams**, please contact me about our team/enterprise product at weixuanfu01@gmail.com or join discord and DM me @briefpeach
[New!] we're building a comfyui custom nodes library wiki like Pypi: https://www.nodecafe.org/
welcome any feedbacks!
🔴‼️ If you updated or cloned this repo between March 14-17, 2024, please update it again to latest verison, there were some bugs that may cause you lose changes in Auto Save mode.
‼️ [3-23-2024]**search and install models from civitai is not working in previous versions**, because civitai changed something in their api. **Please update to the latest version** if you are seeing blank screen in Install Models dialog
[5-7-2024] Author (me) is job hunting in Beijing 🥲 so recently maybe too busy to update this plugin. I will come back to updating this plugin as soon as I finish job hunting 🥲🫠
<img width="610" alt="Screenshot 2024-03-08 at 6 41 18 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/30455eb1-3d12-4930-bb81-28b33148fbaa">
## Installation
@@ -20,14 +21,15 @@ Simply search for “ComfyUI Workspace Manager”, install, and restart ComfyUI.
### [Option 2] via git
Like any other custom node installation, in your ComfyUI root folder run command and restart your ComfyUI.
Like any other custom node installation, in your ComfyUI root folder
```
cd custom_nodes && git clone https://github.com/11cafe/comfyui-workspace-manager.git
```
‼️ Be careful with Auto Save mode as it is constantly overwriting/saving your workflow, which can lead to unwanted overwriting of your precious work if not using carefully. If you disabled Auto Save, make sure you don't forget to Shift+S to save your workflows.
and restart your ComfyUI.
It is recommended to do git clone than downloading zip folder, because you can get latest updates by `git pull`!
## Features
@@ -39,7 +41,7 @@ cd custom_nodes && git clone https://github.com/11cafe/comfyui-workspace-manager
- 📂Save and sync all your workflows in a local folder (by default under `/ComfyUI/my_workflows` customize in Settings)
- **Save workflow by pressing the 💾 button, or ⌨Shift+S shortcut** (customize in Settings).
- Every time you save, it will create a new record in **Version History**. You can easily revert to any version.
- **Enable/disable autosave workflow** (please disable autosave if you are experiencing UI slowing)
- **Enable/disable autosave workflow** (please disable autosave if you are experiencing UI lagging)
- You can move files using OS File Explorer and it will be **two-way synced** with workspace.
- 🕛**Version control** of workflow, create/switch versions
- Bulk import workflows into your workspace
@@ -61,8 +63,17 @@ Recommend using Chrome browser for the best experience. Safari doesn't work well
[中文版教程](https://www.uisdc.com/workspace-manager) &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [Roadmap](https://github.com/orgs/11cafe/projects/1/views/1)
## Updates
**🦩v2.1 New gallery! Inspect image metadata, search image by prompt, model/lora used**
# <<<<<<< HEAD
🚧🔴 Drag and drop to insert subworkflow is not working. I have debugged for many hours but just can't make it work perfectly. If anyone is interested here is the code:
https://github.com/11cafe/comfyui-workspace-manager/blob/d2d2f0c1f1ace35236e73fa5074e9feb83342773/ui/src/utils.tsx#L192
I would really appreciate it if someone can find a solution to insert a subworkflow to current canvas given the json object.
### 🦄v2.0 beta: Two-way sync with local folder, select model install path, cloud sync workflows
> > > > > > > dev
**🦩v2.1 New gallery coming soon! Inspect image metadata, search image by prompt, model/lora used**
<img width="700" alt="Screenshot 2024-03-23 at 12 58 38 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/5bac9793-7491-4131-b850-037094d03823">
@@ -70,9 +81,13 @@ Control+P quick spotlight search (like VSCode)
<img width="500" alt="Screenshot 2024-03-27 at 3 49 35 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/47c14904-180c-4c52-a644-223d0936c9fa">
**🦄v2.0 Two-way sync with local folder, models manager support external models path like Automatic1111, cloud sync workflows**
v2.0 demo: https://youtu.be/7Hht_BMe844
**🦄v2.0 Two-way sync with local folder, select model install path, cloud sync workflows**
- Two-way sync with selected local folder so you can move files using File Explore or Finder to sync them with workspace!
- **Models manager support external models path like Automatic1111**! Choose models downloading folder from added external paths.
- Add models thumbnails image by putting a image file with the same name in models folder like A1111
- One click sharing to cloud to backup your workflows to cloud
v2.0 demo: https://youtu.be/7Hht_BMe844
**v1.7 ComfyUI model manager, view your installed models, install models from civit ai, open workflow in multiple browser tabs**
@@ -82,7 +97,7 @@ v2.0 demo: https://youtu.be/7Hht_BMe844
<img width="200" alt="Screenshot 2024-01-23 at 12 26 56 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/d29d0831-a15a-4597-acf6-facee4dbc76e">
## Usage
<img width="460" alt="gallery" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/2b7886c9-2d4a-46c6-adac-429720f74f11">
switch between flows:
@@ -96,6 +111,10 @@ Bulk import flows:
<img width="400" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/884c8852-73c7-41d6-9179-7fb1fcea9d04">
add tags to flow:
<img width="400" alt="Screenshot 2023-12-13 at 1 54 04 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/6bf4d22b-f52a-4d23-8882-4a31162616f3">
Workflow operations: Manual Save, Download, Discard unsaved changes:
<img width="500" alt="Screenshot 2023-12-29 at 9 59 57 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/88c82672-ebf2-4feb-bf5b-74b447843c2c">
+15 -13
View File
@@ -5,18 +5,17 @@ import folder_paths
import shutil
import os
import sys
import subprocess
import subprocess # don't remove this
from urllib.parse import urlparse
import subprocess
import os
import json
from .service.model_manager.model_installer import download_url_with_wget
from .service.model_manager.model_list import get_model_list
from .service.media_service import *
from .service.file_sync_service import *
from .service.db_service import get_my_workflows_dir
from .service.node_service import *
try:
from send2trash import send2trash
except ImportError:
send2trash = None
WEB_DIRECTORY = "entry"
NODE_CLASS_MAPPINGS = {}
@@ -113,28 +112,31 @@ async def update_file(request):
await asyncio.to_thread(write_json_to_file, json_str)
return web.Response(text="File updated successfully")
@server.PromptServer.instance.routes.post("/workspace/delete_file")
async def delete_file(request):
data = await request.json()
file_path = data['file_path']
delete_empty_folder = data['deleteEmptyFolder']
def sync_delete_file(file_path):
def sync_delete_file(file_path, delete_empty_folder):
my_workflows_dir = get_my_workflows_dir()
full_path = os.path.join(my_workflows_dir, file_path)
if os.path.exists(full_path):
if send2trash:
send2trash(full_path)
os.remove(full_path)
directory = os.path.dirname(full_path)
if delete_empty_folder and not os.listdir(directory):
# If the directory is empty, remove the directory
os.rmdir(directory)
return "File and empty directory deleted successfully"
else:
os.remove(full_path)
print("❌⛔️send2trash is not available. Deleting file permanently. Please `pip install send2trash`")
return "File deleted successfully"
return "File deleted successfully"
else:
return "File not found"
# Run the synchronous file operation in a separate thread
response_text = await asyncio.to_thread(sync_delete_file, file_path)
response_text = await asyncio.to_thread(sync_delete_file, file_path, delete_empty_folder)
if response_text == "File not found":
return web.Response(text=response_text, status=404)
-13
View File
@@ -1,13 +0,0 @@
[project]
name = "comfyui-workspace-manager"
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."
version = "1.0.0"
license = "LICENSE"
[project.urls]
Repository = "https://github.com/11cafe/comfyui-workspace-manager"
[tool.comfy]
PublisherId = "weixuan11"
DisplayName = "comfyui-workspace-manager"
Icon = ""
-1
View File
@@ -1 +0,0 @@
send2trash
+2 -9
View File
@@ -136,11 +136,7 @@ def read_workflow_file(path, id):
def check_and_update_workflow_id(file_path, seen_ids):
with open(file_path, "r", encoding="utf-8") as f:
try:
json_data = json.load(f)
except json.JSONDecodeError as e:
print(f"Error reading JSON from {file_path}: {e}")
return
json_data = json.load(f)
if (
"extra" in json_data
and "workspace_info" in json_data["extra"]
@@ -176,10 +172,7 @@ def dedupe_workflow_ids():
if not file.endswith(".json"):
continue
file_path = os.path.join(root, file)
try:
check_and_update_workflow_id(file_path, seen_ids)
except Exception as e:
print(f"Error while processing file {file_path}: {e}")
check_and_update_workflow_id(file_path, seen_ids)
@server.PromptServer.instance.routes.get('/workspace/deduplicate_workflow_ids')
async def deduplicate_workflow_ids(request):
+1 -1
View File
@@ -9,7 +9,7 @@ from .model_manager.model_preview import get_thumbnail_for_image_file
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
video_extensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']
API_URL = 'https://www.nodecafe.co/api/image/upload'
API_URL = 'https://www.nodecafe.org/api/image/upload'
def view_media(filename, isPreview = False, isInput = False):
if not filename:
return web.Response(status=404)
+1 -1
View File
@@ -18,7 +18,7 @@ if not os.path.exists(FILE_HASH_DICT_FOLDER_PATH):
os.makedirs(FILE_HASH_DICT_FOLDER_PATH)
# Early open shelve for better performance
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict_early_open:
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
print(f"Workspace manager - Openning file hash dict")
file_list = []
+2 -9
View File
@@ -3,8 +3,7 @@ from aiohttp import web
import asyncio
import json
import os
import traceback
import logging
import glob
from threading import Lock
import server
import uuid
@@ -42,7 +41,7 @@ def folder_handle(path, recursive, metaInfoOnly, fileList=None):
if recursive:
folder_handle(item_path, recursive, metaInfoOnly, fileList)
except Exception as e:
logging.error(f"Error scan workflow {item_path}: {e}, {traceback.format_exc()}")
print(f"Error scanning item {item}: {e}")
return fileList
def file_handle(name, fileList, file_path, metaInfoOnly):
@@ -52,9 +51,6 @@ def file_handle(name, fileList, file_path, metaInfoOnly):
createTime, updateTime = getFileCreateTime(file_path)
workspace_info = json_data.get('extra', {}).get('workspace_info', {})
workflow_id = workspace_info.get('id', str(uuid.uuid4()))
saveLock = workspace_info.get('saveLock', False)
cloudID = workspace_info.get('cloudID', None)
coverMediaPath = workspace_info.get('coverMediaPath', None)
# Update JSON data with new ID if needed and write back to file
if 'id' not in workspace_info:
if 'extra' not in json_data:
@@ -69,9 +65,6 @@ def file_handle(name, fileList, file_path, metaInfoOnly):
'name': name,
'type': "workflow",
'id': workflow_id,
'saveLock': saveLock,
'cloudID': cloudID,
'coverMediaPath': coverMediaPath,
'createTime': createTime,
'updateTime': updateTime
}
+1 -9
View File
@@ -5,10 +5,6 @@ import os
from pathlib import Path
import server
from .db_service import get_my_workflows_dir
try:
from send2trash import send2trash
except ImportError:
send2trash = None
@server.PromptServer.instance.routes.post('/workspace/folder/create')
async def create_folder(request):
@@ -35,11 +31,7 @@ def delete_folder_sync(reqJson):
folder_path = reqJson.get('path')
folder_path = os.path.join(get_my_workflows_dir(), folder_path)
try:
if send2trash:
send2trash(folder_path)
else:
shutil.rmtree(folder_path)
print("❌⛔️send2trash is not available. Deleting file permanently. Please `pip install send2trash`")
shutil.rmtree(folder_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"setupGithooks": "node ../scripts/setupGitHooks"
"setupGithooks": "node ../scripts/setupGithooks"
},
"dependencies": {
"@chakra-ui/react": "^2.8.2",
+13 -25
View File
@@ -1,22 +1,10 @@
import { Table } from "./db-tables/WorkspaceDB";
import type { ModelsListRespItem } from "./model-manager/types";
import { api } from "./utils/comfyapp";
export function fetchApi(
route: string,
options?: RequestInit,
): Promise<Response> {
if (api == null) {
console.error("api is null!");
throw new Error("api is null!");
}
return api.fetchApi(route, options);
}
export async function getDB(table: Table): Promise<string | undefined> {
console.warn("[workspace deprecated] getDB is deprecated", table);
try {
const response = await fetchApi(`/workspace/get_db?table=${table}`);
const response = await fetch(`/workspace/get_db?table=${table}`);
if (!response.ok) {
return undefined;
}
@@ -30,7 +18,7 @@ export async function getDB(table: Table): Promise<string | undefined> {
export async function saveDB(table: Table, jsonData: string) {
try {
const response = await fetchApi("/workspace/save_db", {
const response = await fetch("/workspace/save_db", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -46,7 +34,7 @@ export async function saveDB(table: Table, jsonData: string) {
export async function updateFile(file_path: string, jsonData: string) {
try {
const response = await fetchApi("/workspace/update_file", {
const response = await fetch("/workspace/update_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -66,7 +54,7 @@ export async function updateFile(file_path: string, jsonData: string) {
export async function deleteFile(file_path: string, deleteEmptyFolder = false) {
try {
const response = await fetchApi("/workspace/delete_file", {
const response = await fetch("/workspace/delete_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -85,7 +73,7 @@ export async function deleteFile(file_path: string, deleteEmptyFolder = false) {
export async function listBackup(dir: string) {
try {
const response = await fetchApi("/workspace/list_backup", {
const response = await fetch("/workspace/list_backup", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -103,7 +91,7 @@ export async function listBackup(dir: string) {
export async function getSystemDir(root?: string) {
try {
const response = await fetchApi("/workspace/get_system_dir", {
const response = await fetch("/workspace/get_system_dir", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -121,7 +109,7 @@ export async function getSystemDir(root?: string) {
export async function openWorkflowsFolder() {
try {
const response = await fetchApi("/workspace/open_workflow_file_browser", {
const response = await fetch("/workspace/open_workflow_file_browser", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -135,7 +123,7 @@ export async function openWorkflowsFolder() {
}
export async function fetchMyWorkflowsDir() {
const resp = await fetchApi("/workspace/get_my_workflows_dir");
const resp = await fetch("/workspace/get_my_workflows_dir");
const res = (await resp.json()) as {
path?: string;
error?: string;
@@ -149,7 +137,7 @@ export async function fetchMyWorkflowsDir() {
export async function getAllModelsList() {
try {
const response = await fetchApi("/model_manager/get_model_list", {
const response = await fetch("/model_manager/get_model_list", {
method: "GET",
headers: {
"Content-Type": "application/json",
@@ -164,7 +152,7 @@ export async function getAllModelsList() {
export async function getAllFoldersList() {
try {
const response = await fetchApi("/model_manager/get_folder_list", {
const response = await fetch("/model_manager/get_folder_list", {
method: "GET",
headers: {
"Content-Type": "application/json",
@@ -179,7 +167,7 @@ export async function getAllFoldersList() {
export async function deleteLocalDiskFolder(folderPath: string) {
try {
const response = await fetchApi("/workspace/delete_folder", {
const response = await fetch("/workspace/delete_folder", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -197,7 +185,7 @@ export async function deleteLocalDiskFolder(folderPath: string) {
export async function cancelDownload(savePath: string) {
try {
const response = await fetchApi("/model_manager/cancel_installation", {
const response = await fetch("/model_manager/cancel_installation", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -218,7 +206,7 @@ export async function copyFlowsToNewDirectory(
dstDir: string,
) {
try {
const response = await fetchApi("/workspace/copy_json_files", {
const response = await fetch("/workspace/copy_json_files", {
method: "POST",
headers: {
"Content-Type": "application/json",
+212 -103
View File
@@ -1,4 +1,15 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import {
Suspense,
useCallback,
useEffect,
useRef,
useState,
DragEvent,
} from "react";
// @ts-ignore
import { app } from "/scripts/app.js";
// @ts-ignore
import { api } from "/scripts/api.js";
import { Box, Portal, useToast } from "@chakra-ui/react";
import {
loadDBs,
@@ -25,25 +36,23 @@ const RecentFilesDrawer = React.lazy(
);
const GalleryModal = React.lazy(() => import("./gallery/GalleryModal"));
import { IconExternalLink } from "@tabler/icons-react";
import {
COMFYSPACE_TRACKING_FIELD_NAME,
DRAWER_Z_INDEX,
UPGRADE_TO_2WAY_SYNC_KEY,
} from "./const";
import { DRAWER_Z_INDEX, UPGRADE_TO_2WAY_SYNC_KEY } from "./const";
import ServerEventListener from "./model-manager/hooks/ServerEventListener";
import { nanoid } from "nanoid";
import { WorkspaceRoute } from "./types/types";
import { useStateRef } from "./customHooks/useStateRef";
import { indexdb } from "./db-tables/indexdb";
import EnableTwowaySyncConfirm from "./settings/EnableTwowaySyncConfirm";
import { api, app } from "./utils/comfyapp";
import { fetchApi } from "./Api";
import { deepJsonDiffCheck } from "./utils/deepJsonDiffCheck";
import {
OPEN_TAB_EVENT,
tabDataManager,
} from "./topbar/multipleTabs/TabDataManager";
export default function App() {
const [curFlowName, setCurFlowName] = useState<string | null>(null);
const [route, setRoute] = useState<WorkspaceRoute>("root");
const [loadingDB, setLoadingDB] = useState(true);
const [flowID, setFlowID] = useState<string | null>(null);
const curFlowID = useRef<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
const workspaceContainerRef = useRef(null);
@@ -52,10 +61,10 @@ export default function App() {
const developmentEnvLoadFirst = useRef(false);
const toast = useToast();
const [curVersion, setCurVersion] = useStateRef<WorkflowVersion | null>(null);
const saveCurWorkflow = useCallback(async (force = false) => {
const curWorkflow = workflowsTable?.curWorkflow;
if (curWorkflow) {
if (!force && workflowsTable?.curWorkflow?.saveLock) {
const saveCurWorkflow = useCallback(async (newGraphJson?: string) => {
const flowId = workflowsTable?.curWorkflow?.id;
if (flowId) {
if (workflowsTable?.curWorkflow?.saveLock) {
toast({
title: "The workflow is locked and cannot be saved",
status: "warning",
@@ -63,13 +72,14 @@ export default function App() {
});
return;
}
const graphJson = JSON.stringify(app.graph.serialize());
!newGraphJson && setIsDirty(false);
const graphJson = newGraphJson || JSON.stringify(app.graph.serialize());
await Promise.all([
workflowsTable?.updateFlow(curWorkflow.id, {
workflowsTable?.updateFlow(flowId, {
json: graphJson,
}),
changelogsTable?.create({
workflowID: curWorkflow.id,
workflowID: flowId,
json: graphJson,
isAutoSave: false,
}),
@@ -81,7 +91,6 @@ export default function App() {
duration: 1000,
isClosable: true,
});
setIsDirty(false);
}
}, []);
@@ -116,9 +125,7 @@ export default function App() {
// curID null is when you deleted current workflow
const id = workflow?.id ?? null;
workflowsTable?.updateCurWorkflow(workflow);
curFlowID.current = id;
setFlowID(id);
setCurFlowName(workflow?.name ?? "");
if (id == null) {
document.title = "ComfyUI";
window.location.hash = "";
@@ -148,67 +155,80 @@ export default function App() {
}
setLoadingDB(false);
const urlWorkflowID = getWorkflowIdInUrlHash();
if (urlWorkflowID == null) {
const latestWf = localStorage.getItem("workflow");
if (latestWf) {
const workflow = JSON.parse(latestWf);
const id = workflow.extra?.[COMFYSPACE_TRACKING_FIELD_NAME]?.id;
if (id) {
const flow = await workflowsTable?.get(id);
flow && setCurFlowIDAndName(flow);
if (!flow?.saveLock && flow && flow.json != latestWf) {
setIsDirty(true);
}
}
}
} else {
loadWorkflowIDImpl(urlWorkflowID);
let latestWfID = null;
latestWfID = getWorkflowIdInUrlHash();
if (latestWfID == null) {
latestWfID = localStorage.getItem("curFlowID");
}
fetchApi("/workspace/deduplicate_workflow_ids");
if (latestWfID) {
loadWorkflowIDImpl(latestWfID!, null, true);
}
fetch("/workspace/deduplicate_workflow_ids");
const twoway = await userSettingsTable?.getSetting("twoWaySync");
if (!twoway) {
const workflowsCount = await indexdb.workflows.count();
if (workflowsCount == 0) {
// empty workflows, enable 2way sync
await userSettingsTable?.upsert({ twoWaySync: true });
await indexdb.cache.put({
id: UPGRADE_TO_2WAY_SYNC_KEY,
value: "true",
});
return;
}
const myWorkflowsDir =
await userSettingsTable?.getSetting("myWorkflowsDir");
showDialog(
<EnableTwowaySyncConfirm
myWorkflowsDir={myWorkflowsDir ?? "undefined"}
/>,
[
{
label: "I have downloaded all my workflows and ready to enable",
onClick: async () => {
await userSettingsTable?.upsert({ twoWaySync: true });
if (await userSettingsTable?.getSetting("twoWaySync")) {
indexdb.cache.put({
id: UPGRADE_TO_2WAY_SYNC_KEY,
value: "true",
});
location.reload();
}
!twoway &&
indexdb.cache.get(UPGRADE_TO_2WAY_SYNC_KEY).then(async (value) => {
if (value?.value !== "true") {
const workflowsCount = await indexdb.workflows.count();
if (workflowsCount == 0) {
// empty workflows, enable 2way sync
await userSettingsTable?.upsert({ twoWaySync: true });
await indexdb.cache.put({
id: UPGRADE_TO_2WAY_SYNC_KEY,
value: "true",
});
return;
}
const myWorkflowsDir =
await userSettingsTable?.getSetting("myWorkflowsDir");
showDialog(
<EnableTwowaySyncConfirm
myWorkflowsDir={myWorkflowsDir ?? "undefined"}
/>,
[
{
label: "I have downloaded all my workflows and ready to enable",
onClick: async () => {
await userSettingsTable?.upsert({ twoWaySync: true });
if (await userSettingsTable?.getSetting("twoWaySync")) {
indexdb.cache.put({
id: UPGRADE_TO_2WAY_SYNC_KEY,
value: "true",
});
location.reload();
}
},
colorScheme: "red",
},
],
{
closeOnOverlayClick: false,
},
colorScheme: "red",
},
],
{
closeOnOverlayClick: false,
},
);
}
);
}
});
};
const loadWorkflowIDImpl = async (id: string, versionID?: string | null) => {
const checkIsDirty = () => {
if (workflowsTable?.curWorkflow?.id == null) return false;
const curflow = workflowsTable?.curWorkflow;
if (curflow == null) return false;
if (curflow.json == null) return true;
const graphJson = app.graph.serialize() ?? {};
let lastSaved = {};
try {
lastSaved = curflow?.json ? JSON.parse(curflow?.json) : {};
} catch (e) {
console.error("error parsing json", e);
}
const equal = deepJsonDiffCheck(graphJson, lastSaved);
return !equal;
};
const loadWorkflowIDImpl = async (
id: string,
versionID?: string | null,
isInit?: boolean,
) => {
if (app.graph == null) {
return;
}
@@ -223,27 +243,55 @@ export default function App() {
? await workflowVersionsTable?.get(versionID)
: null;
app.ui.dialog.close();
if (version) {
setCurVersion(version);
setCurFlowIDAndName({
...flow,
json: version.json,
});
app.loadGraphData(JSON.parse(version.json));
} else {
setCurFlowIDAndName(flow);
setCurVersion(null);
app.loadGraphData(JSON.parse(flow.json));
setCurVersion(version ?? null);
const tabIndex = tabDataManager.tabs.findIndex((tab) => tab.id === id);
if (tabIndex !== -1) {
tabDataManager.changeActiveTab(tabIndex);
if (version) {
tabDataManager.updateTabData(tabIndex, { json: version.json });
}
return;
}
app.ui.dialog.close();
setRoute("root");
isDirty && setIsDirty(false);
const newTabInfo = {
id: flow.id,
name: flow.name,
json: version ? version.json : flow.json,
isDirty: false,
};
/**
* During initialization,
* because the event listener of OPEN_TAB_EVENT has not been registered yet,
* the newly added tab will be invalid,
* so the tab is added manually during initialization.
*/
if (isInit) {
tabDataManager.addTabData(newTabInfo);
app.loadGraphData(JSON.parse(newTabInfo.json));
setCurFlowIDAndName({
...flow,
json: newTabInfo.json,
});
} else {
document.dispatchEvent(
new CustomEvent(OPEN_TAB_EVENT, {
detail: {
tabInfo: newTabInfo,
},
}),
);
}
};
const loadWorkflowID = async (
id: string | null,
versionID?: string | null,
forceLoad: boolean = false,
) => {
// No current workflow, id is null when you deleted current workflow
if (id === null) {
@@ -251,18 +299,10 @@ export default function App() {
app.graph.clear();
return;
}
if (
!isDirty ||
forceLoad ||
userSettingsTable?.settings?.autoSave ||
workflowsTable?.curWorkflow == null
) {
loadWorkflowIDImpl(id, versionID);
return;
}
// prompt when auto save disabled and dirty
showSaveOrDiscardCurWorkflowDialog(id);
loadWorkflowIDImpl(id, versionID);
};
const showSaveOrDiscardCurWorkflowDialog = async (newIDToLoad?: string) => {
const buttons = [
newIDToLoad
@@ -337,18 +377,19 @@ export default function App() {
json: workflow.json,
name: newFlowName || workflow.name,
parentFolderID: workflow.parentFolderID,
tags: workflow.tags,
});
flow && (await loadWorkflowID(flow.id));
};
const onExecutedCreateMedia = useCallback((image: any) => {
if (curFlowID.current == null) return;
if (workflowsTable?.curWorkflow?.id == null) return;
let path = image.filename;
if (image.subfolder != null && image.subfolder !== "") {
path = image.subfolder + "/" + path;
}
mediaTable?.create({
workflowID: curFlowID.current,
workflowID: workflowsTable?.curWorkflow?.id,
localPath: path,
});
}, []);
@@ -374,6 +415,42 @@ export default function App() {
graphAppSetup();
setLoadChild(true);
const fileInput = document.getElementById(
"comfy-file-input",
) as HTMLInputElement;
const fileInputListener = async () => {
if (fileInput && fileInput.files && fileInput.files.length > 0) {
const newFlow: Workflow = {
id: nanoid(),
name: fileInput.files[0].name,
json: JSON.stringify(defaultGraph),
updateTime: Date.now(),
createTime: Date.now(),
};
setCurFlowIDAndName(newFlow);
const res = await workflowsTable?.createFlow(newFlow);
res &&
tabDataManager.updateTabData(tabDataManager.activeIndex, {
id: res.id,
name: res.name,
});
}
};
fileInput?.addEventListener("change", fileInputListener);
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const autoSaveEnabled = userSettingsTable?.settings?.autoSave ?? true;
if (workflowsTable?.curWorkflow?.saveLock) return;
const isDirty = checkIsDirty();
if (!autoSaveEnabled && isDirty) {
e.preventDefault(); // For modern browsers
e.returnValue = "You have unsaved changes!"; // For older browsers
showSaveOrDiscardCurWorkflowDialog();
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
const handleExecuted = (e: any) => {
e.detail?.output?.images?.forEach(
(im: { filename: string; subfolder: string; type: string }) => {
@@ -392,8 +469,40 @@ export default function App() {
};
api.addEventListener("executed", handleExecuted);
const handleDrop = async (event: DragEvent) => {
const fileName = event.dataTransfer?.files[0]?.name;
const n = app.dragOverNode;
if (n && n.onDragDrop) {
return;
}
const overwriteFlow =
(await userSettingsTable?.getSetting(
"overwriteCurWorkflowWhenDroppingFileToCanvas",
)) ?? false;
if (!overwriteFlow && fileName) {
const newFlow: Workflow = {
id: nanoid(),
name: fileName,
json: JSON.stringify(defaultGraph),
updateTime: Date.now(),
createTime: Date.now(),
};
setCurFlowIDAndName(newFlow);
const res = await workflowsTable?.createFlow(newFlow);
res &&
tabDataManager.updateTabData(tabDataManager.activeIndex, {
id: res.id,
name: res.name,
});
}
};
app.canvasEl.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("change", fileInputListener);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("executed", handleExecuted);
app.canvasEl.removeEventListener("drop", handleDrop);
};
}, []);
@@ -432,7 +541,7 @@ export default function App() {
zIndex={DRAWER_Z_INDEX}
draggable={false}
>
<Topbar curFlowName={curFlowName} setCurFlowName={setCurFlowName} />
<Topbar />
{loadChild && route === "recentFlows" && (
<Suspense>
<RecentFilesDrawer
+1 -2
View File
@@ -21,7 +21,6 @@ import { Workflow } from "../types/dbTypes";
import { mediaTable, workflowsTable } from "../db-tables/WorkspaceDB";
import { WorkspaceContext } from "../WorkspaceContext";
import { openWorkflowInNewTab } from "../utils";
import { fetchApi } from "../Api";
type Props = {
workflow: Workflow;
@@ -56,7 +55,7 @@ export default function MoreActionMenu({ workflow }: Props) {
body.append(`images[${index}]`, file, file.name);
});
body.append("subfolder", "workspace_manager");
await fetchApi("/workspace/images/save", {
await fetch("/workspace/images/save", {
method: "POST",
body,
});
+18 -23
View File
@@ -30,6 +30,8 @@ import RecentFilesDrawerMenu from "./RecentFilesDrawerMenu";
import { insertWorkflowToCanvas, sortFileItem } from "../utils";
import MultipleSelectionOperation from "./MultipleSelectionOperation";
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
// @ts-expect-error ComfyUI import
import { app } from "/scripts/app.js";
import { useDebounce } from "../customHooks/useDebounce";
import SearchInput from "../components/SearchInput";
import { openWorkflowsFolder } from "../Api";
@@ -38,7 +40,6 @@ import MyTagsRow from "./MyTagsRow";
import ImportFlowsFileInput from "./ImportFlowsFileInput";
import ItemsList from "./ItemsList";
import { DRAWER_Z_INDEX } from "../const";
import { app } from "../utils/comfyapp";
type Props = {
onClose: () => void;
@@ -50,7 +51,6 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
Array<Folder | Workflow>
>([]);
const allFlowsRef = useRef<Array<Workflow>>([]);
const { loadWorkflowID, curFlowID } = useContext(WorkspaceContext);
const [selectedTag, setSelectedTag] = useState<string>();
const [multipleState, setMultipleState] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
@@ -112,9 +112,6 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
const onDelete = useCallback(
async (id: string) => {
await workflowsTable?.deleteFlow(id);
if (workflowsTable?.curWorkflow?.id === id) {
await loadWorkflowID?.(null);
}
await loadLatestWorkflows();
},
[selectedTag, debounceSearchValue],
@@ -154,12 +151,6 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
const batchOperationCallback = (type: string, value: unknown) => {
switch (type) {
case "batchDelete":
curFlowID &&
workflowsTable?.get(curFlowID).then((res) => {
if (!res) {
loadWorkflowID?.(null);
}
});
loadLatestWorkflows();
setMultipleState(false);
setSelectedKeys([]);
@@ -257,18 +248,22 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
/>
<HStack mb={2} mt={2} p={0} justifyContent="space-between">
<HStack>
<MultipleSelectionOperation
multipleState={multipleState}
changeMultipleState={(state) => {
setMultipleState(state);
!state && setSelectedKeys([]);
}}
selectedKeys={selectedKeys}
isSelectedAll={
selectedKeys.length === allFlowsRef.current.length
}
batchOperationCallback={batchOperationCallback}
/>
{allFlowsRef.current.length > 0 ? (
<MultipleSelectionOperation
multipleState={multipleState}
changeMultipleState={(state) => {
setMultipleState(state);
!state && setSelectedKeys([]);
}}
selectedKeys={selectedKeys}
isSelectedAll={
selectedKeys.length === allFlowsRef.current.length
}
batchOperationCallback={batchOperationCallback}
/>
) : (
<Box />
)}
{!multipleState && (
<Tooltip hasArrow label="New folder" placement="bottom-start">
<IconButton
@@ -89,7 +89,7 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
w={"100%"}
justify={"space-between"}
borderBottom={"1px solid"}
borderColor={colorMode == "dark" ? "gray.700" : "gray.200"}
borderColor={colorMode == "dark" ? "gray.900" : "gray.200"}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
@@ -111,7 +111,7 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
{isHovering ? (
<WorkflowListItemActionButtons workflow={workflow} />
) : (
<Text color={"GrayText"} fontSize={"sm"}>
<Text color={"GrayText"} ml={"2px"} fontSize={"sm"}>
{formatTimestamp(workflow.updateTime, false, false, "/")}
</Text>
)}
@@ -16,7 +16,6 @@ import CommonCheckboxSettings from "../settings/CommonCheckboxSettings";
import SelectMyWorkflowsDir from "../settings/SelectMyWorkflowsDir";
import { CommonNumberSetting } from "../settings/CommonNumberSetting";
import AutosaveSetting from "../settings/AutosaveSetting";
import CloudHostSetting from "../settings/CloudHostSetting";
export default function WorkspaceSettingsModal({
onClose,
@@ -57,7 +56,6 @@ export default function WorkspaceSettingsModal({
settingKey="hideCoverImage"
text="Hide thumbnail image in workflow list"
/>
<CloudHostSetting />
</VStack>
</HStack>
</ModalBody>
+3 -7
View File
@@ -9,13 +9,9 @@ export type JsonDiff = {
export const WorkspaceContext = createContext<{
curFlowID: string | null;
onDuplicateWorkflow?: (flowID: string, newFlowName?: string) => void;
loadWorkflowID: (
id: string | null,
versionID?: string | null,
forceLoad?: boolean,
) => void;
loadWorkflowID: (id: string | null, versionID?: string | null) => void;
setIsDirty: (dirty: boolean) => void;
saveCurWorkflow: (force?: boolean) => Promise<void>;
saveCurWorkflow: (saveCurWorkflow?: string) => Promise<void>;
discardUnsavedChanges: () => Promise<void>;
isDirty: boolean;
loadNewWorkflow: (input?: { json: string; name?: string }) => void;
@@ -24,7 +20,7 @@ export const WorkspaceContext = createContext<{
route: WorkspaceRoute;
curVersion: WorkflowVersion | null;
setCurVersion: (version: WorkflowVersion | null) => void;
setCurFlowIDAndName: (workflow: Workflow | null) => void;
setCurFlowIDAndName: (workflow: Workflow) => void;
}>({
curFlowID: null,
loadWorkflowID: () => {},
+33 -53
View File
@@ -1,4 +1,3 @@
import { fetchApi } from "../Api";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import { indexdb } from "../db-tables/indexdb";
import { Workflow } from "../types/dbTypes";
@@ -10,17 +9,17 @@ import {
export namespace TwowaySyncAPI {
export async function moveWorkflow(
curRelPath: string,
workflow: Workflow,
newParentFolderRelPath: string,
) {
try {
const response = await fetchApi("/workspace/file/move", {
const response = await fetch("/workspace/file/move", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: curRelPath,
path: getWorkflowRelPath(workflow),
newParentPath: newParentFolderRelPath,
}),
});
@@ -40,7 +39,7 @@ export namespace TwowaySyncAPI {
parentFolderID: string | null,
): Promise<string | null> {
try {
const response = await fetchApi("/workspace/file/gen_unique_name", {
const response = await fetch("/workspace/file/gen_unique_name", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -62,17 +61,17 @@ export namespace TwowaySyncAPI {
}
export async function renameWorkflow(
{ relPath, id }: { relPath: string; id: string },
{ parentFolderID, name }: Pick<Workflow, "name" | "parentFolderID">,
newName: string,
) {
try {
const response = await fetchApi("/workspace/file/rename", {
const response = await fetch("/workspace/file/rename", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: relPath,
path: joinRelPath(parentFolderID ?? "", name + ".json"),
newName: newName,
}),
});
@@ -103,12 +102,7 @@ export namespace TwowaySyncAPI {
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
}
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = workflow.id;
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME].saveLock = workflow.saveLock;
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME].cloudID = workflow.cloudID;
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME].coverMediaPath =
workflow.coverMediaPath;
const response = await fetchApi("/workspace/file/save", {
const response = await fetch("/workspace/file/save", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -131,7 +125,7 @@ export namespace TwowaySyncAPI {
}
export async function deleteWorkflow(workflow: Workflow) {
try {
const response = await fetchApi("/workspace/delete_file", {
const response = await fetch("/workspace/delete_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -148,8 +142,12 @@ export namespace TwowaySyncAPI {
console.error("Error deleting file:", error);
}
}
export async function createWorkflow(workflow: Workflow) {
const { parentFolderID, name, json, id } = workflow;
export async function createWorkflow({
parentFolderID,
name,
json,
id,
}: Workflow) {
let jsonObj: any = JSON.parse(json);
if (jsonObj.extra == null) {
jsonObj.extra = {};
@@ -158,10 +156,6 @@ export namespace TwowaySyncAPI {
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
}
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = id; // to avoid overwriting deps
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME].saveLock = workflow.saveLock;
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME].cloudID = workflow.cloudID;
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME].coverMediaPath =
workflow.coverMediaPath;
const input: { parentFolderPath: string; name: string; json: string } = {
parentFolderPath: sanitizeRelPath(parentFolderID ?? ""),
name: name,
@@ -169,7 +163,7 @@ export namespace TwowaySyncAPI {
};
try {
const response = await fetchApi("/workspace/create_workflow_file", {
const response = await fetch("/workspace/create_workflow_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -193,28 +187,20 @@ export namespace TwowaySyncAPI {
}
}
export async function getFile({
relPath,
parentFolderID,
name,
id,
}: {
relPath: string;
parentFolderID: string | null;
id: string;
name: string;
}): Promise<{
json?: {
extra: {
[COMFYSPACE_TRACKING_FIELD_NAME]: {
id: string;
saveLock: boolean;
cloudID: string | null;
coverMediaPath: string | null;
};
};
};
createTime?: number;
updateTime?: number;
json?: Object;
error?: string;
}> {
const relPath = joinRelPath(parentFolderID ?? "", name + ".json");
try {
const response = await fetchApi("/workspace/get_workflow_file", {
const response = await fetch("/workspace/get_workflow_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -249,9 +235,6 @@ export type ScanLocalFile = {
name: string;
id: string;
json: string;
saveLock: boolean;
cloudID: string | null;
coverMediaPath: string | null;
createTime: number;
updateTime: number;
lastOpenedTime?: number;
@@ -268,20 +251,17 @@ export async function scanLocalFiles(
metaInfoOnly = false,
): Promise<Array<ScanLocalFile | ScanLocalFolder>> {
try {
const response = await fetchApi(
"/workspace/file/scan_my_workflows_folder",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: sanitizeRelPath(path),
recursive,
metaInfoOnly,
}),
const response = await fetch("/workspace/file/scan_my_workflows_folder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({
path: sanitizeRelPath(path),
recursive,
metaInfoOnly,
}),
});
const result = await response.json();
return result;
} catch (error) {
+5 -6
View File
@@ -1,4 +1,3 @@
import { fetchApi } from "../Api";
import { Folder } from "../types/dbTypes";
import { sanitizeRelPath } from "../utils/OsPathUtils";
@@ -14,7 +13,7 @@ export namespace TwowayFolderSyncAPI {
}
export async function createFolder(folder: Folder) {
await fetchApi("/workspace/folder/create", {
await fetch("/workspace/folder/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -28,7 +27,7 @@ export namespace TwowayFolderSyncAPI {
folderToBeMoved: string,
newParentPath: string,
) {
const resp = await fetchApi("/workspace/folder/move", {
const resp = await fetch("/workspace/folder/move", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -46,7 +45,7 @@ export namespace TwowayFolderSyncAPI {
}
export async function deleteFolder(relPath: string) {
try {
const response = await fetchApi("/workspace/folder/delete", {
const response = await fetch("/workspace/folder/delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -66,7 +65,7 @@ export namespace TwowayFolderSyncAPI {
folderRelPath: string,
): Promise<number | null> {
try {
const response = await fetchApi("/workspace/file/count_files", {
const response = await fetch("/workspace/file/count_files", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -91,7 +90,7 @@ export namespace TwowayFolderSyncAPI {
newName: string,
): Promise<void> {
try {
const response = await fetchApi("/workspace/folder/rename", {
const response = await fetch("/workspace/folder/rename", {
method: "POST",
headers: {
"Content-Type": "application/json",
+2 -1
View File
@@ -14,7 +14,8 @@ import {
} from "@chakra-ui/react";
import { workflowVersionsTable } from "../db-tables/WorkspaceDB";
import { useState, ChangeEvent } from "react";
import { app } from "../utils/comfyapp";
// @ts-ignore
import { app } from "/scripts/app.js";
interface Props {
workflowId: string;
+13 -5
View File
@@ -43,10 +43,11 @@ import { Workflow } from "../types/dbTypes";
import CreateVersionDialog from "./CreateVersionDialog";
import HoverMenu from "./HoverMenu";
const ShareDialog = lazy(() => import("../share/ShareDialog"));
// @ts-ignore
import { app } from "/scripts/app.js";
import { TOPBAR_BUTTON_HEIGHT } from "../const";
import DownloadSpaceJsonDialog from "../spacejson/DownloadSpaceJsonDialog";
import { downloadJsonFile } from "../utils/downloadJsonFile";
import { SharedTopbarButton } from "../share/SharedTopbarButton";
import { app } from "../utils/comfyapp";
export default function DropdownTitle() {
const {
@@ -97,7 +98,7 @@ export default function DropdownTitle() {
parentFolderID: workflow?.parentFolderID,
});
flow && (await loadWorkflowID(flow.id, null, true));
flow && (await loadWorkflowID(flow.id, null));
handleOnCloseModal();
};
@@ -185,15 +186,21 @@ export default function DropdownTitle() {
>
Versions History
</MenuItem>
{/* <MenuItem
onClick={() => setRoute("downloadSpaceJson")}
icon={<IconDownload size={20} />}
iconSpacing={1}
>
Save runnable workflow🧪
</MenuItem> */}
<MenuItem
onClick={() => setRoute("share")}
onClick={() => setRoute("downloadSpaceJson")}
icon={<IconShare2 size={20} />}
iconSpacing={1}
alignItems={"center"}
>
<HStack>
<p>Share</p> <Tag size={"sm"}>🧪beta</Tag>
<SharedTopbarButton />
</HStack>
</MenuItem>
</MenuList>
@@ -204,6 +211,7 @@ export default function DropdownTitle() {
{route == "versionHistory" && (
<VersionHistoryDrawer onClose={() => setRoute("root")} />
)}
{route == "downloadSpaceJson" && <DownloadSpaceJsonDialog />}
{route === "saveAsModal" && (
<Modal isOpen={true} onClose={handleOnCloseModal}>
<ModalOverlay />
+7 -15
View File
@@ -22,15 +22,10 @@ import { WorkspaceContext } from "../WorkspaceContext";
type Props = {
displayName: string;
updateFlowName: (newName: string) => void;
isDirty: boolean;
isActive: boolean;
};
export default function EditFlowName({
displayName,
updateFlowName,
isDirty,
}: Props) {
export default function EditFlowName({ displayName, isActive }: Props) {
const { isOpen, onOpen, onClose } = useDisclosure();
const { curFlowID } = useContext(WorkspaceContext);
const [editName, setEditName] = useState(displayName);
@@ -63,7 +58,6 @@ export default function EditFlowName({
await workflowsTable?.updateName(curFlowID, {
name: trimEditName,
});
updateFlowName(trimEditName);
onCloseModal();
}
}
@@ -79,17 +73,15 @@ export default function EditFlowName({
<Tooltip label={displayName} placement="bottom">
<Text
as="div"
color="white"
onClick={startEdit}
maxW={160}
fontWeight={500}
color={isActive ? "white" : "#9d9d9d"}
onDoubleClick={startEdit}
maxW="114px"
marginRight="4px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
userSelect="none"
>
<div style={{ width: 8, display: "inline-block" }}>
<Text as="span">{isDirty && "* "}</Text>
</div>
{displayName}
</Text>
</Tooltip>
+2 -2
View File
@@ -219,12 +219,12 @@ export default function SpotlightSearch() {
document.addEventListener("keyup", handleKeyUp);
document.addEventListener("keydown", keydownListener);
window.addEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
document.addEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
return () => {
document.removeEventListener("keyup", handleKeyUp);
document.removeEventListener("keydown", keydownListener);
window.removeEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
document.removeEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
};
}, []);
+2 -1
View File
@@ -27,9 +27,10 @@ import {
import { useContext, useEffect, useState } from "react";
import { WorkspaceContext } from "../WorkspaceContext";
import { formatTimestamp } from "../utils";
// @ts-ignore
import { app } from "/scripts/app.js";
import { Changelog, WorkflowVersion } from "../types/dbTypes";
import DeleteConfirm from "./DeleteConfirm";
import { app } from "../utils/comfyapp";
export function VersionHistoryDrawer({ onClose }: { onClose: () => void }) {
const toast = useToast();
+2 -1
View File
@@ -44,8 +44,9 @@ export class ChangelogsTable extends TableBase<Changelog> {
createTime: Date.now(),
isAutoSave: input.isAutoSave,
};
await indexdb.changelogs.add(change);
await this.deleteLogsExceedLimit(input.workflowID);
await indexdb.changelogs.add(change);
await this.saveDiskDB();
return change;
}
async deleteLogsExceedLimit(workflowID: string) {
+93
View File
@@ -1,6 +1,59 @@
import { deleteFile, updateFile } from "../Api";
import { foldersTable, workflowsTable, userSettingsTable } from "./WorkspaceDB";
import {
COMFYSPACE_TRACKING_FIELD_NAME,
LEGACY_COMFYSPACE_TRACKING_FIELD_NAME,
} from "../const";
import { toFileNameFriendly } from "../utils";
import { Folder, Workflow } from "../types/dbTypes";
import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi";
export async function saveJsonFileMyWorkflows(workflow: Workflow) {
const file_path = await generateFilePath(workflow);
if (file_path == null) {
return;
}
const fullPath = await generateFilePathAbsolute(workflow);
await workflowsTable?.updateMetaInfo(workflow.id, {
filePath: fullPath ?? undefined,
});
const json = workflow.json;
const flow = JSON.parse(json);
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {
id: workflow.id,
name: workflow.name,
};
delete flow.extra[LEGACY_COMFYSPACE_TRACKING_FIELD_NAME];
await updateFile(file_path, JSON.stringify(flow));
}
export async function deleteJsonFileMyWorkflows(workflow: Workflow) {
if (workflow.name == null) {
return;
}
const file_path = await generateFilePath(workflow);
file_path != null && deleteFile(file_path);
}
export async function generateFilePath(
workflow: Workflow,
): Promise<string | null> {
let filePath = toFileNameFriendly(workflow.name) + ".json";
let curFolderID = workflow.parentFolderID;
while (curFolderID != null) {
const folder = await foldersTable?.get(curFolderID);
if (folder == null) {
break;
}
const folderName = folder.name;
filePath = `${folderName}/${filePath}`;
curFolderID = folder.parentFolderID ?? undefined;
}
return filePath ?? null;
}
export async function genFolderRelPath(
folderId: string | null,
): Promise<string> {
@@ -18,6 +71,46 @@ export async function genFolderRelPath(
return filePath;
}
export async function generateFilePathAbsolute(
workflow: Workflow,
): Promise<string | null> {
const subPath = await generateFilePath(workflow);
let myWorkflowsDir = await userSettingsTable?.getSetting("myWorkflowsDir");
if (!myWorkflowsDir) {
console.error("myWorkflowsDir is not set");
return null;
}
if (!myWorkflowsDir.endsWith("/")) {
myWorkflowsDir = myWorkflowsDir + "/";
}
return myWorkflowsDir + subPath;
}
export async function generateFolderPath(id: string): Promise<string | null> {
const folder = await foldersTable?.get(id);
let parentFolderID = folder?.parentFolderID;
let folderPath = folder?.name;
while (parentFolderID) {
const folder = await foldersTable?.get(parentFolderID);
if (folder == null) {
break;
}
folderPath = `${folder.name}/${folderPath}`;
parentFolderID = folder.parentFolderID ?? undefined;
}
let myWorkflowsDir = await userSettingsTable?.getSetting("myWorkflowsDir");
if (!myWorkflowsDir) {
console.error("myWorkflowsDir is not set");
return null;
}
if (!myWorkflowsDir.endsWith("/")) {
myWorkflowsDir = myWorkflowsDir + "/";
}
return myWorkflowsDir + folderPath;
}
export async function getFileCountInFolder(folderId: string): Promise<number> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
+6
View File
@@ -1,5 +1,6 @@
import { userSettingsTable, workflowsTable } from "./WorkspaceDB";
import { EFlowOperationType, Folder } from "../types/dbTypes";
import { validateOrSaveAllJsonFileMyWorkflows } from "../utils";
import { nanoid } from "nanoid";
import { TableBase } from "./TableBase";
import { indexdb } from "./indexdb";
@@ -83,6 +84,11 @@ export class FoldersTable extends TableBase<Folder> {
}
await indexdb.folders.update(id, input);
this.saveDiskDB();
// folder moved or renamed - move all workflows to the right directory(not required when folded state changes)
if (nameChanged || parentFolderChanged) {
validateOrSaveAllJsonFileMyWorkflows(true);
}
return newRecord;
}
public async deleteFolder(
+10
View File
@@ -2,6 +2,7 @@ import { Table, workflowsTable } from "./WorkspaceDB";
import { TableBase } from "./TableBase";
import { Media } from "../types/dbTypes";
import { indexdb } from "./indexdb";
import { getMetadataFromUrl } from "../gallery/utils.ts";
export class MediaTable extends TableBase<Media> {
static readonly TABLE_NAME: Table = "media";
@@ -24,11 +25,20 @@ export class MediaTable extends TableBase<Media> {
const format = input.localPath.split(".").pop();
if (format == null) return null;
const res = await getMetadataFromUrl(
`/workspace/view_media?filename=${input.localPath}`,
).catch((e) => {
console.error("get meta error:", e);
return { prompt: "" };
});
const md: Media = {
id: input.localPath,
localPath: input.localPath,
workflowID: input.workflowID,
createTime: Date.now(),
workflowJSON: JSON.stringify(res?.prompt),
format: format,
};
await workflowsTable?.updateMetaInfo(input.workflowID, {
latestImage: md.localPath,
+5 -7
View File
@@ -43,7 +43,7 @@ export class UserSettingsTable extends TableBase<UserSettings> {
autoSaveDuration: 3,
twoWaySync: false,
foldersOnTop: false,
cloudHost: "https://www.nodecafe.co",
cloudHost: "https://www.nodecafe.org",
overwriteCurWorkflowWhenDroppingFileToCanvas: false,
maximumChangelogNumber: 80,
hideCoverImage: false,
@@ -93,12 +93,10 @@ export class UserSettingsTable extends TableBase<UserSettings> {
...res,
};
});
if (instance.defaultSettings.cloudHost.includes("comfyspace.art")) {
// overwrite legacy comfyspace.art
await instance.upsert({
cloudHost: instance.defaultSettings.cloudHost,
});
}
// overwrite legacy comfyspace.art
await instance.upsert({
cloudHost: instance.defaultSettings.cloudHost,
});
return instance;
}
}
+107 -76
View File
@@ -8,6 +8,10 @@ import { sortFileItem } from "../utils";
import { nanoid } from "nanoid";
import { TableBase } from "./TableBase";
import { indexdb } from "./indexdb";
import {
deleteJsonFileMyWorkflows,
saveJsonFileMyWorkflows,
} from "./DiskFileUtils";
import { ESortTypes, ImportWorkflow } from "../RecentFilesDrawer/types";
import { defaultGraph } from "../defaultGraph";
import { scanMyWorkflowsDir } from "../utils/twowaySyncUtils";
@@ -17,11 +21,11 @@ import {
scanLocalFiles,
} from "../apis/TwowaySyncApi";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import { tabDataManager } from "../topbar/multipleTabs/TabDataManager";
export class WorkflowsTable extends TableBase<Workflow> {
static readonly TABLE_NAME = "workflows";
private _curWorkflow: Workflow | null = null;
workflowIDPathMap: Record<string, string> = {};
public get curWorkflow() {
return this._curWorkflow;
}
@@ -48,7 +52,11 @@ export class WorkflowsTable extends TableBase<Workflow> {
}
/**
* Check whether the currently opened workflow is the latest version and is consistent with the DB
* Check whether the currently opened workflow is the latest version and is consistent with the DB.
* Through updateTime comparison, if it is inconsistent, it means that a newer version already exists in the DB.
* If the automatic save function is turned on at this time, the latest version in the DB will be overwritten.
* Therefore, the user needs to confirm whether to continue to enable automatic saving.
* @returns boolean
*/
public async latestVersionCheck() {
if (this._curWorkflow) {
@@ -61,8 +69,20 @@ export class WorkflowsTable extends TableBase<Workflow> {
return true;
}
public async listByIndex(): Promise<Workflow[]> {
throw new Error("Method not allowed.");
public async listByIndex(
index: keyof Workflow,
value: string,
): Promise<Workflow[]> {
const list = await super.listByIndex(index, value);
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (!twoWaySyncEnabled) {
return list;
}
// for two way sync we need to varify if it really exists in disk
const results = await Promise.all(
list.map(async (f) => await this.get(f.id)),
);
return results.filter((f) => f != null) as Workflow[];
}
async generateUniqueName(name?: string, parentFolderID?: string) {
@@ -120,20 +140,34 @@ export class WorkflowsTable extends TableBase<Workflow> {
};
//add to IndexDB
await indexdb.workflows.add(newWorkflow);
// add to disk file db
this.saveDiskDB();
// add to my_workflows/
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await TwowaySyncAPI.createWorkflow(newWorkflow);
return await this.get(newWorkflow.id);
} else {
saveJsonFileMyWorkflows(newWorkflow);
}
return newWorkflow;
}
// disallow TableBase.update()
async update(): Promise<Workflow | null> {
async update(
_id: string,
_change: Partial<Workflow>,
): Promise<Workflow | null> {
throw new Error("Method not allowed.");
}
public async updateTopFields(
id: string,
change: Pick<Partial<Workflow>, "topFieldsConfig">,
): Promise<Workflow | null> {
return this._update(id, change);
}
public async updateMetaInfo(
id: string,
change: Omit<Partial<Workflow>, "id" | "name" | "parentFolderID" | "json">,
@@ -144,33 +178,35 @@ export class WorkflowsTable extends TableBase<Workflow> {
id: string,
change: Partial<Workflow>,
): Promise<Workflow | null> {
const cur = await this.get(id);
if (!cur) {
return null;
}
const newWorkflow = {
...cur,
...change,
};
await TwowaySyncAPI.saveWorkflow(newWorkflow);
//update indexdb
await indexdb.workflows.update(id, change);
const newWorkflow = (await indexdb.workflows.get(id)) ?? null;
//update curWorkflow RAM
if (this._curWorkflow && this._curWorkflow.id === id) {
this._curWorkflow = newWorkflow;
}
this.saveDiskDB();
return newWorkflow;
}
async updateFolder(id: string, change: Pick<Workflow, "parentFolderID">) {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
const oldWorkflow = await this.get(id);
const newWorkflow = await this.updateMetaInfo(id, change as any);
if (!newWorkflow || !oldWorkflow) {
return;
}
if (twoWaySyncEnabled) {
const relPath = this.workflowIDPathMap[id];
if (!relPath) {
alert("Error: workflow not found. Cannot move workflow.");
return;
await TwowaySyncAPI.moveWorkflow(oldWorkflow, change.parentFolderID!);
} else {
await saveJsonFileMyWorkflows(newWorkflow);
await deleteJsonFileMyWorkflows(oldWorkflow);
// update parent folder updateTime
if (newWorkflow?.parentFolderID != null) {
await indexdb.folders?.update(newWorkflow.parentFolderID, {
updateTime: Date.now(),
});
}
await TwowaySyncAPI.moveWorkflow(relPath, change.parentFolderID!);
}
}
public async genLastManualSavedJson(id: string) {
@@ -178,31 +214,26 @@ export class WorkflowsTable extends TableBase<Workflow> {
return changelog?.json;
}
public async updateName(id: string, change: Pick<Workflow, "name">) {
const path = this.workflowIDPathMap[id];
if (!path) {
alert("Error: workflow not found. Cannot rename workflow.");
return;
}
const before = await this.get(id);
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
const success = await TwowaySyncAPI.renameWorkflow(
{
relPath: path,
id: id,
},
change.name + ".json",
);
if (success) {
this.workflowIDPathMap[id] =
`${path.split("/").slice(0, -1).join("/")}/${change.name}.json`
.split("/")
.filter((part) => part !== "")
.join("/");
}
before &&
(await TwowaySyncAPI.renameWorkflow(before, change.name + ".json"));
}
tabDataManager.updateTabData(tabDataManager.activeIndex, change);
return await this.updateMetaInfo(id, change as any);
}
public async updateFlow(id: string, input: Pick<Workflow, "json">) {
const before = await this.get(id, false);
if (before == null) {
return;
}
const beforeStr = JSON.stringify(before.json);
const afterStr = JSON.stringify(input.json);
if (beforeStr === afterStr) {
// no change detected
return;
}
const after = await this._update(id, {
updateTime: Date.now(),
json: input.json,
@@ -211,6 +242,8 @@ export class WorkflowsTable extends TableBase<Workflow> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
after && (await TwowaySyncAPI.saveWorkflow(after));
} else {
after && (await saveJsonFileMyWorkflows(after));
}
}
@@ -263,19 +296,39 @@ export class WorkflowsTable extends TableBase<Workflow> {
} catch (e) {
console.error("batchCreateFlows error", e);
}
} else {
await saveJsonFileMyWorkflows(newWorkflow);
}
}
try {
await indexdb.workflows.bulkAdd(newWorkflows);
this.saveDiskDB();
} catch (e) {
console.error("batchCreateFlows error", e);
}
}
public async deleteFlow(id: string) {
const workflow = await this.get(id);
if (workflow) {
const twoWaySyncEnabled =
await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await TwowaySyncAPI.deleteWorkflow(workflow);
} else {
deleteJsonFileMyWorkflows({ ...workflow });
}
}
const tabIndex = tabDataManager.tabs.findIndex((tab) => tab.id === id);
if (tabIndex !== -1) {
tabDataManager?.deleteTabData(tabIndex);
}
//add to IndexDB
await indexdb.workflows.delete(id);
this.saveDiskDB();
}
public async batchDeleteFlow(ids: string[]) {
@@ -286,8 +339,13 @@ export class WorkflowsTable extends TableBase<Workflow> {
await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySyncEnabled) {
await TwowaySyncAPI.deleteWorkflow(flow);
} else {
deleteJsonFileMyWorkflows({ ...flow });
}
}
await indexdb.workflows.bulkDelete(ids);
tabDataManager.batchDeleteTabData(ids);
this.saveDiskDB();
}
public async listFolderContent(
@@ -317,55 +375,28 @@ export class WorkflowsTable extends TableBase<Workflow> {
public async get(
id: string,
fetchJsonContentOnDisk = true, //if false, only get the meta info from indexdb
): Promise<
| {
id: string;
name: string;
parentFolderID?: string | null;
saveLock?: boolean;
cloudID?: string | null;
coverMediaPath?: string | null;
json: string;
updateTime: number;
createTime: number;
}
| undefined
> {
): Promise<Workflow | undefined> {
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
if (!twoWaySyncEnabled) {
return await indexdb.workflows.get(id);
const wf = await indexdb.workflows.get(id);
if (!twoWaySyncEnabled || !fetchJsonContentOnDisk) {
return wf;
}
// two way sync mode
const rel = this.workflowIDPathMap[id];
if (!rel) {
if (wf == null) {
return undefined;
}
const data = await TwowaySyncAPI.getFile({
relPath: rel,
id: id,
parentFolderID: wf.parentFolderID ?? null,
name: wf.name,
id: wf.id,
});
if (!data.json) {
return undefined;
}
const workspaceInfo = data.json?.extra?.[COMFYSPACE_TRACKING_FIELD_NAME];
if (!workspaceInfo || workspaceInfo.id !== id) {
return undefined;
}
return {
id: workspaceInfo.id,
name:
rel
.split("/")
.pop()
?.replace(/\.json$/, "") ?? "Untitled Flow",
parentFolderID: rel.split("/").slice(0, -1).join("/"),
saveLock: workspaceInfo.saveLock,
cloudID: workspaceInfo.cloudID,
coverMediaPath: workspaceInfo.coverMediaPath,
...wf,
json: JSON.stringify(data.json),
updateTime: data.updateTime ?? Date.now(),
createTime: data.createTime ?? Date.now(),
};
}
+3
View File
@@ -1,5 +1,8 @@
// @ts-expect-error ComfyUI import
import { app } from "/scripts/app.js";
import {
Checkbox,
Flex,
Heading,
HStack,
IconButton,
@@ -13,7 +13,8 @@ import {
import { MetaBoxContext } from "./metaBoxContext.ts";
import { workflowsTable } from "../../../db-tables/WorkspaceDB.ts";
import { WorkspaceContext } from "../../../WorkspaceContext.ts";
import { app } from "../../../utils/comfyapp.ts";
// @ts-ignore
import { app } from "/scripts/app.js";
export type TopFieldType = {
promptKey: string | number;
class_type?: string;
@@ -63,6 +64,18 @@ export default function MetadataForm({ media }: { media: Media | null }) {
const [topFields, setTopFields] = useState<TopFieldType[]>([]);
useEffect(() => {
(async () => {
if (curFlowID) {
const topFieldsConfig = (await workflowsTable?.get(curFlowID))
?.topFieldsConfig;
if (topFieldsConfig) {
setTopFields(topFieldsConfig);
setShowAllInputs(false);
}
}
})();
}, [curFlowID]);
const updateTopField = (field: TopFieldType) => {
if (
isInTopField(topFields, {
@@ -75,9 +88,19 @@ export default function MetadataForm({ media }: { media: Media | null }) {
(v) => v.name !== field.name || v.promptKey !== field.promptKey,
);
setTopFields(topFieldsConfig);
if (curFlowID) {
workflowsTable?.updateTopFields(curFlowID, {
topFieldsConfig,
});
}
} else {
const topFieldsConfig = [...topFields, field];
setTopFields(topFieldsConfig);
if (curFlowID) {
workflowsTable?.updateTopFields(curFlowID, {
topFieldsConfig,
});
}
}
};
+1 -14
View File
@@ -1,6 +1,6 @@
import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import { ColorModeScript, type StyleFunctionProps } from "@chakra-ui/react";
import { ColorModeScript } from "@chakra-ui/react";
import { ChakraProvider, extendTheme } from "@chakra-ui/react";
import { AlertDialogProvider } from "./components/AlertDialogProvider.tsx";
import CSSReset from "./MyCSSReset.tsx";
@@ -35,20 +35,7 @@ const zIndices = {
};
const theme = extendTheme({
config: {
initialColorMode: "dark",
useSystemColorMode: false,
},
zIndices,
components: {
Card: {
baseStyle: (props: StyleFunctionProps) => ({
container: {
backgroundColor: props.colorMode === "dark" ? "#171B21" : "#F6F8FA",
},
}),
},
},
});
ReactDOM.createRoot(topbar).render(
@@ -32,7 +32,6 @@ export default function ServerEventListener() {
name: version.name,
authorID: version.authorID,
json: version.json,
api_prompt: null,
});
newversion && loadWorkflowID(existingWorkflow.id, newversion.id);
}
@@ -57,7 +56,6 @@ export default function ServerEventListener() {
name: version.name,
json: version.json,
authorID: version.authorID,
api_prompt: null,
createTime: Date.now(),
});
await loadWorkflowID(newWorkflow.id, newverison?.id);
@@ -1,4 +1,5 @@
// @ts-ignore
import { api } from "/scripts/api.js";
import {
Button,
HStack,
@@ -10,7 +11,6 @@ import {
} from "@chakra-ui/react";
import { useState, useEffect } from "react";
import { cancelDownload } from "../../Api";
import { api } from "../../utils/comfyapp";
type Queue = { save_path: string; progress: number };
@@ -9,7 +9,7 @@ export async function getModelFromSearch(
queries: [
{
q: q,
indexUid: "models_v9",
indexUid: "models_v8",
facets: [
"category.name",
"checkpointType",
@@ -12,12 +12,13 @@ import {
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../../const";
// @ts-ignore
import { app } from "/scripts/app.js";
import type {
DepsResult,
ModelDep,
ModelFile,
} from "../../spacejson/handleDownloadSpaceJson";
import { indexdb } from "../../db-tables/indexdb";
import { app } from "../../utils/comfyapp";
const HF_LOGO =
"https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.png";
@@ -32,8 +33,8 @@ export interface MissingModel {
interface Props {
model: MissingModel;
setSelectedModelDeps: (deps: ModelDep[]) => void;
selectedModelDeps: ModelDep[];
setSelectedModelDeps: (deps: ModelFile[]) => void;
selectedModelDeps: ModelFile[];
foldersList: Record<string, string[]>;
setFoldersList: (folders: Record<string, string[]>) => void;
}
@@ -46,7 +47,7 @@ export default function MissingModelItem({
setFoldersList,
}: Props) {
const [suggestedModel, setSuggestedModel] = useState<
ModelDep | { existingModel: string }
ModelFile | { existingModel: string }
>();
const modelDeps: DepsResult["models"] =
app.graph.extra?.[COMFYSPACE_TRACKING_FIELD_NAME]?.deps?.models;
@@ -76,7 +77,7 @@ export default function MissingModelItem({
const selectedModelNames = selectedModelDeps.map((dep) => dep.filename);
function isExistingModel(
model: ModelDep | { existingModel: string },
model: ModelFile | { existingModel: string },
): model is { existingModel: string } {
return (model as { existingModel: string }).existingModel != null;
}
@@ -10,14 +10,15 @@ import {
useToast,
} from "@chakra-ui/react";
import { useCallback, useContext, useEffect, useState } from "react";
// @ts-ignore
import { app } from "/scripts/app.js";
import MissingModelItem from "./MissingModelItem";
import { DRAWER_Z_INDEX } from "../../const";
import type { ModelDep } from "../../spacejson/handleDownloadSpaceJson";
import type { ModelFile } from "../../spacejson/handleDownloadSpaceJson";
import { getAllFoldersList } from "../../Api";
import { installModelsApi } from "../api/modelsApi";
import { getCivitApiKey } from "../../utils/civitUtils";
import { WorkspaceContext } from "../../WorkspaceContext";
import { app } from "../../utils/comfyapp";
export interface MissingModel {
class_type: string;
@@ -40,7 +41,7 @@ export default function MissingModelsListDrawer({
app.canvasEl.removeEventListener("click", onClose);
};
}, []);
const [selectedModelDeps, setSelectedModelDeps] = useState<ModelDep[]>([]);
const [selectedModelDeps, setSelectedModelDeps] = useState<ModelFile[]>([]);
const [foldersList, setFoldersList] = useState<Record<string, string[]>>({});
const { setRoute } = useContext(WorkspaceContext);
const loadData = useCallback(async () => {
@@ -77,7 +78,7 @@ export default function MissingModelsListDrawer({
}
installModelsApi({
file_hash: model.fileHash ?? undefined,
filename: model.name,
filename: model.filename,
save_path: foldersList[model.fileFolder][0],
url,
force_filename: true,
@@ -13,6 +13,8 @@ import Fuse from "fuse.js/min-basic";
import { useEffect, useState } from "react";
import { ModelsTags } from "./ModelsTags";
import { ModelsList } from "./ModelsList";
// @ts-expect-error ComfyUI imports
import { app } from "/scripts/app.js";
import InstallModelsButton from "../install-models/InstallModelsButton";
import { type ModelsListRespItem } from "../types";
import { useUpdateModels } from "../hooks/useUpdateModels";
@@ -21,7 +23,6 @@ import ShowNsfwModelThumbnailSettings from "../../settings/ShowNsfwModelThumbnai
import { indexdb } from "../../db-tables/indexdb";
import { type Model } from "../../types/dbTypes";
import ModelDropEventListener from "../topbar/ModelDropEventListener";
import { app } from "../../utils/comfyapp";
interface Props {
onClose: () => void;
}
@@ -1,12 +1,13 @@
import { Button } from "@chakra-ui/react";
// @ts-ignore
import { app } from "/scripts/app.js";
// @ts-ignore
import { api } from "/scripts/api.js";
import { useEffect, useState } from "react";
import MissingModelsListDrawer, {
MissingModel,
} from "../missing-models-drawer/MissingModelsListDrawer";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../../const";
import { app } from "../../utils/comfyapp";
interface Props {}
interface NodeError {
@@ -1,5 +1,6 @@
import { useEffect, DragEvent } from "react";
import { app } from "../../utils/comfyapp";
// @ts-ignore
import { app } from "/scripts/app.js";
export default function ModelDropEventListener() {
const handleModelDrop = async (
@@ -1,6 +1,8 @@
import { Button, DarkMode, Stack } from "@chakra-ui/react";
import { lazy, useEffect, useContext, Suspense } from "react";
import { lazy, useEffect, DragEvent, useContext, Suspense } from "react";
import ModelsListDrawer from "../models-list-drawer/ModelsListDrawer";
// @ts-ignore
import { app } from "/scripts/app.js";
import "./index.css";
import { WorkspaceContext } from "../../WorkspaceContext";
// @ts-ignore
@@ -13,10 +15,30 @@ import type { ModelsListRespItemFromApi } from "../types";
import InatallModelsModal from "../install-models/InstallModelsModal";
import { TOPBAR_BUTTON_HEIGHT } from "../../const";
const AddMissingModelsButton = lazy(
() => import("./InstallMissingModelsButton"),
);
export default function ModelManagerTopbar() {
const { setRoute, route } = useContext(WorkspaceContext);
const handleModelDrop = async (
e: DragEvent<HTMLCanvasElement> & { canvasX: number; canvasY: number },
) => {
const eventName = e.dataTransfer.getData("eventName");
if (eventName !== "WorkspaceManagerAddNode") {
return;
}
const modelRelativePath = e.dataTransfer.getData("modelRelativePath");
const nodeType = e.dataTransfer.getData("nodeType");
// @ts-ignore
const node = LiteGraph.createNode(nodeType);
node.pos = [e.canvasX, e.canvasY];
node.configure({ widgets_values: [modelRelativePath] });
app.graph.add(node);
};
useEffect(() => {
app.canvasEl.addEventListener("drop", handleModelDrop);
api.addEventListener(
"model_list",
async (e: { detail: ModelsListRespItemFromApi[] }) => {
@@ -55,22 +77,30 @@ export default function ModelManagerTopbar() {
window.dispatchEvent(new CustomEvent("model_list_updated"));
},
);
return () => {
app.canvasEl.removeEventListener("drop", handleModelDrop);
};
}, []);
return (
<Stack style={{ position: "relative" }}>
<Button
size={"sm"}
backgroundColor={"#434554"}
color={"white"}
colorScheme="blue"
aria-label="My models"
onClick={() => setRoute("modelList")}
px={1}
height={TOPBAR_BUTTON_HEIGHT + "px"}
>
Models
</Button>
<DarkMode>
<Button
size={"sm"}
backgroundColor={"#434554"}
aria-label="My models"
onClick={() => setRoute("modelList")}
px={1}
height={TOPBAR_BUTTON_HEIGHT + "px"}
>
Models
</Button>
</DarkMode>
<div style={{ position: "absolute", top: "38px", left: "0px" }}>
<Suspense>
<AddMissingModelsButton />
</Suspense>
</div>
{route === "modelList" && (
<ModelsListDrawer onClose={() => setRoute("root")} />
)}
+3
View File
@@ -1,5 +1,8 @@
// @ts-ignore
import { deleteFile } from "./Api";
// @ts-ignore
import { app, ComfyApp } from "/scripts/app.js";
export function formatTimestamp(
unixTimestamp: number,
showSec: boolean = false,
-52
View File
@@ -1,52 +0,0 @@
import { Input, Stack, useToast, Text, Flex, Button } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { userSettingsTable } from "../db-tables/WorkspaceDB";
export default function CloudHostSetting() {
const toast = useToast();
const [text, setText] = useState("");
const getSettings = () => {
userSettingsTable?.getSetting("cloudHost").then((res) => {
setText(res ?? "");
});
};
useEffect(() => {
getSettings();
}, []);
const submitChange = async (text: string) => {
await userSettingsTable?.upsert({ cloudHost: text });
getSettings();
toast({
title: "Setting saved. Please refresh to see the changes.",
status: "success",
duration: 4000,
});
};
return (
<Stack>
<Text>
For enterprise paid user only. Hosting site of the shared workflow.
</Text>
<Flex gap={2} alignItems={"center"}>
<Input
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={() => submitChange(text)}
/>
<Button
size={"sm"}
onClick={() => {
const cloudHost = userSettingsTable?.defaultSettings.cloudHost!;
setText(cloudHost);
submitChange(cloudHost);
}}
>
Reset
</Button>
</Flex>
</Stack>
);
}
+88 -134
View File
@@ -10,20 +10,26 @@ import {
ModalContent,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Stack,
Switch,
Tag,
Text,
useToast,
} from "@chakra-ui/react";
import { WorkflowPrivacy, WorkflowVersion } from "../types/dbTypes";
import { useContext, useEffect, useState } from "react";
import { Workflow, WorkflowPrivacy, WorkflowVersion } from "../types/dbTypes";
import { useEffect, useRef, useState } from "react";
import CustomSelector from "../components/CustomSelector";
import { IconCopy, IconExternalLink } from "@tabler/icons-react";
import { userSettingsTable, workflowsTable } from "../db-tables/WorkspaceDB";
import { IconCloud, IconCopy, IconExternalLink } from "@tabler/icons-react";
import {
userSettingsTable,
workflowVersionsTable,
workflowsTable,
} from "../db-tables/WorkspaceDB";
// @ts-ignore
import { api } from "/scripts/api.js";
// @ts-ignore
import { app } from "/scripts/app.js";
import {
PrivacyLabel,
fetchCloudWorkflowPrivacy,
@@ -32,50 +38,35 @@ import {
privacyOptions,
} from "./shareUtils";
import ResourceDepsForm from "../spacejson/ResourceDepsForm";
import { app } from "../utils/comfyapp";
import { useStateRef } from "../customHooks/useStateRef";
import { nanoid } from "nanoid";
import { WorkspaceContext } from "../WorkspaceContext";
import {
DepsResult,
WorkspaceInfoDeps,
extractAndFetchFileNames,
} from "../spacejson/handleDownloadSpaceJson";
import { fetchApi } from "../Api";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import ShareDialogWorkflowVersionRadio from "./ShareDialogWorkflowVersionRadio";
interface Props {
onClose: () => void;
}
export default function ShareDialog({ onClose }: Props) {
const [versionName, setVersionName, versionNameRef] = useStateRef(
"v" + getCurDateString(),
);
const { saveCurWorkflow } = useContext(WorkspaceContext);
const [deps, setDeps] = useState<DepsResult>();
const [uploadingImage, setUploadingImage] = useState(false);
const [versionName, setVersionName] = useState("v" + getCurDateString());
const [localVersions, setLocalVersions] = useState<WorkflowVersion[]>([]);
const [loading, setLoading] = useState(false);
const cloudHost = userSettingsTable?.settings?.cloudHost;
const [privacy, setPrivacy, privacyRef] =
useStateRef<WorkflowPrivacy>("PRIVATE");
const [cloudHost, setCloudHost] = useState("");
const [privacy, setPrivacy] = useState<WorkflowPrivacy>("PRIVATE");
const [selectedVersion, setSelectedVersion] = useState<
string | "new_version"
>("new_version");
const workflow = workflowsTable?.curWorkflow;
const cloudHostRef = useRef("");
const [workflow, setWorkflow] = useState<Workflow>();
const toast = useToast();
const [enableDeps, setEnableDeps] = useState(true);
const handleShareWorkflowSuccess = async (event: MessageEvent) => {
const detail = event.data;
if (
// event.origin !== cloudHostRef.current ||
event.origin !== cloudHostRef.current ||
detail.type !== "share_workflow_success"
) {
return;
}
const localID = detail.localWorkflowID;
const localVerID = detail.localVersionID;
const cloudVersionID = detail.version.id;
const cloudID = detail.version.workflowID;
@@ -83,22 +74,24 @@ export default function ShareDialog({ onClose }: Props) {
localID &&
(await workflowsTable?.updateMetaInfo(localID, {
cloudID: cloudID,
privacy: privacyRef.current,
cloudOrigin: event.origin,
privacy: privacy,
}));
localVerID &&
cloudVersionID &&
(await workflowVersionsTable?.update(localVerID, {
cloudID: cloudVersionID,
cloudOrigin: event.origin,
}));
loadData();
window.open(
cloudHost + "/workflow/" + cloudID + "/" + cloudVersionID,
cloudHostRef.current + "/workflow/" + cloudID + "/" + cloudVersionID,
"_blank",
);
setLoading(false);
};
useEffect(() => {
loadData();
const graph = app.graph.serialize();
extractAndFetchFileNames(graph.nodes ?? []).then((depsRes) => {
setDeps(depsRes);
});
window.addEventListener("message", handleShareWorkflowSuccess);
return () => {
window.removeEventListener("message", handleShareWorkflowSuccess);
@@ -106,11 +99,24 @@ export default function ShareDialog({ onClose }: Props) {
}, []);
const loadData = async () => {
const host =
import.meta.env.MODE === "production"
? await userSettingsTable?.getSetting("cloudHost")
: "http://localhost:3000";
if (host) {
setCloudHost(host);
cloudHostRef.current = host;
}
const workflow = workflowsTable?.curWorkflow ?? undefined;
setWorkflow(workflow);
if (workflow?.cloudID && workflow.cloudOrigin) {
fetchCloudWorkflowPrivacy(workflow).then((privacy) => {
setPrivacy(privacy);
});
}
workflowVersionsTable?.listByWorkflowID(workflow!.id).then((vers) => {
setLocalVersions(vers);
});
};
const copyTextToClipboard = (text: string) => {
navigator.clipboard
@@ -127,24 +133,7 @@ export default function ShareDialog({ onClose }: Props) {
console.error("Failed to copy text: ", err);
});
};
const pushToCloud = async (imageDeps?: DepsResult["images"]) => {
const graph = app.graph.serialize();
(graph.extra ||= {})[COMFYSPACE_TRACKING_FIELD_NAME] ||= {};
graph.extra[COMFYSPACE_TRACKING_FIELD_NAME].deps = {
models: deps?.models ?? {},
images: imageDeps ?? {},
nodeRepos: deps?.nodeRepos ?? [],
} as WorkspaceInfoDeps;
await saveCurWorkflow();
const apiPrompt = await app
.graphToPrompt(app.graph)
.then((data: { output: any; workflow: any }) => {
graph.extra[COMFYSPACE_TRACKING_FIELD_NAME].apiPrompt = data.output;
return data.output;
});
const jsonToShare = JSON.stringify(graph);
const onShare = async () => {
setLoading(true);
const secretKey = generateRandomKey(32);
const host =
@@ -152,15 +141,30 @@ export default function ShareDialog({ onClose }: Props) {
? ((await userSettingsTable?.getSetting("cloudHost")) as string)
: "http://localhost:3000";
let nodeDefs: Object;
nodeDefs = getNodeDefs();
const version: WorkflowVersion = {
id: nanoid(),
workflowID: workflow!.id,
name: versionNameRef.current,
createTime: Date.now(),
api_prompt: JSON.stringify(apiPrompt),
json: jsonToShare ?? JSON.stringify(app.graph.serialize()),
};
let version: WorkflowVersion | undefined;
try {
nodeDefs = getNodeDefs();
if (selectedVersion === "new_version") {
version = await workflowVersionsTable?.add({
workflowID: workflow!.id,
name: versionName,
createTime: Date.now(),
json: JSON.stringify(app.graph.serialize()),
});
} else {
version = await workflowVersionsTable?.get(selectedVersion);
}
if (!version) {
alert(`Failed to find version: ${selectedVersion}, please try again.`);
setLoading(false);
return;
}
} catch (e) {
console.error(e);
setLoading(false);
alert("Failed to share workflow, please try again.");
return;
}
const protocol = window.location.protocol;
const baseHost = window.location.host;
const baseUrl = `${protocol}//${baseHost}`;
@@ -180,7 +184,7 @@ export default function ShareDialog({ onClose }: Props) {
workflow: curWorkflow,
version: version,
nodeDefs: nodeDefs,
privacy: privacyRef.current,
privacy: privacy,
},
host,
);
@@ -190,57 +194,12 @@ export default function ShareDialog({ onClose }: Props) {
window.addEventListener("message", handleChildReady);
};
const onShare = async () => {
const imageDeps = deps?.images ?? {};
if (enableDeps) {
// upload images
setUploadingImage(true);
const imageDepsToUpload = Object.values(deps?.images ?? {})
// .filter((i) => !i.url)
.map((i) => i.filename);
if (imageDepsToUpload.length) {
const uploadResp = await fetchApi("/workspace/upload_image", {
method: "POST",
body: JSON.stringify({
images: imageDepsToUpload,
}),
});
const json = (await uploadResp.json()) as Record<string, string>;
Object.keys(json).forEach((key) => {
if (imageDeps?.[key]) {
imageDeps[key].url = json[key];
}
});
}
setUploadingImage(false);
}
pushToCloud(imageDeps);
};
const cloudWorkflowID = workflowsTable?.curWorkflow?.cloudID;
return (
<Modal isOpen={true} onClose={onClose} size={"lg"}>
<ModalOverlay />
<ModalContent width={["98%", "90%", "50%"]} maxWidth={"600px"}>
<ModalHeader>
<Flex justifyContent={"space-between"}>
<p>Share "{workflow?.name}"</p>
<Button
colorScheme="teal"
onClick={onShare}
size={"sm"}
isDisabled={loading}
>
{loading ? "Sharing" : "Share"}
</Button>
</Flex>
{cloudHost && (
<Text fontSize={16} color={"GrayText"} fontWeight={400}>
Share to {new URL(cloudHost).host}
</Text>
)}
</ModalHeader>
<ModalHeader>Share "{workflow?.name}"</ModalHeader>
<ModalBody pb={10}>
<Stack gap={5}>
{cloudWorkflowID == null ? (
@@ -289,6 +248,7 @@ export default function ShareDialog({ onClose }: Props) {
</Button>
</HStack>
)}
<Text>Choose a version to share:</Text>
<RadioGroup
gap={4}
onChange={(val) => {
@@ -298,6 +258,7 @@ export default function ShareDialog({ onClose }: Props) {
>
<Stack>
<HStack mb={5} alignItems={"center"}>
<Radio key={"new_version"} value="new_version" />
<Input
value={versionName}
width={"60%"}
@@ -309,35 +270,28 @@ export default function ShareDialog({ onClose }: Props) {
}}
/>
<Flex color="green">
<Badge colorScheme="purple">Version Name</Badge>
<Badge colorScheme="purple">New version</Badge>
</Flex>
</HStack>
{localVersions.slice(0, 4).map((ver) => {
return (
<ShareDialogWorkflowVersionRadio
key={ver.id}
version={ver}
cloudHost={cloudHost}
cloudWorkflowID={cloudWorkflowID ?? null}
/>
);
})}
</Stack>
</RadioGroup>
{Object.keys(deps?.images ?? {}).length > 0 && (
<Stack borderRadius={6} borderWidth={"1px"} p={2}>
<Switch
isChecked={enableDeps}
onChange={() => setEnableDeps(!enableDeps)}
fontWeight={"600"}
>
Share input images
</Switch>
<span style={{ color: "GrayText" }}>
If enabled your input images will be uploaded and shared to{" "}
{cloudHost}
</span>
{enableDeps && (
<ResourceDepsForm
deps={deps ?? null}
setDeps={setDeps}
uploadingImage={uploadingImage}
/>
)}
</Stack>
)}
</Stack>
<HStack justifyContent={"flex-end"} mt={16}>
<Button colorScheme="teal" onClick={onShare} isDisabled={loading}>
{loading ? "Sharing" : "Share"}
</Button>
</HStack>
</ModalBody>
</ModalContent>
</Modal>
+2 -1
View File
@@ -1,7 +1,8 @@
import { IconLink, IconLock, IconWorld } from "@tabler/icons-react";
import { CustomSelectorOption } from "../components/CustomSelector";
import { Workflow, WorkflowPrivacy } from "../types/dbTypes";
import { app } from "../utils/comfyapp";
// @ts-expect-error
import { app } from "/scripts/app.js";
export function generateRandomKey(length: number) {
// Generate a random array of bytes
@@ -0,0 +1,56 @@
import { useContext, useRef } from "react";
import { WorkspaceContext } from "../WorkspaceContext";
import {
Badge,
Button,
HStack,
Heading,
Modal,
ModalBody,
ModalContent,
Text,
Tooltip,
} from "@chakra-ui/react";
// @ts-ignore
import { app } from "/scripts/app.js";
import { IconInfoCircle } from "@tabler/icons-react";
import ResourceDepsForm from "./ResourceDepsForm";
export default function DownloadSpaceJsonDialog() {
const { setRoute } = useContext(WorkspaceContext);
const formRef = useRef<HTMLFormElement>(null);
return (
<Modal isOpen={true} onClose={() => setRoute("root")} size={"xl"}>
<ModalContent>
<ModalBody p={5} gap={5}>
<HStack>
<Heading size={"md"}>Add Resouce Dependencies</Heading>
<Tooltip
label="runnable workflows include all resource dependecy download urls
(models, cursom nodes, input images) and can be one-click
installed and run in any ComfyUI with workpace-manager installed."
>
<Badge borderRadius={10}>
<IconInfoCircle size={20} />
</Badge>
</Tooltip>
</HStack>
<Text color={"GrayText"}>Export runnable workflow</Text>
<ResourceDepsForm ref={formRef} />
<HStack justifyContent={"flex-end"}>
<Button
colorScheme="teal"
size="sm"
onClick={() => formRef.current?.requestSubmit()}
>
Next
</Button>
<Button size="sm" onClick={() => setRoute("share")}>
Skip
</Button>
</HStack>
</ModalBody>
</ModalContent>
</Modal>
);
}
+324 -36
View File
@@ -1,44 +1,332 @@
import { HStack, Heading, Image, Spinner, Stack } from "@chakra-ui/react";
import { DepsResult } from "./handleDownloadSpaceJson";
import {
forwardRef,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { WorkspaceContext } from "../WorkspaceContext";
import {
Button,
Flex,
HStack,
Heading,
IconButton,
Image,
Input,
Spinner,
Stack,
Tag,
Text,
Tooltip,
} from "@chakra-ui/react";
import {
DepsResult,
ModelFile,
WorkspaceInfoDeps,
extractAndFetchFileNames,
} from "./handleDownloadSpaceJson";
// @ts-ignore
import { app } from "/scripts/app.js";
import { IconExternalLink, IconPencil, IconRefresh } from "@tabler/icons-react";
import { getHgModelInfoUrlFromDownloadUrl } from "../utils/civitUtils";
import { getAllModelsList } from "../Api";
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
import { indexdb } from "../db-tables/indexdb";
export default function ResourceDepsForm({
deps,
uploadingImage,
}: {
deps: DepsResult | null;
setDeps: (deps: DepsResult) => void;
uploadingImage: boolean;
}) {
const imageDepsArr = Object.values(deps?.images ?? {});
if (!deps) {
return <Spinner size="md" color="teal.400" />;
}
export default forwardRef<HTMLFormElement>(
function ResourceDepsForm(_props, ref) {
const { saveCurWorkflow, setRoute } = useContext(WorkspaceContext);
const [deps, setDeps] = useState<DepsResult>();
const [errors, setErrors] = useState<Record<string, string>>({});
const [uploadingImage, setUploadingImage] = useState(false);
return (
<Stack gap={5}>
{imageDepsArr.length > 0 && (
<Stack>
<HStack>
<Heading size={"sm"}>Images ({imageDepsArr.length})</Heading>
{/* <Tag colorScheme="yellow">Will be uploaded as url</Tag> */}
<p style={{ color: "GrayText" }}>Will be uploaded as url</p>
</HStack>
{uploadingImage && (
<span>
<Spinner size="md" color="teal.400" /> Uploading
</span>
)}
{imageDepsArr.map((image) => (
<Stack key={image.filename}>
<p>{image.filename}</p>
<Image
width={250}
src={`/workspace/view_media?filename=${image.filename}&isPreview=true&isInput=true`}
const init = useCallback(async () => {
const graph = app.graph.serialize();
extractAndFetchFileNames(graph.nodes ?? []).then((depsRes) => {
setDeps(depsRes);
});
}, []);
const onRefreshModelsList = useCallback(async () => {
setDeps(undefined);
await getAllModelsList();
window.addEventListener("model_list_updated", () => {
init();
window.removeEventListener("model_list_updated", () => {});
});
}, []);
useEffect(() => {
init();
}, []);
const validateURL = (url: string) => {
const pattern = new RegExp(
"^(https?:\\/\\/)?" +
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" +
"((\\d{1,3}\\.){3}\\d{1,3}))" +
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
"(\\?[;&a-z\\d%_.~+=-]*)?" +
"(\\#[-a-z\\d_]*)?$",
"i",
);
return !!pattern.test(url);
};
const handleSubmit = async (event: any) => {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(event.target);
// validate model deps and save model urls to db
const saveModelUrlDBPromises: Promise<ModelFile>[] =
Object.values(deps?.models ?? {}).map(async (model) => {
const inputDownloadUrl = formData.get(model.filename)?.toString();
if (inputDownloadUrl?.length) {
// because the models table is storing filename without extension as id, which should be migrated
const baseName = getBaseFileName(model.filename);
await indexdb.models.update(baseName + "@" + model.fileFolder, {
downloadUrl: inputDownloadUrl,
});
}
const infoUrl = model.infoUrl?.length
? model.infoUrl
: inputDownloadUrl
? getHgModelInfoUrlFromDownloadUrl(inputDownloadUrl)
: null;
return {
filename: model.filename,
nodeType: model.nodeType,
fileHash: model.fileHash,
fileFolder: model.fileFolder,
downloadUrl: inputDownloadUrl ?? model.downloadUrl ?? null,
infoUrl: infoUrl ?? null,
inputName: model.inputName,
};
}) ?? [];
const modelDepsArr = await Promise.all(saveModelUrlDBPromises);
if (!validateModelDeps(modelDepsArr)) {
return;
}
setErrors({});
// upload images
setUploadingImage(true);
const imageDepsToUpload = Object.values(deps?.images ?? {})
.filter((i) => !i.url)
.map((i) => i.filename);
const imageDeps = deps?.images ?? {};
if (imageDepsToUpload.length) {
const uploadResp = await fetch("/workspace/upload_image", {
method: "POST",
body: JSON.stringify({
images: imageDepsToUpload,
}),
});
const json = (await uploadResp.json()) as Record<string, string>;
Object.keys(json).forEach((key) => {
if (imageDeps?.[key]) {
imageDeps[key].url = json[key];
}
});
}
setUploadingImage(false);
// save deps to graph
const modelDeps: WorkspaceInfoDeps["models"] = {};
modelDepsArr.forEach((model) => {
modelDeps[model.filename] = model;
});
const graph = app.graph.serialize();
(graph.extra ||= {})[COMFYSPACE_TRACKING_FIELD_NAME] ||= {};
graph.extra[COMFYSPACE_TRACKING_FIELD_NAME].deps = {
models: modelDeps,
images: imageDeps ?? {},
nodeRepos: deps?.nodeRepos ?? [],
} as WorkspaceInfoDeps;
await app
.graphToPrompt(app.graph)
.then((data: { output: any; workflow: any }) => {
graph.extra[COMFYSPACE_TRACKING_FIELD_NAME].apiPrompt = data.output;
});
await saveCurWorkflow();
console.log("graph", graph);
setRoute("share");
};
const validateModelDeps = useCallback((modelDeps: ModelFile[]) => {
let isValid = true;
modelDeps.forEach((model) => {
const key = model.filename;
if (!model.downloadUrl) {
setErrors((prev) => ({
...prev,
[key]: "Invalid URL",
}));
isValid = false;
return;
}
if (!validateURL(model.downloadUrl)) {
setErrors((prev) => ({ ...prev, [key]: "Invalid URL" }));
isValid = false;
return;
}
if (!model.fileFolder) {
setErrors((prev) => ({
...prev,
[key]: "Model file not found, please re-scan models folder",
}));
isValid = false;
return;
}
});
return isValid;
}, []);
const imageDepsArr = Object.values(deps?.images ?? {});
const modelDepsArr = Object.values(deps?.models ?? []);
if (!deps) {
return <Spinner size="md" color="teal.400" />;
}
return (
<form onSubmit={handleSubmit} ref={ref}>
<Stack gap={5} mt={5}>
<Stack>
<Heading size={"sm"}>Models ({modelDepsArr.length})</Heading>
{modelDepsArr.map((modelFile, index) => (
<ModelDepsItem
modelFile={modelFile}
key={index}
errors={errors}
onClickRefetchModelList={onRefreshModelsList}
/>
))}
</Stack>
{imageDepsArr.length > 0 && (
<Stack>
<HStack>
<Heading size={"sm"}>Images ({imageDepsArr.length})</Heading>
{/* <Tag colorScheme="yellow">Will be uploaded as url</Tag> */}
<p style={{ color: "GrayText" }}>Will be uploaded as url</p>
</HStack>
{uploadingImage && (
<span>
<Spinner size="md" color="teal.400" /> Uploading
</span>
)}
{imageDepsArr.map((image) => (
<Stack key={image.filename}>
<p>{image.filename}</p>
<Image
width={250}
src={`/workspace/view_media?filename=${image.filename}&isPreview=true&isInput=true`}
/>
</Stack>
))}
</Stack>
))}
)}
</Stack>
)}
</form>
);
},
);
function getBaseFileName(filename: string) {
const parts = filename.split(".");
if (parts.length > 1) {
parts.pop();
return parts.join(".");
} else {
return filename;
}
}
function ModelDepsItem({
modelFile,
errors,
onClickRefetchModelList,
}: {
modelFile: DepsResult["models"][0];
errors: Record<string, string>;
onClickRefetchModelList: () => void;
}) {
const inputKey = modelFile.filename;
const [isEditing, setIsEditing] = useState(!modelFile.downloadUrl);
if (!modelFile.fileFolder) {
return (
<Stack>
<Flex gap={2}>
<p>{modelFile.filename}</p>
<Tag size={"sm"} colorScheme={"red"}>
Model file not found
</Tag>
</Flex>
<Button
size={"sm"}
leftIcon={<IconRefresh />}
width={"fit-content"}
borderColor={"red"}
borderWidth={errors[inputKey] != null ? 2 : 0}
onClick={onClickRefetchModelList}
>
Fetch model file
</Button>
</Stack>
);
}
return (
<Stack ml={2}>
<Flex key={modelFile.filename + modelFile.nodeType} gap={2}>
<Tag height={"fit-content"} colorScheme={"teal"}>
{modelFile.fileFolder}
</Tag>
{modelFile.downloadUrl ? (
<HStack>
<a
href={modelFile.infoUrl ?? modelFile.downloadUrl}
target="_blank"
>
{modelFile.filename}
</a>
<IconExternalLink size={18} />
<IconButton
ml={3}
aria-label="edit"
icon={<IconPencil size={18} />}
size={"xs"}
onClick={() => setIsEditing(!isEditing)}
/>
</HStack>
) : (
<Stack>
<span>
{modelFile.filename} <span>❓</span>
</span>
</Stack>
)}
</Flex>
<Stack style={{ display: isEditing ? "block" : "none" }}>
<Tooltip
label={"Model download link, supports Huggingface and Civitai"}
>
<Input
name={inputKey}
defaultValue={modelFile.downloadUrl ?? ""}
size={"sm"}
isInvalid={errors[inputKey] != null}
placeholder={"https://civitai.com/api/download/models/324524"}
/>
</Tooltip>
</Stack>
<Text color={"red.400"}>{errors[inputKey]}</Text>
</Stack>
);
}
+25 -20
View File
@@ -5,7 +5,8 @@ import {
getCivitModelPageUrl,
getHgModelInfoUrlFromDownloadUrl,
} from "../utils/civitUtils";
import { app } from "../utils/comfyapp";
// @ts-ignore
import { app } from "/scripts/app.js";
type LiteNode = {
id: number;
@@ -13,18 +14,13 @@ type LiteNode = {
widgets_values?: Array<string | number>;
};
export type ModelDep = {
name: string;
folder: string | null;
hash: string | null;
url: string | null;
// TODO: will be deperecated
filename?: string;
fileFolder?: string | null;
fileHash?: string | null;
downloadUrl?: string | null;
export type ModelFile = {
filename: string;
fileFolder: string | null;
fileHash: string | null;
downloadUrl: string | null;
// optional info
infoUrl?: string | null;
infoUrl: string | null;
nodeType?: string;
inputName?: string;
};
@@ -45,12 +41,12 @@ export type DepsResult = {
nodeRepos: NodeRepo[];
};
export type DepsResultModel = ModelDep & {
export type DepsResultModel = ModelFile & {
length: number;
};
export type WorkspaceInfoDeps = {
models: Record<string, ModelDep>;
models: Record<string, ModelFile>;
images: DepsResult["images"];
nodeRepos: DepsResult["nodeRepos"];
};
@@ -62,18 +58,20 @@ async function fetchModelData(
): Promise<DepsResultModel> {
const res =
(await indexdb.models.where("fileName").equals(filename).toArray()) ?? [];
console.log("fetch modeldata", res);
const first = res.at(0);
const modelFile: DepsResultModel = {
name: filename,
filename,
nodeType,
hash: first?.fileHash ?? null,
folder: first?.fileFolder ?? null,
url: first?.civitModelVersionID
fileHash: first?.fileHash ?? null,
fileFolder: first?.fileFolder ?? null,
downloadUrl: first?.civitModelVersionID
? getCivitModelDownloadUrl(first.civitModelVersionID)
: first?.downloadUrl ?? null,
inputName: inputName,
length: res.length,
infoUrl: first?.civitModelID
? getCivitModelPageUrl(first.civitModelID, first.civitModelVersionID)
: getHgModelInfoUrlFromDownloadUrl(first?.downloadUrl ?? "") ?? null,
};
return modelFile;
}
@@ -97,9 +95,13 @@ export async function extractAndFetchFileNames(
if (typeof value != "string") return;
// Check if it's a model file
if (modelFileExtensions.some((ext) => value.endsWith(ext))) {
// if (curDeps?.models?.[value]) {
// modelPromises.push(Promise.resolve(curDeps.models[value]));
// } else {
modelPromises.push(
fetchModelData(value, node.class_type, inputName),
);
// }
}
// Check if it's an image file
if (imageFileExtensions.some((ext) => value.endsWith(ext))) {
@@ -133,7 +135,10 @@ export async function extractAndFetchFileNames(
const models = await Promise.all(modelPromises);
const modelsMap: Record<string, DepsResultModel> = {};
models.forEach((model) => {
modelsMap[model.name] = model;
// if (model.nodeType) {
// model.gitRepo = reposMapping[model.nodeType];
// }
modelsMap[model.filename] = model;
});
const nodeRepos: Record<string, NodeRepo> = {};
Object.values(reposMapping).forEach((repo) => {
+24 -24
View File
@@ -1,30 +1,25 @@
import { useContext, useEffect, useRef, useState } from "react";
// @ts-expect-error
import { app } from "/scripts/app.js";
import { WorkspaceContext } from "../WorkspaceContext";
import {
changelogsTable,
userSettingsTable,
workflowsTable,
} from "../db-tables/WorkspaceDB";
import { Tag, Tooltip } from "@chakra-ui/react";
import { Tag, Tooltip, useToast } from "@chakra-ui/react";
import { matchShortcut } from "../utils";
import { EShortcutKeys } from "../types/dbTypes";
import useDebounceFn from "../customHooks/useDebounceFn";
import {
COMFYSPACE_TRACKING_FIELD_NAME,
SHORTCUT_TRIGGER_EVENT,
} from "../const";
import { app } from "../utils/comfyapp";
import { deepJsonDiffCheck } from "../utils/deepJsonDiffCheck";
import { SHORTCUT_TRIGGER_EVENT } from "../const";
export default function AppIsDirtyEventListener() {
const {
isDirty,
setIsDirty,
setRoute,
saveCurWorkflow,
setCurFlowIDAndName,
} = useContext(WorkspaceContext);
const { isDirty, setIsDirty, setRoute, saveCurWorkflow } =
useContext(WorkspaceContext);
const isDirtRef = useRef(isDirty);
const [isOutdated, setIsOutdated] = useState(false);
const toast = useToast();
const autoSaveTimer = useRef<number | null>(null);
useEffect(() => {
@@ -32,7 +27,7 @@ export default function AppIsDirtyEventListener() {
}, [isDirty]);
useEffect(() => {
const keydownListener = (event: KeyboardEvent) => {
const shortcutListener = (event: KeyboardEvent) => {
if (document.visibilityState === "hidden") return;
const matchResult = matchShortcut(event);
if (matchResult) {
@@ -65,17 +60,26 @@ export default function AppIsDirtyEventListener() {
debounceOnIsDirty();
}
};
const originalOnAfterChange = app.canvas.onAfterChange;
app.graph.onAfterChange = function () {
// Call the original onAfterChange method
originalOnAfterChange?.apply(this, arguments);
debounceOnIsDirty();
};
const originalOnConfigure = app.graph.onConfigure;
app.graph.onConfigure = function () {
originalOnConfigure?.apply(this, arguments);
// delay check diff json cuz it takes time for graph.serialize() to settle up and return the correct json
setTimeout(() => {
const id = app.graph.extra?.[COMFYSPACE_TRACKING_FIELD_NAME]?.id;
if (id == null || id != workflowsTable?.curWorkflow?.id) {
setCurFlowIDAndName(null);
const deepCheck = deepJsonDiffCheck(
app.graph.serialize(),
JSON.parse(workflowsTable?.curWorkflow?.json ?? "{}"),
);
if (!deepCheck) {
debounceOnIsDirty();
}
}, 500);
}, 1000);
};
document.addEventListener("click", (e) => {
@@ -88,7 +92,7 @@ export default function AppIsDirtyEventListener() {
debounceOnIsDirty();
}
});
document.addEventListener("keydown", keydownListener);
document.addEventListener("keydown", shortcutListener);
if (
userSettingsTable?.settings?.autoSave &&
@@ -101,7 +105,7 @@ export default function AppIsDirtyEventListener() {
}
return () => {
document.removeEventListener("keydown", keydownListener);
document.removeEventListener("keydown", shortcutListener);
autoSaveTimer.current && clearInterval(autoSaveTimer.current);
};
}, []);
@@ -128,10 +132,6 @@ export default function AppIsDirtyEventListener() {
const onIsDirty = async () => {
if (workflowsTable?.curWorkflow?.saveLock || isDirtRef.current) return;
const graph = app.graph.serialize();
if (JSON.stringify(graph) === workflowsTable?.curWorkflow?.json) {
return;
}
setIsDirty(true);
if (
userSettingsTable?.settings?.autoSave &&
+30 -25
View File
@@ -5,8 +5,8 @@ import {
IconFolder,
IconGripVertical,
IconPhoto,
IconPlus,
IconTriangleInvertedFilled,
IconLock,
} from "@tabler/icons-react";
import DropdownTitle from "../components/DropdownTitle";
import {
@@ -17,27 +17,29 @@ import {
useEffect,
useState,
} from "react";
import EditFlowName from "../components/EditFlowName";
import { WorkspaceContext } from "../WorkspaceContext";
import { PanelPosition } from "../types/dbTypes";
import "./Topbar.css";
import { SharedTopbarButton } from "../share/SharedTopbarButton";
import VersionNameTopbar from "./VersionNameTopbar";
import { userSettingsTable, workflowsTable } from "../db-tables/WorkspaceDB";
import { userSettingsTable } from "../db-tables/WorkspaceDB";
import { TOPBAR_BUTTON_HEIGHT } from "../const";
import TopbarNewWorkflowButton from "./TopbarNewWorkflowButton";
const AppIsDirtyEventListener = lazy(() => import("./AppIsDirtyEventListener"));
import MultipleTabs from "./multipleTabs";
const ModelManagerTopbar = lazy(
() => import("../model-manager/topbar/ModelManagerTopbar"),
);
const SpotlightSearch = lazy(() => import("../components/SpotlightSearch"));
interface Props {
curFlowName: string | null;
setCurFlowName: (newName: string) => void;
}
export function Topbar({ curFlowName, setCurFlowName }: Props) {
const { isDirty, saveCurWorkflow, setRoute, curFlowID, route } =
useContext(WorkspaceContext);
export function Topbar() {
const {
isDirty,
loadNewWorkflow,
saveCurWorkflow,
setRoute,
curFlowID,
route,
} = useContext(WorkspaceContext);
const [positionStyle, setPositionStyle] = useState<PanelPosition>();
const updatePanelPosition: (
position?: PanelPosition,
@@ -109,20 +111,21 @@ export function Topbar({ curFlowName, setCurFlowName }: Props) {
<Suspense fallback={<div style={{ width: "60px" }} />}>
<ModelManagerTopbar />
</Suspense>
<TopbarNewWorkflowButton />
<EditFlowName
isDirty={isDirty}
displayName={curFlowName ?? ""}
updateFlowName={(newName) => {
setCurFlowName(newName);
requestAnimationFrame(() => {
updatePanelPosition();
});
}}
/>
{workflowsTable?.curWorkflow?.saveLock && (
<IconLock color="#FFF" size={20} />
)}
<Tooltip label="New workflow">
<Button
size={"sm"}
variant={"outline"}
colorScheme="teal"
aria-label="new workflow"
height={TOPBAR_BUTTON_HEIGHT + "px"}
onClick={() => loadNewWorkflow()}
px={1}
>
<HStack gap={0}>
<IconPlus size={16} color={"white"} />
</HStack>
</Button>
</Tooltip>
{curFlowID && (
<HStack gap={"4px"}>
<Tooltip label="Open gallery">
@@ -151,6 +154,8 @@ export function Topbar({ curFlowName, setCurFlowName }: Props) {
) : (
<div style={{ width: 1 }} />
)}
<MultipleTabs />
<SharedTopbarButton />
<VersionNameTopbar />
<AppIsDirtyEventListener />
<IconGripVertical
-41
View File
@@ -1,41 +0,0 @@
import { Tooltip, Button } from "@chakra-ui/react";
import { IconDeviceFloppy, IconPlus } from "@tabler/icons-react";
import { useContext } from "react";
import { WorkspaceContext } from "../WorkspaceContext";
import { TOPBAR_BUTTON_HEIGHT } from "../const";
import { app } from "../utils/comfyapp";
import { workflowsTable } from "../db-tables/WorkspaceDB";
export default function TopbarNewWorkflowButton() {
const { loadNewWorkflow, setCurFlowIDAndName, curFlowID } =
useContext(WorkspaceContext);
const saveNewWorkflow = async () => {
const graph = JSON.stringify(app.graph.serialize());
const workflowName = prompt("Enter workflow name", "Untitled");
if (!workflowName) return;
const flow = await workflowsTable?.createFlow({
json: graph,
name: workflowName,
});
flow && setCurFlowIDAndName(flow);
};
return (
<Tooltip label={curFlowID ? "New workflow" : "Save workflow"}>
<Button
size={"sm"}
variant={"outline"}
colorScheme="teal"
aria-label="new workflow"
height={TOPBAR_BUTTON_HEIGHT + "px"}
onClick={() => (curFlowID ? loadNewWorkflow() : saveNewWorkflow())}
px={1}
>
{curFlowID ? (
<IconPlus size={16} color={"white"} />
) : (
<IconDeviceFloppy size={16} color={"white"} />
)}
</Button>
</Tooltip>
);
}
@@ -0,0 +1,131 @@
export const RE_RENDER_MULTIPLE_TABS_EVENT = "RE_RENDER_MULTIPLE_TABS_EVENT";
export const OPEN_TAB_EVENT = "OPEN_TAB_EVENT";
// @ts-ignore
import { app } from "/scripts/app.js";
export type TabData = {
id: string;
name: string;
json: string;
isDirty: boolean;
};
type TabUpdateInput = Partial<TabData>;
class TabDataManager {
tabs: TabData[] = [];
activeIndex: number = 0;
activeTab: TabData | null = null;
changeActiveTab(index: number, needLoadNewFlow: boolean = true) {
this.saveCurTabJson();
this.activeIndex = index;
this.activeTab = this.tabs[index];
this.notifyChanges(needLoadNewFlow ? "loadNewFlow" : "");
}
updateTabData(index: number, updateInput: TabUpdateInput) {
if (this.tabs[index]) {
this.tabs[index] = { ...this.tabs[index], ...updateInput };
this.activeTab = this.tabs[index];
this.notifyChanges(updateInput.json ? "loadNewFlow" : "");
}
}
private saveCurTabJson() {
const graphJson = JSON.stringify(app.graph.serialize());
this.tabs[this.activeIndex].json = graphJson;
}
addTabData(newTab: TabData) {
const currentFlowIsDirty = this.activeTab?.isDirty;
const existingIndex = this.tabs.findIndex((tab) => tab.id === newTab.id);
if (this.tabs.length === 0) {
this.tabs.push(newTab);
this.activeTab = newTab;
} else {
let nextActiveIndex = -1;
if (existingIndex !== -1) {
nextActiveIndex = existingIndex;
} else if (currentFlowIsDirty) {
nextActiveIndex = this.activeIndex + 1;
this.tabs.splice(nextActiveIndex, 0, newTab);
}
if (nextActiveIndex >= 0) {
this.saveCurTabJson();
this.activeIndex = nextActiveIndex;
this.activeTab = this.tabs[nextActiveIndex];
} else {
this.updateTabData(this.activeIndex, newTab);
return;
}
}
this.notifyChanges("loadNewFlow");
}
deleteTabData(index: number) {
if (index < 0 || index >= this.tabs.length) return;
if (this.tabs.length === 1) {
this.tabs = [];
this.activeIndex = 0;
this.activeTab = null;
this.notifyChanges("clearCanvas");
return;
}
this.tabs.splice(index, 1);
if (index <= this.activeIndex) {
const isDeleteActiveTab = index === this.activeIndex;
this.activeIndex = isDeleteActiveTab ? Math.min(this.tabs.length - 1, this.activeIndex) : this.activeIndex - 1;
this.activeTab = this.tabs[this.activeIndex];
this.notifyChanges(isDeleteActiveTab ? "loadNewFlow" : '');
return;
}
this.notifyChanges('');
}
batchDeleteTabData(ids: string[]) {
if (this.tabs.length > 0) {
this.tabs = this.tabs.filter(tab => !ids.includes(tab.id));
if (this.tabs.length === 0) {
this.activeIndex = 0;
this.activeTab = null;
this.notifyChanges("clearCanvas");
return;
} else {
const activeIndex = this.tabs.findIndex(tab => tab.id === this.activeTab?.id);
if (activeIndex === -1) {
this.activeIndex = this.tabs.length - 1;
this.activeTab = this.tabs[this.activeIndex];
this.notifyChanges("loadNewFlow");
return;
} else if (activeIndex !== this.activeIndex) {
this.activeIndex = activeIndex;
this.activeTab = this.tabs[activeIndex];
this.notifyChanges();
return;
}
}
this.notifyChanges();
}
}
private notifyChanges(otherAction?: string) {
const event = new CustomEvent(RE_RENDER_MULTIPLE_TABS_EVENT, {
detail: {
tabs: this.tabs,
activeIndex: this.activeIndex,
activeTab: this.activeTab,
otherAction,
},
});
document.dispatchEvent(event);
}
}
export const tabDataManager = new TabDataManager();
+25
View File
@@ -0,0 +1,25 @@
.workspace-multiple-tabs::-webkit-scrollbar {
background-color: transparent;
width: 2px;
height: 2px;
}
.workspace-multiple-tabs::-webkit-scrollbar-thumb {
border-radius: 1em;
background-color: rgba(130, 129, 129, 0.3);
}
.workspace-multiple-tabs
.workspace-multiple-tabs-item-action:hover
.workspace-multiple-tabs-item-dirty {
display: none !important;
}
.workspace-multiple-tabs-item-action:hover
.workspace-multiple-tabs-item-dirty-close {
display: flex !important;
}
.workspace-multiple-tabs-item:hover .workspace-multiple-tabs-item-close {
display: flex !important;
}
+209
View File
@@ -0,0 +1,209 @@
import { IconButton, HStack, Box } from "@chakra-ui/react";
import { IconX, IconAsterisk, IconLock } from "@tabler/icons-react";
import { useEffect, useContext, useReducer } from "react";
// @ts-ignore
import { app } from "/scripts/app.js";
import { WorkspaceContext } from "../../WorkspaceContext";
import {
OPEN_TAB_EVENT,
RE_RENDER_MULTIPLE_TABS_EVENT,
} from "./TabDataManager";
import { tabDataManager } from "./TabDataManager";
import "./index.css";
import { workflowsTable } from "../../db-tables/WorkspaceDB";
import EditFlowName from "../../components/EditFlowName";
import { useDialog } from "../../components/AlertDialogProvider";
export default function MultipleTabs() {
const { showDialog } = useDialog();
const {
setCurFlowIDAndName,
isDirty,
setIsDirty,
setCurVersion,
curVersion,
loadWorkflowID,
saveCurWorkflow,
} = useContext(WorkspaceContext);
const [, reRenderDispatch] = useReducer((state) => state + 1, 0);
const onClose = (index: number) => {
const deleteTabInfo = tabDataManager.tabs[index];
if (deleteTabInfo.isDirty) {
showDialog(
`Please save or discard your changes to "${deleteTabInfo.name}" before leaving, or your changes will be lost.`,
[
{
label: "Save",
colorScheme: "teal",
onClick: async () => {
const deleteFlow = await workflowsTable?.get(deleteTabInfo.id);
if (deleteFlow) {
/**
* Get the deleted flow information and update updateCurWorkflow,
* Because workflowsTable.curWorkflow.id needs to be used when saving
*/
workflowsTable?.updateCurWorkflow({
...deleteFlow,
json: deleteTabInfo.json,
});
await saveCurWorkflow(deleteTabInfo.json);
tabDataManager.deleteTabData(index);
}
},
},
{
label: "Close",
colorScheme: "red",
onClick: async () => {
tabDataManager.deleteTabData(index);
},
},
],
);
} else {
tabDataManager.deleteTabData(index);
}
};
useEffect(() => {
tabDataManager.updateTabData(tabDataManager.activeIndex, { isDirty });
}, [isDirty]);
useEffect(() => {
const reRender = (e: CustomEvent) => {
const detail = e.detail;
reRenderDispatch();
switch (detail.otherAction) {
case "clearCanvas":
loadWorkflowID(null);
break;
case "loadNewFlow":
app.loadGraphData(JSON.parse(detail.activeTab.json));
workflowsTable?.get(detail.activeTab.id).then((flow) => {
flow &&
setCurFlowIDAndName({
...flow,
json: detail.activeTab.json,
});
});
setIsDirty(detail.activeTab.isDirty);
curVersion && setCurVersion(null);
// Avoid scrolling when the corresponding element has not yet been rendered.
setTimeout(() => {
document
.getElementById(
`workspace-multiple-tabs-item-${detail.activeTab.id}`,
)
?.scrollIntoView({ behavior: "smooth" });
}, 0);
break;
}
};
const openTab = (e: CustomEvent) => {
const detail = e.detail;
if (detail.tabInfo.id !== tabDataManager.activeTab?.id) {
tabDataManager.addTabData(detail.tabInfo);
}
};
document.addEventListener(RE_RENDER_MULTIPLE_TABS_EVENT, reRender);
document.addEventListener(OPEN_TAB_EVENT, openTab);
return () => {
document.removeEventListener(RE_RENDER_MULTIPLE_TABS_EVENT, reRender);
document.removeEventListener(OPEN_TAB_EVENT, reRender);
};
}, []);
return (
<HStack
spacing={0}
className="workspace-multiple-tabs"
overflowX="auto"
maxW="calc(100vw - 380px)"
>
{tabDataManager.tabs.map((tab, index) => {
const isActive = tabDataManager.activeIndex === index;
const { id, name, isDirty } = tab;
return (
<Box
key={`${id}-${name}`}
className="workspace-multiple-tabs-item"
id={`workspace-multiple-tabs-item-${id}`}
cursor="pointer"
border="1px"
borderColor="#64646459"
borderTopColor={isActive ? "#0078d4" : "#64646459"}
borderRightWidth={0}
display="flex"
alignItems="center"
justifyContent="space-between"
h="32px"
lineHeight="32px"
padding="0 30px 0 10px"
position="relative"
bg={isActive ? "#1f1f1f" : "#181818"}
_hover={{ bg: "#1f1f1f" }}
_last={{ borderRightWidth: 1 }}
onClick={() => {
tabDataManager.activeIndex !== index &&
tabDataManager.changeActiveTab(index);
}}
>
<EditFlowName isActive={isActive} displayName={name ?? ""} />
{isDirty ? (
<Box
className="workspace-multiple-tabs-item-action"
position="absolute"
right="4px"
>
<IconButton
icon={<IconAsterisk color="white" width={14} height={14} />}
size={"xs"}
aria-label="un save flag"
variant={"ghost"}
display="flex"
_hover={{ bg: "whiteAlpha.200" }}
className="workspace-multiple-tabs-item-dirty"
/>
<IconButton
onClick={(e) => {
e.stopPropagation();
onClose(index);
}}
icon={<IconX color="white" width={14} height={14} />}
size={"xs"}
aria-label="close tab"
variant={"ghost"}
_hover={{ bg: "whiteAlpha.200" }}
display="none"
className="workspace-multiple-tabs-item-dirty-close"
/>
</Box>
) : (
<IconButton
onClick={(e) => {
e.stopPropagation();
onClose(index);
}}
icon={<IconX color="white" width={14} height={14} />}
size={"xs"}
aria-label="close tab"
variant={"ghost"}
_hover={{ bg: "whiteAlpha.200" }}
display={isActive ? "flex" : "none"}
position="absolute"
right="4px"
className={isActive ? "" : "workspace-multiple-tabs-item-close"}
/>
)}
{isActive && workflowsTable?.curWorkflow?.saveLock && (
<IconLock color="#FFF" size={20} />
)}
</Box>
);
})}
</HStack>
);
}
+6 -6
View File
@@ -10,17 +10,17 @@ export interface SortableItem {
export interface Workflow extends SortableItem {
id: string;
json: string;
lastSavedJson?: string; // TODO will be deprecated
name: string;
coverMediaPath?: string | null;
saveLock?: boolean;
cloudID?: string | null;
createTime: number;
// will be deprecated
filePath?: string; // TODO will be deprecated
privacy?: WorkflowPrivacy;
tags?: string[];
parentFolderID?: string | null; //TODO remove undefined, use null only
coverMediaPath?: string;
cloudID?: string; // TODO will be deprecated
cloudOrigin?: string;
saveLock?: boolean;
latestImage?: string;
topFieldsConfig?: TopFieldType[];
}
@@ -67,7 +67,6 @@ export type WorkflowVersion = {
workflowID: string;
json: string;
createTime: number;
api_prompt: string | null;
cloudID?: string;
cloudOrigin?: string;
authorID?: string;
@@ -132,6 +131,7 @@ export type Media = {
workflowJSON?: string;
createTime: number;
localPath: string;
format: string;
};
/**
+18 -1
View File
@@ -1,12 +1,29 @@
import { SHORTCUT_TRIGGER_EVENT } from "../const";
import {
OPEN_TAB_EVENT,
RE_RENDER_MULTIPLE_TABS_EVENT,
TabData,
} from "../topbar/multipleTabs/TabDataManager";
import { EOtherKeys, EShortcutKeys } from "./dbTypes";
interface ShortcutTriggerDetail {
shortcutType: EShortcutKeys | EOtherKeys;
}
interface reRenderTabsDetail {
tabs: TabData;
activeIndex: number;
}
interface reRenderTabsDetail {
tabs: TabData;
activeIndex: number;
}
declare global {
interface WindowEventMap {
interface DocumentEventMap {
[SHORTCUT_TRIGGER_EVENT]: CustomEvent<ShortcutTriggerDetail>;
[RE_RENDER_MULTIPLE_TABS_EVENT]: CustomEvent<reRenderTabsDetail>;
[OPEN_TAB_EVENT]: CustomEvent;
}
}
+52 -3
View File
@@ -1,7 +1,22 @@
import { deleteFile, updateFile } from "./Api";
import { ESortTypes } from "./RecentFilesDrawer/types";
import { userSettingsTable } from "./db-tables/WorkspaceDB";
import { Workflow, EShortcutKeys, SortableItem } from "./types/dbTypes";
import { app } from "./utils/comfyapp";
import { workflowsTable, userSettingsTable } from "./db-tables/WorkspaceDB";
import {
generateFilePathAbsolute,
saveJsonFileMyWorkflows,
} from "./db-tables/DiskFileUtils";
import {
Workflow,
EShortcutKeys,
EOtherKeys,
SortableItem,
} from "./types/dbTypes";
// @ts-expect-error ComfyUI import
import { app } from "/scripts/app.js";
import {
COMFYSPACE_TRACKING_FIELD_NAME,
LEGACY_COMFYSPACE_TRACKING_FIELD_NAME,
} from "./const";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare let LiteGraph: any, LGraph: any, LGraphCanvas: any;
@@ -141,6 +156,22 @@ export function sortFlows(
return copyFlows;
}
export async function validateOrSaveAllJsonFileMyWorkflows(
deleteEmptyFolder = false,
) {
const twoWaySync = await userSettingsTable?.getSetting("twoWaySync");
if (twoWaySync) return; // disable this function in two way sync mode
const flowList = (await workflowsTable?.listAll()) ?? [];
for (const workflow of flowList) {
const fullPath = await generateFilePathAbsolute(workflow);
if (workflow.filePath != fullPath) {
// file path changed
workflow.filePath != null &&
(await deleteFile(workflow.filePath, deleteEmptyFolder));
await saveJsonFileMyWorkflows(workflow);
}
}
}
export const sortFileItem = <T extends SortableItem>(
items: T[],
@@ -310,3 +341,21 @@ export const openWorkflowInNewTab = (workflowID: string) => {
const newHash = generateUrlHashWithFlowId(workflowID);
window.open(`${window.location.origin}/#${newHash}`);
};
export async function rewriteAllLocalFiles() {
const flowList = (await workflowsTable?.listAll()) ?? [];
for (const workflow of flowList) {
try {
const fullPath = await generateFilePathAbsolute(workflow);
const flow = JSON.parse(workflow.json);
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {
id: workflow.id,
name: workflow.name,
};
delete flow.extra[LEGACY_COMFYSPACE_TRACKING_FIELD_NAME];
fullPath && (await updateFile(fullPath, JSON.stringify(flow)));
} catch (error) {
console.error(error);
}
}
}
+3 -9
View File
@@ -1,13 +1,7 @@
// @ts-ignore
export let app: any | null = null;
export const app = window.app;
const api_base = location.pathname.split("/").slice(0, -1).join("/");
export let api: any | null = null;
import(api_base + "/scripts/api.js").then((apiJs) => {
api = apiJs?.api;
});
import(api_base + "/scripts/app.js").then((appJs) => {
app = appJs?.app;
export const api = import(api_base + "/scripts/app.js").then((api) => {
return api;
});
+7 -19
View File
@@ -4,7 +4,7 @@ import {
ScanLocalFolder,
scanLocalFiles,
} from "../apis/TwowaySyncApi";
import { isFolder, workflowsTable } from "../db-tables/WorkspaceDB";
import { isFolder, userSettingsTable } from "../db-tables/WorkspaceDB";
import { indexdb } from "../db-tables/indexdb";
import { Folder, Workflow } from "../types/dbTypes";
import { sortFileItem } from "../utils";
@@ -34,33 +34,21 @@ export async function scanMyWorkflowsDir(
return folder;
} else {
// is workflow
// const existingWorkflow = (await indexdb.workflows
// ?.get(file.id)
// .catch((err) => {
// console.error("Error getting workflow from indexdb", err);
// return null;
// })) ?? {
// createTime: Date.now(),
// updateTime: Date.now(),
// };
if (workflowsTable) {
workflowsTable.workflowIDPathMap[file.id] = relPath;
}
const existingWorkflow = (await indexdb.workflows?.get(file.id)) ?? {
createTime: Date.now(),
updateTime: Date.now(),
};
const newWorkflow: Workflow = {
// ...existingWorkflow,
...existingWorkflow,
id: file.id,
json: file.json ?? "{}",
cloudID: file.cloudID,
coverMediaPath: file.coverMediaPath,
saveLock: file.saveLock,
name: fileName.replace(/\.json$/, ""),
parentFolderID: parentRelPath,
createTime: file.createTime,
// setting updateTime will result latestVersionChcek() always fail if in
// workflow.get() not pull updateTime from file
updateTime: file.updateTime,
// updateTime: file.updateTime,
};
return newWorkflow;
}