initial commit

This commit is contained in:
qingli.hql
2025-01-19 21:36:25 +08:00
commit b050a02262
86 changed files with 19275 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
dist/
entry/
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: [aib]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+20
View File
@@ -0,0 +1,20 @@
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
View File
@@ -0,0 +1,31 @@
__pycache__
hash
.env
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
ui/node_modules
# ui/src/utils/encryptUtils.ts
db
dist-ssr
*.local
backup
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.cache
# dev
ui/yarn.lock
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 aib
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
test
+1
View File
@@ -0,0 +1 @@
test
+22
View File
@@ -0,0 +1,22 @@
import asyncio
import server
from aiohttp import web
import folder_paths
from .service.conversation_service import *
WEB_DIRECTORY = "entry"
NODE_CLASS_MAPPINGS = {}
__all__ = ['NODE_CLASS_MAPPINGS']
version = "V2.1.0"
workspace_path = os.path.join(os.path.dirname(__file__))
comfy_path = os.path.dirname(folder_paths.__file__)
db_dir_path = os.path.join(workspace_path, "db")
dist_path = os.path.join(workspace_path, 'dist/copilot_web')
if os.path.exists(dist_path):
server.PromptServer.instance.app.add_routes([
web.static('/copilot_web/', dist_path),
])
else:
print(f"🦄🦄🔴🔴Error: Web directory not found: {dist_path}")
+1
View File
@@ -0,0 +1 @@
mkdir -p static/workflows
+11
View File
@@ -0,0 +1,11 @@
import{r as t,j as s,R as p,_ as v}from"./input.js";const w=9999999,E=p.lazy(()=>v(()=>import("./workflowChat-FpdXvLRn.js"),__vite__mapDeps([0,1,2])));function y(){const d=t.useRef(null),[f,a]=t.useState(!1),[r,c]=t.useState(),[o,u]=t.useState(!1),[i,m]=t.useState({x:0,y:0});t.useEffect(()=>{c({x:window.innerWidth-160,y:20})},[]);const h=e=>{e.preventDefault(),u(!0);const n=e.currentTarget.getBoundingClientRect();m({x:e.clientX-n.left,y:e.clientY-n.top})};return t.useEffect(()=>{const e=l=>{if(o){const x=Math.min(Math.max(0,l.clientX-i.x),window.innerWidth-100),g=Math.min(Math.max(0,l.clientY-i.y),window.innerHeight-40);c({x,y:g})}},n=()=>{u(!1)};return o&&(document.addEventListener("mousemove",e),document.addEventListener("mouseup",n)),()=>{document.removeEventListener("mousemove",e),document.removeEventListener("mouseup",n)}},[o,i]),r?s.jsx("div",{ref:d,children:s.jsxs("div",{className:"fixed",style:{zIndex:w,left:r.x,top:r.y,cursor:o?"grabbing":"grab"},children:[s.jsx("button",{onMouseDown:h,onClick:()=>a(!0),className:"px-4 py-2 border border-gray-300 rounded-md bg-white text-black hover:bg-green-600 transition-colors select-none",children:"copilot"}),s.jsx(E,{onClose:()=>a(!1),visible:f})]})}):null}export{y as default};
function __vite__mapDeps(indexes) {
const apiBase = window.comfyAPI?.api?.api?.api_base;
const prefix = apiBase ? `${apiBase.substring(1)}/` : '';
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = ["copilot_web/workflowChat-FpdXvLRn.js","copilot_web/input.js","copilot_web/assets/input-Vr0StwNx.css"].map(
path => `${prefix}${path}`
);
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i]);
}
File diff suppressed because one or more lines are too long
+46
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
//@ts-ignore
import { api } from "../../scripts/api.js";
setTimeout(() => {
import(api.api_base + "/copilot_web/input.js");
}, 500);
+26
View File
@@ -0,0 +1,26 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413, 389],
"size": { "0": 425.27801513671875, "1": 180.6060791015625 },
"flags": {},
"order": 3,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [6],
"slot_index": 0
}
],
"properties": {},
"widgets_values": ["text, watermark"]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"last_node_id": 5,
"last_link_id": 4,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [200, 200]
},
{
"id": 2,
"type": "FaceRestore",
"pos": [500, 200]
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [200, 200]
},
{
"id": 2,
"type": "ImageUpscale",
"pos": [500, 200]
}
]
}
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "comfyui-copilot"
description = ""
version = "1.0.0"
license = { file = "LICENSE" }
[project.urls]
Repository = "https://github.com/ai-business-hql/comfyUIAgent"
[tool.comfy]
PublisherId = "weixuan11"
DisplayName = "comfyui-copilot"
Icon = ""
+1
View File
@@ -0,0 +1 @@
send2trash
+57
View File
@@ -0,0 +1,57 @@
const fs = require('fs');
const { exec } = require('child_process');
const path = require('path');
const files = [
{ path: '../.git/info/exclude.dist', content: '/dist' },
{
path: '../.git/hooks/post-checkout',
content: `
#!/bin/sh
# Get the current branch name
BRANCH_NAME=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD)
# Remove previously set exclude files
rm -f .git/info/exclude
# When not in the beta/main branch, add additional files that need to be ignored
if [ "$BRANCH_NAME" != "beta" ] && [ "$BRANCH_NAME" != "main" ]; then
cp .git/info/exclude.dist .git/info/exclude
# elif [ "$BRANCH_NAME" = "xxx" ]; then
# cp .git/info/exclude.xxx .git/info/exclude
fi`,
},
];
function createFile(filePath, content) {
fs.writeFile(filePath, content, (err) => {
if (err) {
console.error(`An error occurred while creating file ${filePath}:`, err);
}
});
}
function executeCommand(command, workingDirectory = '../') {
exec(command, { cwd: path.resolve(__dirname, workingDirectory) }, (error, stdout, stderr) => {
if (error) {
console.error(`An error occurred while executing command "${command}":`, error);
return;
}
if (stderr) {
console.error(`Command error "${stderr}"`);
return;
}
});
}
function main() {
files.forEach((file) => {
createFile(file.path, file.content);
});
executeCommand('git config advice.ignoredHook false');
executeCommand('chmod +x .git/hooks/post-checkout');
console.log('Custom git hooks installation completed')
}
main();
+240
View File
@@ -0,0 +1,240 @@
import json
import os
import asyncio
import time
from typing import Optional, Dict, Any, TypedDict, List, Union
import server
from aiohttp import web
import aiohttp
import base64
# 使用内存字典存储会话消息
session_messages = {}
# 在文件开头添加
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "public")
# Define types using TypedDict
class Node(TypedDict):
name: str # 节点名称
description: str # 节点描述
image: str # 节点图片url,可为空
github_url: str # 节点github地址
from_index: int # 节点在列表中的位置
to_index: int # 节点在列表中的位置
class NodeInfo(TypedDict):
existing_nodes: List[Node] # 已安装的节点
missing_nodes: List[Node] # 未安装的节点
class Workflow(TypedDict, total=False):
id: Optional[int] # 工作流id
name: Optional[str] # 工作流名称
description: Optional[str] # 工作流描述
image: Optional[str] # 工作流图片
workflow: Optional[str] # 工作流
class ExtItem(TypedDict):
type: str # 扩展类型
data: Union[dict, list] # 扩展数据
class ChatResponse(TypedDict):
session_id: str # 会话id
text: Optional[str] # 返回文本
finished: bool # 是否结束
type: str # 返回的类型
format: str # 返回的格式
ext: Optional[List[ExtItem]] # 扩展信息
def get_workflow_templates():
templates = []
workflows_dir = os.path.join(STATIC_DIR, "workflows")
for filename in os.listdir(workflows_dir):
if filename.endswith('.json'):
with open(os.path.join(workflows_dir, filename), 'r') as f:
template = json.load(f)
templates.append(template)
return templates
@server.PromptServer.instance.routes.get("/workspace/fetch_messages_by_id")
async def fetch_messages(request):
session_id = request.query.get('session_id')
data = await asyncio.to_thread(fetch_messages_sync, session_id)
return web.json_response(data)
def fetch_messages_sync(session_id):
print("fetch_messages: ", session_id)
return session_messages.get(session_id, [])
@server.PromptServer.instance.routes.post("/workspace/workflow_gen")
async def workflow_gen(request):
print("Received workflow_gen request")
req_json = await request.json()
print("Request JSON:", req_json)
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'application/json',
'X-Content-Type-Options': 'nosniff'
}
)
await response.prepare(request)
session_id = req_json.get('session_id')
user_message = req_json.get('message')
# Create user message
user_msg = {
"id": str(len(session_messages.get(session_id, []))),
"content": user_message,
"role": "user"
}
if "workflow" in user_message.lower():
workflow = {
"name": "basic_image_gen",
"description": "Create a basic image generation workflow",
"image": "https://placehold.co/600x400",
"workflow": """{ ... }""" # Your workflow JSON here
}
chat_response = ChatResponse(
session_id=session_id,
text="",
finished=False,
type="workflow_option",
format="text",
ext=[{"type": "workflows", "data": [workflow]}]
)
await response.write(json.dumps(chat_response).encode() + b"\n")
message = "Let me help you choose a workflow. Here are some options available:"
accumulated = ""
for char in message:
accumulated += char
chat_response["text"] = accumulated
await response.write(json.dumps(chat_response).encode() + b"\n")
await asyncio.sleep(0.01)
chat_response["finished"] = True
chat_response["text"] = message
await response.write(json.dumps(chat_response).encode() + b"\n")
elif "recommend" in user_message.lower():
existing_nodes = [
{
"name": "LoraLoader",
"description": "Load LoRA weights for conditioning.",
"image": "",
"github_url": "https://github.com/CompVis/taming-transformers",
"from_index": 0,
"to_index": 0
},
{
"name": "KSampler",
"description": "Generate images using K-diffusion sampling.",
"image": "",
"github_url": "https://github.com/CompVis/taming-transformers",
"from_index": 0,
"to_index": 0
}
]
missing_nodes = [
{
"name": "CLIPTextEncode",
"description": "Encode text prompts for conditioning.",
"image": "",
"github_url": "https://github.com/CompVis/clip-interrogator",
"from_index": 0,
"to_index": 0
}
]
node_info = {
"existing_nodes": existing_nodes,
"missing_nodes": missing_nodes
}
chat_response = ChatResponse(
session_id=session_id,
text="",
finished=False,
type="downstream_node_recommend",
format="text",
ext=[{"type": "node_info", "data": node_info}]
)
await response.write(json.dumps(chat_response).encode() + b"\n")
message = "Here are some recommended nodes:"
accumulated = ""
for char in message:
accumulated += char
chat_response["text"] = accumulated
await response.write(json.dumps(chat_response).encode() + b"\n")
await asyncio.sleep(0.01)
chat_response["finished"] = True
chat_response["text"] = message
await response.write(json.dumps(chat_response).encode() + b"\n")
else:
chat_response = ChatResponse(
session_id=session_id,
text="",
finished=False,
type="message",
format="text",
ext=[{"type": "guides", "data": ["Create a workflow", "Search for nodes", "Get node recommendations"]}]
)
await response.write(json.dumps(chat_response).encode() + b"\n")
message = "I can help you with workflows, nodes, and more. Try asking about:"
accumulated = ""
for char in message:
accumulated += char
chat_response["text"] = accumulated
await response.write(json.dumps(chat_response).encode() + b"\n")
await asyncio.sleep(0.01)
chat_response["finished"] = True
chat_response["text"] = message
await response.write(json.dumps(chat_response).encode() + b"\n")
if session_id not in session_messages:
session_messages[session_id] = []
session_messages[session_id].extend([user_msg])
await response.write_eof()
return response
async def upload_to_oss(file_data: bytes, filename: str) -> str:
# Implement your OSS upload logic here
# Return the URL of the uploaded file
pass
@server.PromptServer.instance.routes.post("/api/chat/invoke")
async def invoke_chat(request):
data = await request.json()
session_id = data['session_id']
prompt = data['prompt']
images = data.get('images', [])
image_urls = []
for image in images:
# 从base64解码图片数据
image_data = base64.b64decode(image['data'].split(',')[1])
# 上传到OSS
url = await upload_to_oss(image_data, image['filename'])
image_urls.append(url)
# 使用image_urls进行后续处理...
+192
View File
@@ -0,0 +1,192 @@
import inspect
import json
import os
import subprocess
from nodes import NODE_CLASS_MAPPINGS
import server
from aiohttp import web
BUILT_IN_NODE_TYPES = {
"BasicScheduler",
"CLIPLoader",
"CLIPMergeSimple",
"CLIPSave",
"CLIPSetLastLayer",
"CLIPTextEncode",
"CLIPTextEncodeSDXL",
"CLIPTextEncodeSDXLRefiner",
"CLIPVisionEncode",
"CLIPVisionLoader",
"Canny",
"CheckpointLoader",
"CheckpointLoaderSimple",
"CheckpointSave",
"ConditioningAverage",
"ConditioningCombine",
"ConditioningConcat",
"ConditioningSetArea",
"ConditioningSetAreaPercentage",
"ConditioningSetAreaStrength",
"ConditioningSetMask",
"ConditioningSetTimestepRange",
"ConditioningZeroOut",
"ControlNetApply",
"ControlNetApplyAdvanced",
"ControlNetLoader",
"CropMask",
"DiffControlNetLoader",
"DiffusersLoader",
"DualCLIPLoader",
"EmptyImage",
"EmptyLatentImage",
"ExponentialScheduler",
"FeatherMask",
"FlipSigmas",
"FreeU",
"FreeU_V2",
"GLIGENLoader",
"GLIGENTextBoxApply",
"GrowMask",
"HyperTile",
"HypernetworkLoader",
"ImageBatch",
"ImageBlend",
"ImageBlur",
"ImageColorToMask",
"ImageCompositeMasked",
"ImageCrop",
"ImageInvert",
"ImageOnlyCheckpointLoader",
"ImageOnlyCheckpointSave",
"ImagePadForOutpaint",
"ImageQuantize",
"ImageScale",
"ImageScaleBy",
"ImageScaleToTotalPixels",
"ImageSharpen",
"ImageToMask",
"ImageUpscaleWithModel",
"InpaintModelConditioning",
"InvertMask",
"JoinImageWithAlpha",
"KSampler",
"KSamplerAdvanced",
"KSamplerSelect",
"KarrasScheduler",
"LatentAdd",
"LatentBatch",
"LatentBatchSeedBehavior",
"LatentBlend",
"LatentComposite",
"LatentCompositeMasked",
"LatentCrop",
"LatentFlip",
"LatentFromBatch",
"LatentInterpolate",
"LatentMultiply",
"LatentRotate",
"LatentSubtract",
"LatentUpscale",
"LatentUpscaleBy",
"LoadImage",
"LoadImageMask",
"LoadLatent",
"LoraLoader",
"LoraLoaderModelOnly",
"MaskComposite",
"MaskToImage",
"ModelMergeAdd",
"ModelMergeBlocks",
"ModelMergeSimple",
"ModelMergeSubtract",
"ModelSamplingContinuousEDM",
"ModelSamplingDiscrete",
"PatchModelAddDownscale",
"PerpNeg",
"PhotoMakerEncode",
"PhotoMakerLoader",
"PolyexponentialScheduler",
"PorterDuffImageComposite",
"PreviewImage",
"RebatchImages",
"RebatchLatents",
"RepeatImageBatch",
"RepeatLatentBatch",
"RescaleCFG",
"SDTurboScheduler",
"SD_4XUpscale_Conditioning",
"SVD_img2vid_Conditioning",
"SamplerCustom",
"SamplerDPMPP_2M_SDE",
"SamplerDPMPP_SDE",
"SaveAnimatedPNG",
"SaveAnimatedWEBP",
"SaveImage",
"SaveLatent",
"SelfAttentionGuidance",
"SetLatentNoiseMask",
"SolidMask",
"SplitImageWithAlpha",
"SplitSigmas",
"StableZero123_Conditioning",
"StableZero123_Conditioning_Batched",
"StyleModelApply",
"StyleModelLoader",
"TomePatchModel",
"UNETLoader",
"UpscaleModelLoader",
"VAEDecode",
"VAEDecodeTiled",
"VAEEncode",
"VAEEncodeForInpaint",
"VAEEncodeTiled",
"VAELoader",
"VAESave",
"VPScheduler",
"VideoLinearCFGGuidance",
"unCLIPCheckpointLoader",
"unCLIPConditioning"
}
def get_git_repo(node_type: str):
if node_type not in NODE_CLASS_MAPPINGS:
return None
if node_type in BUILT_IN_NODE_TYPES:
return None
cls = NODE_CLASS_MAPPINGS[node_type]
source_file = inspect.getfile(cls)
directory = os.path.dirname(source_file)
# Attempt to get the remote repository URL directly
try:
git_repo = subprocess.check_output(["git", "-C", directory, "config", "--get", "remote.origin.url"], text=True).strip()
except subprocess.CalledProcessError:
return None
# Attempt to get the current commit hash
try:
commit_hash = subprocess.check_output(["git", "-C", directory, "rev-parse", "HEAD"], text=True).strip()
except subprocess.CalledProcessError:
return None
if git_repo.endswith(".git"):
git_repo = git_repo[:-4]
username = git_repo.split("/")[-2]
repo_name = git_repo.split("/")[-1]
return {"gitRepo": f"{username}/{repo_name}", "commitHash": commit_hash}
@server.PromptServer.instance.routes.post("/workspace/fetch_node_repos") # Handle POST requests
async def fetch_node_repos(request):
data = await request.json()
nodetypes = data.get("nodes")
if not nodetypes:
return web.Response(status=400, text="NodeTypes parameter is required and should be a list of node types.")
repos_mapping = {}
for nodetype in nodetypes:
try:
repo = get_git_repo(nodetype)
if repo:
repos_mapping[nodetype] = repo
except Exception as e:
print(f"Error fetching repo for {nodetype}: {e}")
return web.Response(text=json.dumps(repos_mapping), content_type='application/json')
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh', '@typescript-eslint', 'prettier'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'prettier/prettier': ['error'],
},
}
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/
+15
View File
@@ -0,0 +1,15 @@
{
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"printWidth": 80,
"proseWrap": "always",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"endOfLine": "auto"
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="./src/output.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+11035
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "comfyui-workspace-manager",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:css": "tailwindcss -i ./src/input.css -o ./src/output.css --watch",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"setupGithooks": "node ../scripts/setupGitHooks",
"build:prod": "VITE_API_BASE_URL=https://comfyui-copilot-server.onrender.com vite build && node scripts/post-build.js"
},
"dependencies": {
"@tabler/icons-react": "^2.42.0",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"fuse.js": "^7.0.0",
"glob": "^8.1.0",
"jszip": "^3.10.1",
"marked": "^12.0.2",
"nanoid": "^5.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"vis-network": "^9.1.9"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.10.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.49",
"prettier": "^3.2.4",
"rollup-plugin-watch": "^1.0.4",
"tailwindcss": "^3.4.15",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+44
View File
@@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
import glob from 'glob';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// 获取 __dirname 的等效值
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 在 dist/copilot_web 目录下查找包含 __vite__mapDeps 的 JS 文件
const distDir = path.resolve(__dirname, '../../dist/copilot_web');
const files = glob.sync(`${distDir}/**/App-*.js`);
files.forEach(file => {
console.log('start modify', file)
let content = fs.readFileSync(file, 'utf-8');
if (content.includes('__vite__mapDeps')) {
// 提取原始依赖数组
const depsMatch = content.match(/__vite__mapDeps\.viteFileDeps = \[(.*?)\]/);
if (depsMatch && depsMatch[1]) {
const originalDeps = depsMatch[1];
// 替换函数实现
content = content.replace(
/function __vite__mapDeps\(indexes\) {[\s\S]*?return indexes\.map\(\(i\) => __vite__mapDeps\.viteFileDeps\[i\]\)\s*}/,
`function __vite__mapDeps(indexes) {
const apiBase = window.comfyAPI?.api?.api?.api_base;
const prefix = apiBase ? \`\${apiBase.substring(1)}/\` : '';
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = [${originalDeps}].map(
path => \`\${prefix}\${path}\`
);
}
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i]);
}`
);
fs.writeFileSync(file, content, 'utf-8');
console.log(`Modified ${path.basename(file)}`);
}
}
});
+42
View File
@@ -0,0 +1,42 @@
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}`);
if (!response.ok) {
return undefined;
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching workspace:", error);
return undefined;
}
}
export async function fetchMyWorkflowsDir() {
const resp = await fetchApi("/workspace/get_my_workflows_dir");
const res = (await resp.json()) as {
path?: string;
error?: string;
os: "win32" | "darwin" | "linux";
};
if (res.error) {
alert(`Failed to fetch my workflows path: ${res.error}`);
}
return res;
}
+77
View File
@@ -0,0 +1,77 @@
import React, { useRef, useState, useEffect } from "react";
import { DRAWER_Z_INDEX } from "./const";
const WorkflowChat = React.lazy(() => import("./workflowChat/workflowChat"));
export default function App() {
const workspaceContainerRef = useRef(null);
const [showChat, setShowChat] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number }>();
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
useEffect(() => {
setPosition({
x: window.innerWidth - 160,
y: 20
});
}, []);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
const buttonRect = e.currentTarget.getBoundingClientRect();
setDragOffset({
x: e.clientX - buttonRect.left,
y: e.clientY - buttonRect.top
});
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = Math.min(Math.max(0, e.clientX - dragOffset.x), window.innerWidth - 100);
const newY = Math.min(Math.max(0, e.clientY - dragOffset.y), window.innerHeight - 40);
setPosition({ x: newX, y: newY });
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragOffset]);
if (!position) return null;
return (
<div ref={workspaceContainerRef}>
<div
className="fixed"
style={{
zIndex: DRAWER_Z_INDEX,
left: position.x,
top: position.y,
cursor: isDragging ? 'grabbing' : 'grab'
}}
>
<button
onMouseDown={handleMouseDown}
onClick={() => setShowChat(true)}
className="px-4 py-2 border border-gray-300 rounded-md bg-white text-black hover:bg-green-600 transition-colors select-none"
>
copilot
</button>
<WorkflowChat onClose={() => setShowChat(false)} visible={showChat} />
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { api } from "../utils/comfyapp";
export async function getObjectInfo(): Promise<Record<string, any>> {
try {
const response = await api.fetchApi("/object_info", { method: "GET" });
return await response.json();
} catch (error) {
console.error("Error fetching object info:", error);
throw error;
}
}
export async function getInstalledNodes(): Promise<string[]> {
const objectInfos = await getObjectInfo();
return Object.keys(objectInfos);
}
+187
View File
@@ -0,0 +1,187 @@
import { config } from '../config'
import { fetchApi } from "../Api";
import { Message, ChatResponse, OptimizedWorkflowRequest, OptimizedWorkflowResponse, Node, ExtItem } from "../types/types";
import { generateUUID } from '../utils/uuid';
const BASE_URL = config.apiBaseUrl
const getApiKey = () => {
const apiKey = localStorage.getItem('chatApiKey');
if (!apiKey) {
alert('API key is required. Please set your API key first.');
throw new Error('API key is required. Please set your API key first.');
}
return apiKey;
};
export namespace WorkflowChatAPI {
export async function* streamInvokeServer(sessionId: string, prompt: string, images: File[] = [], intent: string | null = null, ext: any | null = null): AsyncGenerator<ChatResponse> {
try {
const apiKey = getApiKey();
// Convert images to base64
const imagePromises = (images || []).map(file =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
})
);
const base64Images = await Promise.all(imagePromises);
const response = await fetch(`${BASE_URL}/api/chat/invoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'accept': 'application/json',
'Access-Control-Allow-Origin': '*',
'Authorization': `Bearer ${apiKey}`,
'trace-id': generateUUID(),
},
body: JSON.stringify({
session_id: sessionId,
prompt: prompt,
mock: false,
intent: intent,
ext: ext,
images: base64Images.map((base64, index) => ({
filename: images[index].name,
data: base64
}))
}),
});
const reader = response.body!.getReader();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += new TextDecoder().decode(value);
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line) as ChatResponse;
}
}
}
if (buffer.trim()) {
yield JSON.parse(buffer) as ChatResponse;
}
} catch (error) {
console.error('Error in streamInvokeServer:', error);
alert(error instanceof Error ? error.message : 'An error occurred while streaming');
throw error;
}
}
export async function getOptimizedWorkflow(
workflowId: number,
prompt: string
): Promise<OptimizedWorkflowResponse> {
try {
const apiKey = getApiKey();
const response = await fetch(`${BASE_URL}/api/chat/get_optimized_workflow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'accept': 'application/json',
'Access-Control-Allow-Origin': '*',
'Authorization': `Bearer ${apiKey}`,
'trace-id': generateUUID(),
},
body: JSON.stringify({
workflow_id: workflowId,
prompt: prompt
} as OptimizedWorkflowRequest),
});
const result = await response.json();
if (!result.success) {
const message = result.message || 'Failed to get optimized workflow';
alert(message);
throw new Error(message);
}
return result.data as OptimizedWorkflowResponse;
} catch (error) {
console.error('Error getting optimized workflow:', error);
alert(error instanceof Error ? error.message : 'Failed to get optimized workflow');
throw error;
}
}
export async function batchGetNodeInfo(nodeTypes: string[]): Promise<any> {
const apiKey = getApiKey();
const response = await fetch(`${BASE_URL}/api/chat/get_node_info_by_types`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'trace-id': generateUUID(),
},
body: JSON.stringify({ node_types: nodeTypes }),
});
const result = await response.json();
if (!result.success) {
const message = result.message || 'Failed to get node info by types';
alert(message);
throw new Error(message);
}
return result.data as Node[];
}
export async function fetchMessages(sessionId: string): Promise<Message[]> {
try {
const apiKey = getApiKey();
const response = await fetch(`${BASE_URL}/api/chat/get_messages_by_session_id?session_id=${sessionId}`, {
method: 'GET',
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'trace-id': generateUUID(),
}
});
const result = await response.json();
if (!result.success) {
const message = result.message || 'Failed to fetch messages';
alert(message);
throw new Error(message);
}
return result.data.map((msg: any) => {
console.log("msg.ext", msg.ext);
return {
id: msg.id.toString(),
content: msg.role === 'ai' ? JSON.stringify({
session_id: sessionId,
text: msg.content,
finished: msg.finished || false,
format: msg.format || 'markdown',
ext: msg.ext
}) : msg.content,
role: msg.role,
name: msg.role === 'ai' ? 'Assistant' : undefined,
format: msg.format || 'markdown',
finished: msg.finished
}
});
} catch (error) {
console.error('Error fetching messages:', error);
alert(error instanceof Error ? error.message : 'Failed to fetch messages');
throw error;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
interface ApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (apiKey: string) => void;
initialApiKey?: string;
}
export function ApiKeyModal({ isOpen, onClose, onSave, initialApiKey = '' }: ApiKeyModalProps) {
const [apiKey, setApiKey] = useState(initialApiKey);
const [showApiKey, setShowApiKey] = useState(false);
useEffect(() => {
setApiKey(initialApiKey);
}, [initialApiKey]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-96 shadow-xl">
<h2 className="text-lg font-semibold mb-4">Set API Key</h2>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your API key"
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-4 pr-10 bg-gray-50"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-600 hover:text-gray-800"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
) : (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
)}
</button>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md"
>
Cancel
</button>
<button
onClick={() => {
onSave(apiKey);
onClose();
}}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Save
</button>
</div>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { XIcon, TrashIcon } from './Icons';
import { ApiKeyModal } from './ApiKeyModal';
interface ChatHeaderProps {
onClose?: () => void;
onClear?: () => void;
hasMessages: boolean;
}
export function ChatHeader({ onClose, onClear, hasMessages }: ChatHeaderProps) {
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const handleApiKeyClick = () => {
setIsApiKeyModalOpen(true);
};
const handleSaveApiKey = (apiKey: string) => {
localStorage.setItem('chatApiKey', apiKey);
};
return (
<div className="flex items-center justify-between border-b px-4 py-3
bg-white border-gray-200 sticky top-0 z-10">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-800">copilot</h3>
<button
onClick={handleApiKeyClick}
className="p-1 hover:bg-gray-100 rounded"
>
🔨
</button>
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center justify-center rounded-md p-2
text-gray-500 hover:bg-gray-100 hover:text-gray-600
disabled:opacity-50 transition-colors duration-200"
disabled={!hasMessages}
onClick={onClear}>
<TrashIcon className="h-5 w-5" />
</button>
<button
className="inline-flex items-center justify-center rounded-md p-2
text-gray-500 hover:bg-gray-100 hover:text-gray-600
transition-colors duration-200"
onClick={onClose}>
<XIcon className="h-5 w-5" />
</button>
</div>
<ApiKeyModal
isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)}
onSave={handleSaveApiKey}
initialApiKey={localStorage.getItem('chatApiKey') || ''}
/>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { ChangeEvent, KeyboardEvent, useState } from 'react';
import { SendIcon, ImageIcon, PlusIcon, XIcon } from './Icons';
import React from 'react';
interface ChatInputProps {
input: string;
loading: boolean;
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
onKeyPress: (event: KeyboardEvent) => void;
onUploadImages: (files: FileList) => void;
uploadedImages: UploadedImage[];
onRemoveImage: (imageId: string) => void;
}
export interface UploadedImage {
id: string;
file: File;
preview: string;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const SUPPORTED_FORMATS = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
export function ChatInput({
input,
loading,
onChange,
onSend,
onKeyPress,
onUploadImages,
uploadedImages,
onRemoveImage,
}: ChatInputProps) {
const [showUploadModal, setShowUploadModal] = useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const invalidFiles: string[] = [];
const validFiles: File[] = [];
Array.from(event.target.files).forEach(file => {
if (!SUPPORTED_FORMATS.includes(file.type)) {
invalidFiles.push(`${file.name} (unsupported format)`);
} else if (file.size > MAX_FILE_SIZE) {
invalidFiles.push(`${file.name} (exceeds 5MB)`);
} else {
validFiles.push(file);
}
});
if (invalidFiles.length > 0) {
alert(`The following files couldn't be uploaded:\n${invalidFiles.join('\n')}`);
}
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach(file => dataTransfer.items.add(file));
onUploadImages(dataTransfer.files);
}
}
};
return (
<div className={`relative ${uploadedImages.length > 0 ? 'mt-12' : ''}`}>
{/* 已上传图片预览 */}
{uploadedImages.length > 0 && (
<div className="absolute -top-10 left-0 flex gap-2">
{uploadedImages.map(image => (
<div key={image.id} className="relative group">
<img
src={image.preview}
alt="uploaded"
className="w-8 h-8 rounded object-cover"
/>
<button
onClick={() => onRemoveImage(image.id)}
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5
opacity-0 group-hover:opacity-100 transition-opacity"
>
<XIcon className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<textarea
onChange={onChange}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
onKeyPress(e);
}}
value={input}
placeholder="Type your message..."
className="w-full min-h-[80px] resize-none rounded-md border
border-gray-200 px-3 py-2 pr-12 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500
focus:border-transparent bg-white transition-all
duration-200 text-gray-700"
/>
{/* 上传图片按钮 */}
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="absolute bottom-3 left-3 p-2 rounded-md text-gray-500
hover:bg-gray-100 hover:text-gray-600 transition-all duration-200">
<ImageIcon className="h-5 w-5" />
</button>
{/* 发送按钮 */}
<button
type="submit"
onClick={onSend}
disabled={loading}
className="absolute bottom-3 right-3 p-2 rounded-md text-gray-500
hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50
transition-all duration-200 active:scale-95">
{loading ? (
<div className="h-5 w-5 animate-spin rounded-full
border-2 border-gray-300 border-t-blue-500" />
) : (
<SendIcon className="h-5 w-5 transform transition-transform
group-hover:translate-x-1" />
)}
</button>
{/* 上传图片模态框 */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-96 relative">
<button
onClick={() => setShowUploadModal(false)}
className="absolute top-2 right-2 text-gray-500 hover:text-gray-700"
>
<XIcon className="w-5 h-5" />
</button>
<h3 className="text-lg font-medium mb-4">Upload Images</h3>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8
flex flex-col items-center justify-center gap-4
hover:border-blue-500 transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<PlusIcon className="w-8 h-8 text-gray-400" />
<div className="text-center">
<p className="text-sm text-gray-500 mb-2">
Click to upload images or drag and drop
</p>
<p className="text-xs text-gray-400">
Supported formats: JPG, PNG, GIF, WebP
</p>
<p className="text-xs text-gray-400">
Max file size: 5MB
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept={SUPPORTED_FORMATS.join(',')}
onChange={handleFileChange}
className="hidden"
/>
{/* 预览区域 */}
{uploadedImages.length > 0 && (
<div className="mt-4 grid grid-cols-3 gap-2">
{uploadedImages.map(image => (
<div key={image.id} className="relative group">
<img
src={image.preview}
alt="preview"
className="w-full h-20 object-cover rounded"
/>
<button
onClick={() => onRemoveImage(image.id)}
className="absolute -top-1 -right-1 bg-red-500 text-white
rounded-full p-0.5 opacity-0 group-hover:opacity-100
transition-opacity"
>
<XIcon className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowUploadModal(false)}
className="px-4 py-2 text-sm font-medium text-gray-700
bg-white border border-gray-300 rounded-md
hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
export function SendIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
)
}
export function XIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)
}
export function TrashIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
)
}
export function UploadIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
)
}
export function ImageIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
)
}
export function PlusIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { Message } from "../../types/types";
import { UserMessage } from "./messages/UserMessage";
import { AIMessage } from "./messages/AIMessage";
import { WorkflowOption } from "./messages/WorkflowOption";
import { NodeSearch } from "./messages/NodeSearch";
import { NodeRecommend } from "./messages/NodeRecommend";
import { DownstreamSubgraphs } from "./messages/DownstreamSubgraphs";
import { NodeInstallGuide } from "./messages/NodeInstallGuide";
import { LoadingMessage } from "./messages/LoadingMessage";
interface MessageListProps {
messages: Message[];
onOptionClick: (option: string) => void;
latestInput: string;
installedNodes: any[];
onAddMessage: (message: Message) => void;
loading?: boolean;
}
const getAvatar = (name?: string) => {
return `https://ui-avatars.com/api/?name=${name || 'User'}&background=random`;
};
export function MessageList({ messages, latestInput, onOptionClick, installedNodes, onAddMessage, loading }: MessageListProps) {
const renderMessage = (message: Message) => {
console.log('[MessageList] Rendering message:', message);
if (message.role === 'user') {
return <UserMessage key={message.id} content={message.content} />;
}
if (message.role === 'ai' || message.role === 'tool') {
const avatar = getAvatar(message.role);
try {
const response = JSON.parse(message.content);
console.log('[MessageList] Parsed message content:', response);
// 获取扩展类型
const workflowExt = response.ext?.find(item => item.type === 'workflow');
const nodeExt = response.ext?.find(item => item.type === 'node');
const nodeRecommendExt = response.ext?.find(item => item.type === 'node_recommend');
const downstreamSubgraphsExt = response.ext?.find(item => item.type === 'downstream_subgraph_search');
const nodeInstallGuideExt = response.ext?.find(item => item.type === 'node_install_guide');
console.log('[MessageList] Found extensions:', {
workflowExt,
nodeExt,
nodeRecommendExt,
downstreamSubgraphsExt,
nodeInstallGuideExt
});
// 根据扩展类型添加对应组件
let ExtComponent = null;
if (workflowExt) {
ExtComponent = (
<WorkflowOption
content={message.content}
name={message.name}
latestInput={latestInput}
installedNodes={installedNodes}
onAddMessage={onAddMessage}
/>
);
} else if (nodeRecommendExt) {
ExtComponent = (
<NodeRecommend
content={message.content}
name={message.name}
avatar={avatar}
installedNodes={installedNodes}
/>
);
} else if (nodeExt) {
ExtComponent = (
<NodeSearch
content={message.content}
name={message.name}
avatar={avatar}
installedNodes={installedNodes}
/>
);
} else if (downstreamSubgraphsExt) {
ExtComponent = (
<DownstreamSubgraphs
content={message.content}
name={message.name}
avatar={avatar}
installedNodes={installedNodes}
onAddMessage={onAddMessage}
/>
);
} else if (nodeInstallGuideExt) {
ExtComponent = (
<NodeInstallGuide
content={message.content}
onLoadSubgraph={() => {
if (message.metadata?.pendingSubgraph) {
const selectedNode = Object.values(app.canvas.selected_nodes)[0];
if (selectedNode) {
// 直接调用 DownstreamSubgraphs 中的 loadSubgraphToCanvas
const node = message.metadata.pendingSubgraph;
const nodes = node.json.nodes;
const links = node.json.links;
const entryNode = nodes.find(node => node.id === 0);
const entryNodeId = entryNode?.id;
const nodeMap = {};
if (entryNodeId) {
nodeMap[entryNodeId] = selectedNode;
}
// 创建其他所有节点
app.canvas.emitBeforeChange();
try {
for (const node of nodes) {
if (node.id !== entryNodeId) {
const posEntryOld = entryNode?.pos;
const posEntryNew = [selectedNode._pos[0], selectedNode._pos[1]];
const nodePosNew = [
node.pos[0] + posEntryNew[0] - posEntryOld[0],
node.pos[1] + posEntryNew[1] - posEntryOld[1]
];
nodeMap[node.id] = app.addNodeOnGraph(
{ name: node.type },
{pos: nodePosNew}
);
}
}
} finally {
app.canvas.emitAfterChange();
}
// 处理所有连接
for (const link of links) {
const origin_node = nodeMap[link['origin_id']];
const target_node = nodeMap[link['target_id']];
if (origin_node && target_node) {
origin_node.connect(
link['origin_slot'],
target_node,
link['target_slot']
);
}
}
} else {
alert("Please select a upstream node first before adding a subgraph.");
}
} else if (message.metadata?.pendingWorkflow) {
const workflow = message.metadata.pendingWorkflow;
const optimizedParams = message.metadata.optimizedParams;
app.loadGraphData(workflow);
for (const [nodeId, nodeName, paramIndex, paramName, value] of optimizedParams) {
const widgets = app.graph._nodes_by_id[nodeId].widgets;
for (const widget of widgets) {
if (widget.name === paramName) {
widget.value = value;
}
}
}
app.graph.setDirtyCanvas(false, true);
}
}}
/>
);
}
// 如果有response.text,使用AIMessage渲染
if (response.text || ExtComponent) {
return (
<AIMessage
key={message.id}
content={message.content}
name={message.name}
format={message.format}
onOptionClick={onOptionClick}
extComponent={ExtComponent}
/>
);
}
// 如果没有response.text但有扩展组件,直接返回扩展组件
if (ExtComponent) {
return ExtComponent;
}
// 默认返回AIMessage
return (
<AIMessage
key={message.id}
content={message.content}
name={message.name}
format={message.format}
onOptionClick={onOptionClick}
/>
);
} catch (error) {
console.error('[MessageList] Error parsing message content:', error);
// 如果解析JSON失败,使用AIMessage
console.error('解析JSON失败', message.content);
return (
<AIMessage
key={message.id}
content={message.content}
name={message.name}
format={message.format}
onOptionClick={onOptionClick}
/>
);
}
}
return null;
};
return (
<div className="flex flex-col gap-4 w-full">
{messages?.map(renderMessage)}
{loading && <LoadingMessage />}
</div>
);
}
@@ -0,0 +1,73 @@
import { app } from "../../utils/comfyapp";
interface SelectedNodeInfoProps {
nodeInfo: any;
onSendWithIntent: (intent: string, ext?: any) => void;
loading: boolean;
}
function getDownstreamSubgraphExt() {
const selectedNode = Object.values(app.canvas.selected_nodes)[0];
const nodeTypeSet = new Set<string>();
function findUpstreamNodes(node: any, depth: number) {
if (!node || depth >= 1) return;
if (node.inputs) {
for (const input of Object.values(node.inputs)) {
const linkId = (input as any).link;
if (linkId && app.graph.links[linkId]) {
const originId = app.graph.links[linkId].origin_id;
const originNode = app.graph._nodes_by_id[originId];
if (originNode) {
nodeTypeSet.add(originNode.type);
findUpstreamNodes(originNode, depth + 1);
}
}
}
}
}
if (selectedNode) {
findUpstreamNodes(selectedNode, 0);
return [{"type": "upstream_node_types", "data": Array.from(nodeTypeSet)}];
}
return null;
}
export function SelectedNodeInfo({ nodeInfo, onSendWithIntent, loading }: SelectedNodeInfoProps) {
return (
<div className="mb-3 p-3 rounded-md bg-gray-50 border border-gray-200
transform transition-all duration-200 hover:shadow-md">
<div className="text-sm">
<p>node: {nodeInfo.type}</p>
<div className="flex gap-2 mt-2">
<button
className="px-3 py-1 text-xs rounded-md bg-blue-50
hover:bg-blue-100 text-blue-700 transition-all
duration-200 hover:shadow-sm active:scale-95"
onClick={() => onSendWithIntent('node_explain')}
disabled={loading}>
Node Usage
</button>
<button
className="px-3 py-1 text-xs rounded bg-green-100
hover:bg-green-200 text-green-700 transition-colors"
onClick={() => onSendWithIntent('node_params')}
disabled={loading}>
Node Parameters
</button>
<button
className="px-3 py-1 text-xs rounded bg-purple-100
hover:bg-purple-200 text-purple-700 transition-colors"
onClick={() => onSendWithIntent('downstream_subgraph_search', getDownstreamSubgraphExt())}
disabled={loading}>
Downstream Node Recommend
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import { MemoizedReactMarkdown } from "../../markdown";
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeExternalLinks from 'rehype-external-links';
import { BaseMessage } from './BaseMessage';
import { ChatResponse } from "../../../types/types";
import { useRef } from "react";
interface AIMessageProps {
content: string;
name?: string;
avatar: string;
format?: string;
onOptionClick?: (option: string) => void;
extComponent?: React.ReactNode;
}
export function AIMessage({ content, name = 'Assistant', format, onOptionClick, extComponent }: AIMessageProps) {
const markdownWrapper = useRef<HTMLDivElement | null>()
const renderContent = () => {
try {
const response = JSON.parse(content) as ChatResponse;
const guides = response.ext?.find(item => item.type === 'guides')?.data || [];
return (
<div className="space-y-3">
{format === 'markdown' && response.text ? (
<div ref={markdownWrapper as React.RefObject<HTMLDivElement>}>
<MemoizedReactMarkdown
rehypePlugins={[
[rehypeExternalLinks, { target: '_blank' }],
rehypeKatex
]}
remarkPlugins={[remarkGfm, remarkMath]}
className="prose prose-xs prose-neutral prose-a:text-accent-foreground/50 break-words [&>*]:!my-1 leading-relaxed text-xs
prose-headings:font-semibold
prose-h1:text-base
prose-h2:text-sm
prose-h3:text-xs
prose-h4:text-xs
prose-p:text-xs
prose-ul:text-xs
prose-ol:text-xs
prose-li:text-xs
prose-code:text-xs
prose-pre:text-xs"
components={{
p: ({ children }) => {
return <p className="!my-0.5 leading-relaxed text-xs">{children}</p>
},
h1: ({ children }) => {
return <h1 className="text-base font-semibold !my-1">{children}</h1>
},
h2: ({ children }) => {
return <h2 className="text-sm font-semibold !my-1">{children}</h2>
},
h3: ({ children }) => {
return <h3 className="text-xs font-semibold !my-1">{children}</h3>
},
h4: ({ children }) => {
return <h4 className="text-xs font-semibold !my-1">{children}</h4>
},
table: ({ children }) => (
<table className="border-solid border border-[#979797] w-[100%] text-xs">{children}</table>
),
th: ({ children }) => (
<th className="border-solid bg-[#E5E7ED] dark:bg-[#FFFFFF] dark:text-[#000000] border border-[#979797] text-center pt-2 text-xs">{children}</th>
),
td: ({ children }) => {
if (Array.isArray(children) && children?.length > 0) {
const list: any[] = [];
const length = children.length;
for (let i = 0; i < length; i++) {
if (children[i] === '<br>') {
list.push(<br />)
} else {
list.push(children[i])
}
}
children = list;
}
return (
<td className="border-solid border border-[#979797] text-center text-xs">{children}</td>
)
},
code: ({ children }) => {
return <code className="text-xs bg-gray-100 rounded px-1">{children}</code>
},
pre: ({ children }) => {
return <pre className="text-xs bg-gray-100 rounded p-2 overflow-x-auto">{children}</pre>
},
img: ({ node, ...props }) => (
<img
{...props}
loading="lazy"
className="max-w-full h-auto"
onError={(e) => {
console.warn('Image failed to load:', props.src);
e.currentTarget.style.display = 'none';
}}
crossOrigin="anonymous"
/>
),
}}
>
{response.text}
</MemoizedReactMarkdown>
</div>
) : response.text ? (
<p className="whitespace-pre-wrap text-left">
{response.text}
</p>
) : null}
{guides.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{guides.map((guide: string, index: number) => (
<button
key={index}
className="px-3 py-1.5 bg-white text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs w-full"
onClick={() => onOptionClick?.(guide)}
>
{guide}
</button>
))}
</div>
)}
{extComponent}
</div>
);
} catch {
return <p className="whitespace-pre-wrap text-left">{content}</p>;
}
};
return (
<BaseMessage name={name}>
<div className="w-full rounded-lg bg-gray-50 p-4 text-gray-700 text-sm break-words overflow-hidden">
{renderContent()}
</div>
</BaseMessage>
);
}
@@ -0,0 +1,15 @@
interface BaseMessageProps {
name: string;
isUser?: boolean;
children: React.ReactNode;
}
export function BaseMessage({ name, isUser = false, children }: BaseMessageProps) {
return (
<div className="flex flex-col w-full items-start gap-2 py-2 animate-fadeIn">
<div className="w-full">
{children}
</div>
</div>
);
}
@@ -0,0 +1,291 @@
import { app } from "../../../utils/comfyapp";
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChatResponse, Node, Subgraph } from "../../../types/types";
import { Network } from 'vis-network';
import { WorkflowChatAPI } from "../../../apis/workflowChatApi";
import { generateUUID } from "../../../utils/uuid";
interface DownstreamSubgraphsProps {
content: string;
name?: string;
avatar: string;
installedNodes: any[];
onAddMessage?: (message: Message) => void;
}
export function DownstreamSubgraphs({ content, name = 'Assistant', avatar, installedNodes, onAddMessage }: DownstreamSubgraphsProps) {
const response = JSON.parse(content) as ChatResponse;
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const networkRef = useRef<Network | null>(null);
const nodes = response.ext?.find(item => item.type === 'downstream_subgraph_search')?.data || [];
// 将 Subgraph 转换为 vis.js 格式的函数
const convertToVisFormat = (subgraph: Subgraph) => {
const visNodes = subgraph.json.nodes.map(node => ({
id: node.id,
label: node.type,
color: {
background: '#2B7CE9',
border: '#1B5BB1'
},
font: {
size: 16,
color: '#FFFFFF',
face: 'arial',
bold: true
},
margin: 12,
padding: 10,
shape: 'box',
widthConstraint: {
minimum: 100,
maximum: 200
}
}));
const visEdges = subgraph.json.links.map(link => ({
from: link.origin_id,
to: link.target_id,
arrows: {
to: { enabled: true, scaleFactor: 1.2 }
},
color: '#FFFFFF',
width: 2
}));
return { nodes: visNodes, edges: visEdges };
};
// 添加清理函数
useEffect(() => {
return () => {
if (networkRef.current) {
networkRef.current.destroy();
networkRef.current = null;
}
};
}, []);
const createNetwork = useCallback((el: HTMLElement, node: Subgraph) => {
if (networkRef.current) {
networkRef.current.destroy();
}
const visData = convertToVisFormat(node);
const newNetwork = new Network(el, visData, {
nodes: {
shape: 'box',
margin: 12,
padding: 10,
font: {
size: 16,
color: '#FFFFFF',
face: 'arial',
bold: true
},
borderWidth: 2,
shadow: {
enabled: true,
color: 'rgba(0,0,0,0.2)',
size: 5
}
},
edges: {
arrows: 'to',
color: '#FFFFFF',
width: 2,
smooth: {
enabled: true,
type: 'straightCross'
}
},
physics: {
enabled: true,
solver: 'hierarchicalRepulsion',
hierarchicalRepulsion: {
nodeDistance: 200,
springLength: 200
}
},
layout: {
hierarchical: {
enabled: true,
direction: 'UD',
sortMethod: 'directed',
nodeSpacing: 200,
levelSeparation: 150
}
},
interaction: {
dragNodes: false,
zoomView: false,
dragView: false
}
});
networkRef.current = newNetwork;
}, []);
const checkAndLoadSubgraph = async (node: Subgraph) => {
console.log('[DownstreamSubgraphs] Starting checkAndLoadSubgraph with node:', node);
const nodes = node.json.nodes;
const selectedNode = Object.values(app.canvas.selected_nodes)[0];
if (!selectedNode) {
console.warn('[DownstreamSubgraphs] No node selected');
alert("Please select a upstream node first before adding a subgraph.");
return;
}
// 检查所有节点是否已安装
const requiredNodeTypes = nodes.map(node => node.type);
const installedNodeTypes = installedNodes;
console.log('[DownstreamSubgraphs] Required node types:', requiredNodeTypes);
console.log('[DownstreamSubgraphs] Installed node types:', installedNodeTypes);
const missingNodeTypes = requiredNodeTypes.filter(
type => !installedNodeTypes.includes(type)
);
console.log('[DownstreamSubgraphs] Missing node types:', missingNodeTypes);
if (missingNodeTypes.length > 0) {
try {
console.log('[DownstreamSubgraphs] Fetching info for missing nodes');
const nodeInfos = await WorkflowChatAPI.batchGetNodeInfo(missingNodeTypes);
console.log('[DownstreamSubgraphs] Received node infos:', nodeInfos);
// 构造消息内容 - 修改为显示按钮列表格式
const messageContent = {
text: ``,
ext: [{
type: 'node_install_guide',
data: nodeInfos.map(info => ({
name: info.name,
repository_url: info.github_url
}))
}]
};
console.log('[DownstreamSubgraphs] Created message content:', messageContent);
const aiMessage = {
id: generateUUID(),
role: 'ai',
content: JSON.stringify(messageContent),
format: 'markdown',
name: 'Assistant',
// 保存原始的subgraph信息,用于后续加载
metadata: {
pendingSubgraph: node
}
};
console.log('[DownstreamSubgraphs] Created AI message:', aiMessage);
onAddMessage?.(aiMessage);
return;
} catch (error) {
console.error('[DownstreamSubgraphs] Error fetching node info:', error);
alert('Error checking required nodes. Please try again.');
return;
}
}
console.log('[DownstreamSubgraphs] All required nodes are installed, proceeding to load subgraph');
loadSubgraphToCanvas(node, selectedNode);
};
const loadSubgraphToCanvas = (node: Subgraph, selectedNode: any) => {
const nodes = node.json.nodes;
const links = node.json.links;
const entryNode = nodes.find(node => node.id === 0);
const entryNodeId = entryNode?.id;
console.log('[DownstreamSubgraphs] Entry node ID:', entryNodeId);
const nodeMap = {};
if (entryNodeId !== null) {
nodeMap[entryNodeId] = selectedNode;
}
// 创建其他所有节点
app.canvas.emitBeforeChange();
try {
for (const node of nodes) {
if (node.id !== entryNodeId) {
const posEntryOld = entryNode?.pos;
const posEntryNew = [selectedNode._pos[0], selectedNode._pos[1]];
const nodePosNew = [
node.pos[0] + posEntryNew[0] - posEntryOld[0],
node.pos[1] + posEntryNew[1] - posEntryOld[1]
];
nodeMap[node.id] = app.addNodeOnGraph(
{ name: node.type },
{pos: nodePosNew}
);
}
}
// 处理所有连接
for (const link of links) {
const origin_node = nodeMap[link['origin_id']];
const target_node = nodeMap[link['target_id']];
if (origin_node && target_node) {
origin_node.connect(
link['origin_slot'],
target_node,
link['target_slot']
);
}
}
} finally {
app.canvas.emitAfterChange();
}
};
return (
<div className="rounded-lg bg-green-50 p-3 text-gray-700 text-xs break-words overflow-visible">
{nodes.length > 0 && (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{nodes.map((node: Subgraph) => (
<div key={node.name} className="relative group">
<button
className="px-3 py-1.5 bg-blue-500 text-white rounded-md
hover:bg-blue-600 transition-colors text-xs"
onClick={() => checkAndLoadSubgraph(node)}
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.name}
</button>
<p className="text-xs ml-3 text-gray-500">
[{node.tags.join(', ')}]
</p>
{hoveredNode === node.name && (
<div
className="fixed transform -translate-y-full
z-[9999] w-[500px] p-4 bg-gray-800 text-white text-xs
rounded-md shadow-lg mb-2 border border-gray-700"
style={{
left: 'calc(var(--mouse-x, 0) + 16px)',
top: 'calc(var(--mouse-y, 0) - 8px)'
}}
>
<div
style={{ width: '100%', height: '300px' }}
ref={(el) => {
if (el) {
createNetwork(el, node);
}
}}
/>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import { BaseMessage } from "./BaseMessage";
export function LoadingMessage() {
return (
<BaseMessage name="Assistant">
<div className="flex items-center space-x-2 p-4 bg-gray-50 rounded-lg">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
<span className="text-sm text-gray-500">AI is thinking...</span>
</div>
</BaseMessage>
);
}
@@ -0,0 +1,39 @@
import React from 'react';
interface NodeInstallGuideProps {
content: string;
onLoadSubgraph?: () => void;
}
export function NodeInstallGuide({ content, onLoadSubgraph }: NodeInstallGuideProps) {
const response = JSON.parse(content);
const nodeInfos = response.ext?.find(item => item.type === 'node_install_guide')?.data || [];
return (
<div>
<p>在加载graph到画布前,以下节点有待安装,请跳转到对应的github安装节点:</p>
<div className="space-y-2">
{nodeInfos.map((node: any, index: number) => (
<button
key={index}
className="w-full px-3 py-2 bg-white text-blue-600 rounded-md
hover:bg-blue-50 transition-colors text-xs text-left"
onClick={() => window.open(node.repository_url, '_blank')}
>
{node.name}
</button>
))}
</div>
<p>安装完成后,请点击继续加载graph按钮,将graph加载到画布中:</p>
<div className="mt-4">
<button
className="w-full px-3 py-2 bg-blue-500 text-white rounded-md
hover:bg-blue-600 transition-colors text-xs"
onClick={onLoadSubgraph}
>
继续加载graph
</button>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { app } from "../../../utils/comfyapp";
import { useState } from 'react';
import { ChatResponse, Node } from "../../../types/types";
interface NodeRecommendProps {
content: string;
name?: string;
avatar: string;
installedNodes: any[];
}
export function NodeRecommend({ content, name = 'Assistant', avatar, installedNodes }: NodeRecommendProps) {
const response = JSON.parse(content) as ChatResponse;
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const nodes = response.ext?.find(item => item.type === 'node')?.data || [];
const isNodeInstalled = (nodeName: string) => {
return installedNodes.some(node => node === nodeName);
};
return (
// <BaseMessage avatar={avatar} name={name}>
<div className="rounded-lg bg-green-50 p-3 text-gray-700 text-xs break-words overflow-visible">
{nodes.length > 0 && (
<div className="space-y-3">
<p className="text-xs">Recommended downstream nodes that can be connected:</p>
<div className="flex flex-wrap gap-2">
{nodes.map((node: Node) => {
const installed = isNodeInstalled(node.name);
return (
<div key={node.name} className="relative group">
{installed ? (
<button
className="px-3 py-1.5 bg-blue-500 text-white rounded-md
hover:bg-blue-600 transition-colors text-xs"
onClick={() => {
const addNode = app.addNodeOnGraph({ name: node.name });
if (addNode) {
const selectedNode = Object.values(app.canvas.selected_nodes)[0];
if (selectedNode) {
selectedNode.connect(node.from_index, addNode, node.to_index);
}
}
}}
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.name}
</button>
) : (
<a
href={node.github_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md
hover:bg-gray-200 transition-colors text-xs border
border-gray-200"
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.name}
</a>
)}
{hoveredNode === node.name && node.description && (
<div className="fixed transform -translate-y-full
z-[9999] w-64 p-2 bg-gray-800 text-white text-xs
rounded-md shadow-lg mb-2"
style={{
left: 'calc(var(--mouse-x, 0) + 16px)',
top: 'calc(var(--mouse-y, 0) - 8px)'
}}
>
{node.description}
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2
border-4 border-transparent border-t-gray-800"/>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{response.node_info?.missing_nodes && response.node_info.missing_nodes.length > 0 && (
<div className="space-y-3 mt-4">
<p>Other recommended nodes (requires installation):</p>
<div className="flex flex-wrap gap-2">
{response.node_info.missing_nodes.map((node: Node) => (
<div key={node.name} className="relative group">
<a
href={node.github_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md
hover:bg-gray-200 transition-colors text-xs border
border-gray-200"
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.name}
</a>
{hoveredNode === node.name && node.description && (
<div className="fixed transform -translate-y-full
z-[9999] w-64 p-2 bg-gray-800 text-white text-xs
rounded-md shadow-lg mb-2"
style={{
left: 'calc(var(--mouse-x, 0) + 16px)',
top: 'calc(var(--mouse-y, 0) - 8px)'
}}
>
{node.description}
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2
border-4 border-transparent border-t-gray-800"/>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
// </BaseMessage>
);
}
@@ -0,0 +1,109 @@
import { app } from "../../../utils/comfyapp";
import { BaseMessage } from './BaseMessage';
import { useState } from 'react';
import { ChatResponse, Node } from "../../../types/types";
interface NodeSearchProps {
content: string;
name?: string;
avatar: string;
installedNodes: any[];
}
export function NodeSearch({ content, name = 'Assistant', avatar, installedNodes }: NodeSearchProps) {
const response = JSON.parse(content) as ChatResponse;
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const nodes = response.ext?.find(item => item.type === 'node')?.data || [];
const isNodeInstalled = (nodeName: string) => {
return installedNodes.some(node => node === nodeName);
};
const installedNodesList = nodes.filter(node => isNodeInstalled(node.name));
const uninstalledNodesList = nodes.filter(node => !isNodeInstalled(node.name));
return (
<div className="rounded-lg bg-green-50 p-3 text-gray-700 text-xs break-words overflow-visible">
{installedNodesList.length > 0 && (
<div className="space-y-3">
<p className="text-xs">Available nodes that can be added to canvas:</p>
<div className="flex flex-wrap gap-2">
{installedNodesList.map((node: Node) => (
<div key={node.name} className="relative group">
<button
className="px-3 py-1.5 bg-blue-500 text-white rounded-md
hover:bg-blue-600 transition-colors text-xs"
onClick={() => {
const addNode = app.addNodeOnGraph({ name: node.name });
if (addNode) {
addNode.connect(node.from_index, addNode, node.to_index);
}
}}
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.name}
</button>
{hoveredNode === node.name && node.description && (
<div className="fixed transform -translate-y-full
z-[9999] w-64 p-2 bg-gray-800 text-white text-xs
rounded-md shadow-lg mb-2"
style={{
left: 'calc(var(--mouse-x, 0) + 16px)',
top: 'calc(var(--mouse-y, 0) - 8px)'
}}
>
{node.description}
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2
border-4 border-transparent border-t-gray-800"/>
</div>
)}
</div>
))}
</div>
</div>
)}
{uninstalledNodesList.length > 0 && (
<div className={`space-y-3 ${installedNodesList.length > 0 ? 'mt-4' : ''}`}>
<p className="text-xs">
Uninstalled nodes, you can install them from github:
</p>
<div className="flex flex-wrap gap-2">
{uninstalledNodesList.map((node: Node) => (
<div key={node.name} className="relative group">
<a
href={node.github_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md
hover:bg-gray-200 transition-colors text-xs border
border-gray-200"
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.name}
</a>
{hoveredNode === node.name && node.description && (
<div className="fixed transform -translate-y-full
z-[9999] w-64 p-2 bg-gray-800 text-white text-xs
rounded-md shadow-lg mb-2"
style={{
left: 'calc(var(--mouse-x, 0) + 16px)',
top: 'calc(var(--mouse-y, 0) - 8px)'
}}
>
{node.description}
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2
border-4 border-transparent border-t-gray-800"/>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,15 @@
import { BaseMessage } from './BaseMessage';
interface UserMessageProps {
content: string;
}
export function UserMessage({ content }: UserMessageProps) {
return (
<BaseMessage name="User" isUser={true}>
<div className="w-full rounded-lg border border-gray-700 p-4 text-gray-700 text-sm break-words">
<p className="whitespace-pre-wrap">{content}</p>
</div>
</BaseMessage>
);
}
@@ -0,0 +1,158 @@
import { app } from "../../../utils/comfyapp";
import { BaseMessage } from './BaseMessage';
import { ChatResponse, Workflow } from "../../../types/types";
import { WorkflowChatAPI } from "../../../apis/workflowChatApi";
import { MemoizedReactMarkdown } from "../../markdown";
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeExternalLinks from 'rehype-external-links';
import { generateUUID } from "../../../utils/uuid";
interface WorkflowOptionProps {
content: string;
name?: string;
avatar: string;
latestInput: string;
installedNodes: any[];
onAddMessage?: (message: any) => void;
}
export function WorkflowOption({ content, name = 'Assistant', avatar, latestInput, installedNodes, onAddMessage }: WorkflowOptionProps) {
const response = JSON.parse(content) as ChatResponse;
const workflows = response.ext?.find(item => item.type === 'workflow')?.data || [];
const handleAcceptWorkflow = async (workflow: Workflow) => {
if (!workflow.id) {
console.error('No workflow id provided');
return;
}
try {
// 获取优化后的工作流
const optimizedResult = await WorkflowChatAPI.getOptimizedWorkflow(
workflow.id,
latestInput
);
// 加载优化后的工作流
if (optimizedResult.workflow) {
// 检查是否需要安装节点
const nodeTypes = new Set<string>();
for (const node of optimizedResult.workflow.nodes) {
nodeTypes.add(node.type);
}
console.log('[WorkflowOption] Required node types:', Array.from(nodeTypes));
console.log('[WorkflowOption] Installed nodes:', installedNodes);
const missingNodeTypes = Array.from(nodeTypes).filter(
type => !installedNodes.includes(type)
);
console.log('[WorkflowOption] Missing node types:', missingNodeTypes);
if (missingNodeTypes.length > 0) {
try {
console.log('[WorkflowOption] Fetching info for missing nodes');
const nodeInfos = await WorkflowChatAPI.batchGetNodeInfo(missingNodeTypes);
console.log('[WorkflowOption] Received node infos:', nodeInfos);
const messageContent = {
text: ``,
ext: [{
type: 'node_install_guide',
data: nodeInfos.map(info => ({
name: info.name,
repository_url: info.github_url
}))
}]
};
const aiMessage = {
id: generateUUID(),
role: 'ai',
content: JSON.stringify(messageContent),
format: 'markdown',
name: 'Assistant',
metadata: {
pendingWorkflow: optimizedResult.workflow,
optimizedParams: optimizedResult.optimized_params
}
};
onAddMessage?.(aiMessage);
return;
} catch (error) {
console.error('[WorkflowOption] Error fetching node info:', error);
alert('Error checking required nodes. Please try again.');
return;
}
}
// 如果所有节点都已安装,直接加载工作流
loadWorkflow(optimizedResult.workflow, optimizedResult.optimized_params);
}
} catch (error) {
console.error('Failed to optimize workflow:', error);
}
};
const loadWorkflow = (workflow: any, optimizedParams: any[]) => {
app.loadGraphData(workflow);
// 应用优化后的参数 [节点id,节点名称,参数id,参数名称,参数默认值]
for (const [nodeId, nodeName, paramIndex, paramName, value] of optimizedParams) {
const widgets = app.graph._nodes_by_id[nodeId].widgets;
for (const widget of widgets) {
if (widget.name === paramName) {
widget.value = value;
}
}
}
app.graph.setDirtyCanvas(false, true);
};
return (
// <BaseMessage avatar={avatar} name={name}>
<div className="space-y-3">
{workflows.length > 0 && (
<div className="flex flex-col space-y-4">
{workflows.map((workflow: Workflow, index: number) => (
<div key={index} className="flex items-center gap-4 p-4 rounded-lg border border-gray-200 hover:bg-gray-50">
{workflow.image && (
<img
src={workflow.image}
alt={workflow.name}
className="w-14 h-14 object-cover rounded-lg"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null; // 防止循环触发
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56' fill='none'%3E%3Crect width='56' height='56' fill='%23F3F4F6'/%3E%3Cpath d='M28 28C30.2091 28 32 26.2091 32 24C32 21.7909 30.2091 20 28 20C25.7909 20 24 21.7909 24 24C24 26.2091 25.7909 28 28 28Z' fill='%239CA3AF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18.7253 37.6307C19.8278 35.1533 22.6897 33.6 26 33.6H30C33.3103 33.6 36.1722 35.1533 37.2747 37.6307C37.6419 38.4561 37.0611 39.2 36.1694 39.2H19.8306C18.9389 39.2 18.3581 38.4561 18.7253 37.6307Z' fill='%239CA3AF'/%3E%3C/svg%3E";
}}
/>
)}
<div className="flex-1 break-words flex flex-col justify-between">
<div>
<h3 className="font-medium text-xs">{workflow.name}</h3>
{workflow.description && (
<p className="text-gray-600 text-xs">{workflow.description}</p>
)}
</div>
<div className="flex justify-end mt-4">
<button
onClick={() => handleAcceptWorkflow(workflow)}
className="px-3 py-1.5 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm"
>
Accept
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
// </BaseMessage>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { FC, memo } from 'react'
import ReactMarkdown, { Options } from 'react-markdown'
export const MemoizedReactMarkdown: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className
)
+9
View File
@@ -0,0 +1,9 @@
const isDevelopment = import.meta.env.MODE === 'development'
const defaultApiBaseUrl = 'http://localhost:8000'
export const config = {
apiBaseUrl: isDevelopment
? defaultApiBaseUrl
: (import.meta.env.VITE_API_BASE_URL || defaultApiBaseUrl)
}
+7
View File
@@ -0,0 +1,7 @@
export const LEGACY_COMFYSPACE_TRACKING_FIELD_NAME = "comfyspace_tracking";
export const COMFYSPACE_TRACKING_FIELD_NAME = "workspace_info";
export const COMFYSPACE_AUTH_ENDPOINT = "comfyspace_auth";
export const COMFYSPACE_AUTH_REDIRECT_URL = "http://localhost:8188/";
export const DRAWER_Z_INDEX = 9999999;
export const TOPBAR_BUTTON_HEIGHT = 28;
export const SHORTCUT_TRIGGER_EVENT = "SHORTCUT_TRIGGER_EVENT";
+135
View File
@@ -0,0 +1,135 @@
export const defaultGraph = {
last_node_id: 9,
last_link_id: 9,
nodes: [
{
id: 7,
type: "CLIPTextEncode",
pos: [413, 389],
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
links: [6],
slot_index: 0,
},
],
properties: {},
widgets_values: ["text, watermark"],
},
{
id: 6,
type: "CLIPTextEncode",
pos: [415, 186],
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
links: [4],
slot_index: 0,
},
],
properties: {},
widgets_values: [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
],
},
{
id: 5,
type: "EmptyLatentImage",
pos: [473, 609],
size: { 0: 315, 1: 106 },
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
},
{
id: 3,
type: "KSampler",
pos: [863, 186],
size: { 0: 315, 1: 262 },
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
},
{
id: 8,
type: "VAEDecode",
pos: [1209, 188],
size: { 0: 210, 1: 46 },
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
},
{
id: 9,
type: "SaveImage",
pos: [1451, 189],
size: { 0: 210, 1: 26 },
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
},
{
id: 4,
type: "CheckpointLoaderSimple",
pos: [26, 474],
size: { 0: 315, 1: 98 },
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
],
groups: [],
config: {},
extra: {},
version: 0.4,
};
+51
View File
@@ -0,0 +1,51 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #d39e9e;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
height: 100%;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+34
View File
@@ -0,0 +1,34 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { waitForApp } from "./utils/comfyapp.ts";
import "./input.css";
const App = React.lazy(() =>
import("./App.tsx").then(({ default: App }) => ({
default: App,
})),
);
function waitForDocumentBody() {
return new Promise((resolve) => {
if (document.body) {
return resolve(document.body);
}
document.addEventListener("DOMContentLoaded", () => {
resolve(document.body);
});
});
}
waitForDocumentBody()
.then(() => waitForApp())
.then(() => {
const topbar = document.createElement("div");
document.body.append(topbar);
ReactDOM.createRoot(topbar).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
});
+1137
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
import { LGraphNode, IWidget } from "./litegraph";
import { ComfyApp } from "../../scripts/app";
export interface ComfyExtension {
/**
* The name of the extension
*/
name: string;
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance
*/
init?(app: ComfyApp): Promise<void>;
/**
* Allows any additonal setup, called after the application is fully set up and running
* @param app The ComfyUI app instance
*/
setup?(app: ComfyApp): Promise<void>;
/**
* Called before nodes are registered with the graph
* @param defs The collection of node definitions, add custom ones or edit existing ones
* @param app The ComfyUI app instance
*/
addCustomNodeDefs?(
defs: Record<string, ComfyObjectInfo>,
app: ComfyApp,
): Promise<void>;
/**
* Allows the extension to add custom widgets
* @param app The ComfyUI app instance
* @returns An array of {[widget name]: widget data}
*/
getCustomWidgets?(
app: ComfyApp,
): Promise<
Record<
string,
(
node,
inputName,
inputData,
app,
) => { widget?: IWidget; minWidth?: number; minHeight?: number }
>
>;
/**
* Allows the extension to add additional handling to the node before it is registered with LGraph
* @param nodeType The node class (not an instance)
* @param nodeData The original node object info config object
* @param app The ComfyUI app instance
*/
beforeRegisterNodeDef?(
nodeType: typeof LGraphNode,
nodeData: ComfyObjectInfo,
app: ComfyApp,
): Promise<void>;
/**
* Allows the extension to register additional nodes with LGraph after standard nodes are added
* @param app The ComfyUI app instance
*/
registerCustomNodes?(app: ComfyApp): Promise<void>;
/**
* Allows the extension to modify a node that has been reloaded onto the graph.
* If you break something in the backend and want to patch workflows in the frontend
* This is the place to do this
* @param node The node that has been loaded
* @param app The ComfyUI app instance
*/
loadedGraphNode?(node: LGraphNode, app: ComfyApp);
/**
* Allows the extension to run code after the constructor of the node
* @param node The node that has been created
* @param app The ComfyUI app instance
*/
nodeCreated?(node: LGraphNode, app: ComfyApp);
}
export type ComfyObjectInfo = {
name: string;
display_name?: string;
description?: string;
category: string;
input?: {
required?: Record<string, ComfyObjectInfoConfig>;
optional?: Record<string, ComfyObjectInfoConfig>;
};
output?: string[];
output_name: string[];
};
export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any];
+164
View File
@@ -0,0 +1,164 @@
import type { MODEL_TYPE } from "../model-manager/install-models/util/modelTypes";
import { TopFieldType } from "../gallery/components/MetaBox/MetadataForm.tsx";
export interface SortableItem {
name: string;
updateTime: number;
lastOpenedTime?: number;
}
export interface Workflow extends SortableItem {
id: string;
json: string;
name: string;
coverMediaPath?: string | null;
saveLock?: boolean;
cloudID?: string | null;
createTime: number;
// will be deprecated
privacy?: WorkflowPrivacy;
tags?: string[];
parentFolderID?: string | null; //TODO remove undefined, use null only
cloudOrigin?: string;
latestImage?: string;
topFieldsConfig?: TopFieldType[];
}
export interface TableBaseModel {
id?: string;
name?: string;
createTime?: number;
}
export type Tags = {
[name: string]: Tag;
};
export type Tag = {
name: string;
workflowIDs: string[];
updateTime: number;
};
export type CacheKey = string;
export type LocalCache = {
id: CacheKey;
value: string;
};
export type Model = {
id: string;
fileName: string;
fileFolder: string;
fileHash: string | null; // files are not guaranteed to finish hashing
modelName: string | null;
civitModelID?: string;
downloadUrl?: string;
civitModelVersionID?: string;
imageUrl?: string | null;
};
export interface ModelManagerPosition {
top: number;
right: number;
}
export type WorkflowVersion = {
id: string;
name: string;
workflowID: string;
json: string;
createTime?: number;
createdAt?: string;
created_at?: string;
authorID?: string;
nodeDefs?: string; //for cloud workflow version
};
export enum EShortcutKeys {
SAVE = "save",
SAVE_AS = "saveAs",
openSpotlightSearch = "openSpotlightSearch",
}
export enum EOtherKeys {
ArrowUp = "ArrowUp",
ArrowDown = "ArrowDown",
Enter = "Enter",
}
export type UserSettings = {
id: string;
myWorkflowsDir: string;
topBarStyle: PanelPosition;
shortcuts: Record<EShortcutKeys, string>;
autoSave?: boolean;
autoSaveDuration?: number; //every X seconds to save
twoWaySync?: boolean;
foldersOnTop?: boolean;
showNsfwModelThumbnail?: boolean;
cloudHost: string;
defaultFolders: Record<MODEL_TYPE, string>;
maximumChangelogNumber: number;
hideCoverImage: boolean;
disableUnsavedWarning: boolean;
};
export interface PanelPosition {
top: number;
left: number;
}
export interface Folder extends SortableItem {
id: string;
name: string;
parentFolderID: string | null; // if null, it is in the root folder
createTime: number;
type: "folder";
isCollapse?: boolean;
}
export type Changelog = {
id: string;
workflowID: string;
createTime: number;
json: string;
isAutoSave?: boolean;
};
export type Media = {
id: string;
workflowID: string;
workflowJSON?: string;
createTime: number;
localPath: string;
};
/**
* When deleting a folder, how files in the folder and files in nested folders are handled
*/
export enum EFlowOperationType {
/**
* Move to root directory
*/
MOVE_TO_ROOT_FOLDER = "moveToRootFolder",
/**
* Delete
*/
DELETE = "delete",
}
export type WorkflowPrivacy = "PUBLIC" | "PRIVATE" | "UNLISTED";
export enum EWorkflowPrivacy {
PRIVATE = "PRIVATE",
PUBLIC = "PUBLIC",
UNLISTED = "UNLISTED",
}
export const WORKSPACE_INDEXDB_NAME = "comfyui_workspace_db";
export type RecentlyOpenedFile = {
id: string;
name: string;
type: "workflow" | "model";
updateTime: number;
};
+1505
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
import { EWorkflowPrivacy } from "./dbTypes";
export type WorkspaceRoute =
| "root"
| "customNodes"
| "recentFlows"
| "gallery"
| "versionHistory"
| "saveAsModal"
| "modelList"
| "spotlightSearch"
| "downloadSpaceJson"
| "installModels"
| "share";
export type Session = {
username: string | null;
shareKey: string;
};
export type ShareWorkflowData = {
version: {
name: string;
json: string;
};
workflow: {
name: string;
cloudID?: string | null;
};
nodeDefs: Object;
privacy: EWorkflowPrivacy;
};
export interface Node {
name: string;
description: string;
image: string;
github_url: string;
from_index: number;
to_index: number;
}
export interface NodeInfo {
existing_nodes: Node[];
missing_nodes: Node[];
}
export interface Subgraph {
id: number;
name: string;
description: string;
json: Record<string, any>;
tags: string[];
}
export interface Workflow {
id?: number;
name?: string;
description?: string;
image?: string;
}
export interface ExtItem {
type: string;
data: any;
}
export interface ChatResponse {
session_id: string;
text?: string;
finished: boolean;
format: string;
ext?: ExtItem[];
}
export interface Message {
id: string;
content: string;
role: 'user' | 'ai' | 'tool';
name?: string;
type?: string;
format?: string;
finished?: boolean;
}
export interface ToolCall {
toolCallId: string
name: string
args: {}
result?: {} | []
}
export interface ComponentConfig {
id: string
name: string
type: string
props: any
children: ComponentConfig[]
}
export interface WorkflowOption {
name: string;
description: string;
thumbnail: string;
dir: string;
workflow: string;
}
export interface MessageContent {
ai_message: string;
options?: string[] | WorkflowOption[];
}
export interface OptimizedWorkflowRequest {
workflow_id: number;
prompt: string;
}
export interface OptimizedWorkflowResponse {
workflow: any; // 工作流数据
optimized_params: [number, string, number, string, string | number | boolean][]; // 优化后的参数
}
+12
View File
@@ -0,0 +1,12 @@
import { SHORTCUT_TRIGGER_EVENT } from "../const";
import { EOtherKeys, EShortcutKeys } from "./dbTypes";
interface ShortcutTriggerDetail {
shortcutType: EShortcutKeys | EOtherKeys;
}
declare global {
interface WindowEventMap {
[SHORTCUT_TRIGGER_EVENT]: CustomEvent<ShortcutTriggerDetail>;
}
}
+275
View File
@@ -0,0 +1,275 @@
import { ESortTypes } from "./RecentFilesDrawer/types";
import { userSettingsTable } from "./db-tables/WorkspaceDB";
import { Workflow, EShortcutKeys, SortableItem } from "./types/dbTypes";
import { app } from "./utils/comfyapp";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare let LiteGraph: any, LGraph: any, LGraphCanvas: any;
// copied from app.js
function sanitizeNodeName(string: string): string {
const entityMap: Record<string, string> = {
"&": "",
"<": "",
">": "",
'"': "",
"'": "",
"`": "",
"=": "",
};
return String(string).replace(/[&<>"'`=]/g, function fromEntityMap(s) {
return entityMap[s];
});
}
export function findMissingNodes(): string[] {
const missingNodeTypes = [];
for (const n of app.graph._nodes) {
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
if (n.type == "ConditioningAverage ") n.type = "ConditioningAverage"; //typo fix
if (n.type == "SDV_img2vid_Conditioning")
n.type = "SVD_img2vid_Conditioning"; //typo fix
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
n.type = sanitizeNodeName(n.type);
missingNodeTypes.push(n.type);
}
}
return missingNodeTypes;
}
export function toFileNameFriendly(str: string) {
// Keep only alphanumeric characters (including characters from other languages), underscores, and hyphens
// The \w character class in JavaScript's regex includes [A-Za-z0-9_] and Unicode characters (like Chinese, Japanese)
// str = str.replace(/[^\w-]/g, "");
str = str.replace(/[\\/:*?"<>|]/g, "_");
return str.trim();
}
export function formatTimestamp(
unixTimestamp: number,
showHourMinue: boolean = true,
showSec: boolean = false,
splitChar: string = "-",
) {
// Create a new Date object from the UNIX timestamp
const date = new Date(unixTimestamp);
// Get the day, month, year, hours, and minutes from the Date object
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0"); // Month is 0-indexed
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
// Format the date and time string
const res = `${month}${splitChar}${day}${splitChar}${year}`;
if (showHourMinue) {
return res + ` ${hours}:${minutes}`;
}
if (showSec) {
return res + `:${seconds}`;
}
return res;
}
/**
* Sort workspace list data
* @param flows Data that needs to be sorted
* @param sortType The type of sorting
* @returns sorted data
*/
export function sortFlows(
flows: Workflow[] = [],
sortType: ESortTypes = ESortTypes.RECENTLY_MODIFIED,
) {
const copyFlows = [...flows];
if (copyFlows.length) {
switch (sortType) {
case ESortTypes.AZ:
copyFlows.sort((a, b) => a.name.localeCompare(b.name));
break;
case ESortTypes.ZA:
copyFlows.sort((a, b) => b.name.localeCompare(a.name));
break;
case ESortTypes.RECENTLY_MODIFIED:
copyFlows.sort((a, b) => b.updateTime - a.updateTime);
break;
case ESortTypes.OLDEST_MODIFIED:
copyFlows.sort((a, b) => a.updateTime - b.updateTime);
break;
}
}
return copyFlows;
}
export const sortFileItem = <T extends SortableItem>(
items: T[],
sortType: ESortTypes = ESortTypes.RECENTLY_MODIFIED,
): T[] => {
const copyFlows = [...items];
switch (sortType) {
case ESortTypes.AZ:
copyFlows.sort((a, b) => a.name.localeCompare(b.name));
break;
case ESortTypes.ZA:
copyFlows.sort((a, b) => b.name.localeCompare(a.name));
break;
case ESortTypes.RECENTLY_MODIFIED:
copyFlows.sort((a, b) => b.updateTime - a.updateTime);
break;
case ESortTypes.OLDEST_MODIFIED:
copyFlows.sort((a, b) => a.updateTime - b.updateTime);
break;
case ESortTypes.RECENTLY_OPENED:
copyFlows.sort(
(a, b) => (b.lastOpenedTime ?? 0) - (a.lastOpenedTime ?? 0),
);
break;
}
return copyFlows;
};
export function json2ClipboardString(graphData: any) {
const nodes = graphData.nodes;
const links = new Array();
const relative_id_map = new Map();
const exist_link_ids = new Array();
for (let i = 0; i < graphData.nodes.length; ++i) {
const node = graphData.nodes[i];
if (node.inputs) {
for (const input of node.inputs) {
if (input.link) {
exist_link_ids.push(input.link);
input.link = null;
}
}
}
if (node.outputs) {
for (const output of node.outputs) {
if (output.links) {
exist_link_ids.concat(output.links);
output.links = [];
}
}
}
relative_id_map.set(node.id, i);
}
for (const link of graphData.links) {
if (!exist_link_ids.includes(link[0])) {
continue;
}
links.push([
relative_id_map.get(link[1]),
link[2],
relative_id_map.get(link[3]),
link[4],
link[1],
]);
}
return JSON.stringify({ nodes, links });
}
export function insertWorkflowToCanvas(json: string, insertPos?: number[]) {
let graphData = JSON.parse(json);
if (!graphData || !graphData.nodes || graphData.nodes.length == 0) return;
const prevClipboard = localStorage.getItem("litegrapheditor_clipboard");
localStorage.setItem(
"litegrapheditor_clipboard",
json2ClipboardString(graphData),
);
const priorPos = app.canvas.graph_mouse;
if (insertPos) {
insertPos[0] -= 15;
insertPos[1] -= 15;
app.canvas.graph_mouse = insertPos;
}
app.canvas.pasteFromClipboard();
app.canvas.graph_mouse = priorPos;
if (prevClipboard) {
localStorage.setItem("litegrapheditor_clipboard", prevClipboard);
}
}
export const matchShortcut = (event: KeyboardEvent) => {
/**
* Where matchShortcut is used, the browser's default behavior needs to be prevented.
* So here you cannot get the shortcut keys by await userSettingsTable?.getSetting("shortcuts")
* Because the async await function will cause the browser's default behavior to fail;
*/
const shortcuts = userSettingsTable?.settings?.shortcuts;
if (!shortcuts) return false;
for (const shortcutType in shortcuts) {
const shortcutString = shortcuts[shortcutType as EShortcutKeys];
const keys = shortcutString.split("+");
const pressedKeys: Record<string, boolean> = {
Control: event.ctrlKey,
Shift: event.shiftKey,
Alt: event.altKey,
Command: event.metaKey,
[event.key.toUpperCase()]: true,
};
for (const key in pressedKeys) {
if (!pressedKeys[key]) {
delete pressedKeys[key];
}
}
if (
keys.length === Object.keys(pressedKeys).length &&
keys.every((key) => pressedKeys[key])
) {
return shortcutType as EShortcutKeys;
}
}
};
export function isImageFormat(fileName: string) {
const imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "webp"];
const extension = fileName.split(".").pop();
return extension != null && imageExtensions.includes(extension);
}
export function isVideoFormat(fileName: string) {
const videoExtensions = ["mp4", "webm", "ogg"];
const extension = fileName.split(".").pop();
return extension != null && videoExtensions.includes(extension);
}
export function getFileUrl(relativePath: string) {
return `/workspace/view_media?filename=${relativePath}`;
}
export function getWorkflowIdInUrlHash() {
const hashArr = window.location.hash.slice(1).split("/");
const workspaceId = hashArr.find((h) => h.includes("workspaceId@"));
return workspaceId ? workspaceId.split("@")[1] : null;
}
/**
* Generate url hash containing workflowId;
* If workspaceId@ exists, replace it, if it does not exist, append it.
* This operation will not damage the original hash.
*/
export function generateUrlHashWithFlowId(id: string) {
const hashArr = window.location.hash.slice(1).split("/");
const workspaceIdIndex = hashArr.findIndex((h) => h.includes("workspaceId@"));
const newWorkflowId = `workspaceId@${id}`;
if (workspaceIdIndex >= 0) {
hashArr[workspaceIdIndex] = newWorkflowId;
} else {
hashArr.push(newWorkflowId);
}
return `${hashArr.join("/")}`;
}
export const openWorkflowInNewTab = (workflowID: string) => {
const newHash = generateUrlHashWithFlowId(workflowID);
window.open(`${window.location.origin}/#${newHash}`);
};
+39
View File
@@ -0,0 +1,39 @@
import type { Workflow } from "../types/dbTypes";
export const serverInfo: {
os: "Windows" | "Linux" | "Darwin" | "Java" | null;
} = {
os: null,
};
export const getPathSep = () => (serverInfo.os === "Windows" ? "\\" : "/");
export function joinRelPath(...segments: string[]) {
const rel = segments.filter((segment) => segment !== "").join("/");
return sanitizeRelPath(rel);
}
export function sanitizeRelPath(path: string) {
const segments = path
.split("/")
.filter((segment) => segment !== "")
.join("/");
// Replace backslashes with forward slashes to handle Windows paths
let sanitizedPath = segments.replace(/\\/g, "/");
// Remove leading slashes to ensure the path is treated as relative
sanitizedPath = sanitizedPath.replace(/^\/+/, "");
if (serverInfo.os === "Windows") {
sanitizedPath = segments.replace(/\//g, "\\");
}
return sanitizedPath;
}
export function getWorkflowRelPath(workflow: Workflow) {
return joinRelPath(workflow.parentFolderID ?? "", workflow.name + ".json");
}
export function getFolderRelPath(folder: { id: string }) {
return folder.id;
}
+77
View File
@@ -0,0 +1,77 @@
import { userSettingsTable } from "../db-tables/WorkspaceDB";
const CIVIT_API_KEY_STORAGE_KEY = "WORKSPACE_CIVIT_API_KEY_STORAGE_KEY";
export function getCivitApiKey() {
return localStorage.getItem(CIVIT_API_KEY_STORAGE_KEY);
}
export function setCivitApiKey(apiKey: string) {
localStorage.setItem(CIVIT_API_KEY_STORAGE_KEY, apiKey);
}
export function getCivitModelDownloadUrl(modelVersionID: string) {
return `https://civitai.com/api/download/models/${modelVersionID}`;
}
export function getCivitModelPageUrl(modelID: string, modelVersionID?: string) {
if (!modelVersionID) {
return `https://civitai.com/models/${modelID}`;
}
return `https://civitai.com/models/${modelID}?modelVersionId=${modelVersionID}`;
}
export function getHgModelInfoUrlFromDownloadUrl(downloadUrl: string) {
if (!downloadUrl.includes("huggingface")) return;
const infoPageUrl = downloadUrl.replace("/resolve/", "/blob/");
return infoPageUrl;
}
interface ResponsePartial {
id: number;
modelId: number;
name: string;
model: {
name: string;
type: string;
nsfw: boolean;
};
images: {
url: string;
nsfwLevel: number;
width: number;
height: number;
hash: string;
}[];
}
export async function fetchCivitModelFromHashKey(filehash: string): Promise<{
modelName?: string;
civitModelID?: string;
civitModelVersionID?: string;
imageUrl?: string;
}> {
try {
const url = `https://civitai.com/api/v1/model-versions/by-hash/${filehash}`;
const resp = await fetch(url);
const json: ResponsePartial = await resp.json();
let image_url: string | undefined;
const showNsfwThumbnail = await userSettingsTable?.getSetting(
"showNsfwModelThumbnail",
);
if (showNsfwThumbnail === true) {
image_url = json?.images?.[0]?.url;
} else if (!json.model.nsfw) {
const sfwImage = json.images.find((i) => i.nsfwLevel == 1);
image_url = sfwImage?.url;
}
return {
modelName: json.model.name,
civitModelID: String(json.modelId),
civitModelVersionID: String(json.id),
imageUrl: image_url ?? undefined,
};
} catch (e) {
return {};
}
}
+21
View File
@@ -0,0 +1,21 @@
// @ts-ignore
export let app: any | null = null;
const api_base = location.pathname.split("/").slice(0, -1).join("/");
export let api: any | null = null;
export async function waitForApp() {
try {
await Promise.all([
import(api_base + "/scripts/api.js").then((apiJs) => {
api = apiJs?.api;
}),
import(api_base + "/scripts/app.js").then((appJs) => {
app = appJs?.app;
}),
]);
} catch (e) {
console.error("waitForApp error", e);
}
}
+46
View File
@@ -0,0 +1,46 @@
export const deepJsonDiffCheck = (oldData: any, newData: any) => {
const oldDataNodes = oldData["nodes"] == null ? [] : oldData["nodes"];
const newDataNodes = newData["nodes"] == null ? [] : newData["nodes"];
oldData["nodes"] = [];
newData["nodes"] = [];
let equal = JSON.stringify(oldData) === JSON.stringify(newData);
if (equal) {
const array2Map = (map: any, obj: any) => {
const oldOrder = obj["order"];
const oldPos = obj["pos"];
delete obj["order"];
delete obj["pos"];
map.set(obj["id"], JSON.stringify(obj));
map.set(obj["id"] + "_pos", oldPos);
obj["order"] = oldOrder;
obj["pos"] = oldPos;
return map;
};
const withoutDeviation = (a: any, b: any, c: any) => {
return Math.abs(a - b) > c;
};
if (oldDataNodes.length == newDataNodes.length) {
const oldDataNodesMap = oldDataNodes.reduce(array2Map, new Map());
const newDataNodesMap = newDataNodes.reduce(array2Map, new Map());
for (let [key, value] of oldDataNodesMap) {
if (!newDataNodesMap.has(key)) {
equal = false;
break;
}
if (typeof key === 'string' && key.includes("_pos")) {
const newDataPos = newDataNodesMap.get(key);
if (newDataPos == null || value == null || withoutDeviation(newDataPos[0], value[0], 5) || withoutDeviation(newDataPos[1], value[1], 5)) {
equal = false;
break;
}
} else if (newDataNodesMap.get(key) !== value) {
equal = false;
break;
}
}
}
}
oldData["nodes"] = oldDataNodes;
newData["nodes"] = newDataNodes;
return equal;
};
+12
View File
@@ -0,0 +1,12 @@
export function downloadJsonFile(jsonStr: string, fileName: string) {
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${fileName}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+37
View File
@@ -0,0 +1,37 @@
import { Workflow } from "../types/dbTypes";
import { formatTimestamp } from "../utils";
import JSZip from "JSZip";
import { genFolderRelPath } from "../db-tables/DiskFileUtils";
import { sanitizeRelPath } from "../utils/OsPathUtils";
import { userSettingsTable } from "../db-tables/WorkspaceDB";
export const downloadWorkflowsZip = async (selectedList: Array<Workflow>) => {
const exportName = `ComfyUI workspace workflows ${formatTimestamp(Date.now())}`;
const zip = new JSZip();
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
for (const workflow of selectedList) {
let folderPath;
if (twoWaySyncEnabled) {
folderPath = workflow.parentFolderID ?? "";
} else {
folderPath = await genFolderRelPath(workflow.parentFolderID ?? null).then(
async (path) => {
return sanitizeRelPath(path ?? "");
},
);
}
// Ensure the folder path exists in the zip
const folder = zip.folder(folderPath);
// Adding the JSON file to the corresponding folder
folder?.file(`${workflow.name}.json`, workflow.json);
}
zip.generateAsync({ type: "blob" }).then(function (content) {
const a = document.createElement("a");
a.href = window.URL.createObjectURL(content);
a.download = `${exportName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
};
+18
View File
@@ -0,0 +1,18 @@
// utils/encryptUtils
const defaultSalt = import.meta.env.VITE_SHARE_KEY_ENCODE_KEY;
export function encodeKey(text: string, salt: string = defaultSalt): string {
const randomLength = Math.floor(Math.random() * 13) + 1; // Random length between 1 and 13
const randomPadding = Math.random()
.toString(36)
.substring(2, 2 + randomLength);
const input = randomPadding + salt + text;
return btoa(encodeURIComponent(input));
}
export function decodeKey(encoded: string, salt: string = defaultSalt): string {
const decoded = decodeURIComponent(atob(encoded));
const saltIndex = decoded.indexOf(salt);
if (saltIndex === -1) throw new Error("Invalid encoded string or salt");
return decoded.slice(saltIndex + salt.length);
}
+42
View File
@@ -0,0 +1,42 @@
import { SearchHit } from "../model-manager/civitSearchTypes";
import { isCivitModel } from "../model-manager/install-models/util/modelTypes";
import { CivitiModel } from "../model-manager/types";
interface ImageLike {
nsfw?: "None" | "Soft" | "Mature" | "X";
nsfwLevel?: number;
}
export function findSfwImage<T extends ImageLike>(
images?: T[],
fallback?: boolean,
): T | undefined {
if (!images || images.length === 0) {
return;
}
let sfw = images.find(
(image) => image.nsfw === "None" || image.nsfwLevel === 1,
);
if (fallback) {
sfw = sfw ?? images[0];
}
return sfw;
}
export function findSfwImageFromModel(
model: CivitiModel | SearchHit,
IMAGE_SIZE = 280,
fallback?: boolean,
): string | undefined {
if (!model) {
return;
}
if (model.images) {
const imageurl = `https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/${findSfwImage(model.images, fallback)?.url}/width=${IMAGE_SIZE}/`;
return imageurl;
} else if (isCivitModel(model)) {
return findSfwImage(model.modelVersions?.at(0)?.images, fallback)?.url;
}
return undefined;
}
+127
View File
@@ -0,0 +1,127 @@
// import { LGraph, LGraphGroup, LiteGraph, LGraphNode } from "../types/litegraph.d";
// interface ParsedWorkflow {
// nodes?: any[];
// groups?: any[];
// reroutes?: any[];
// links?: any[];
// }
// interface LoadResults {
// created: (LGraphNode | LGraphGroup)[];
// nodes: Map<number, LGraphNode>;
// links: Map<number, any>;
// reroutes: Map<number, any>;
// }
// export function loadSubGraphData(jsonObject: string | object, graph: LGraph, mousePos: [number, number]): LoadResults {
// // Parse JSON if string is provided
// const parsed: ParsedWorkflow = typeof jsonObject === 'string' ? JSON.parse(jsonObject) : jsonObject;
// // Initialize arrays if they don't exist
// parsed.nodes ??= [];
// parsed.groups ??= [];
// parsed.reroutes ??= [];
// parsed.links ??= [];
// // Find minimum offsets
// let offsetX = Infinity;
// let offsetY = Infinity;
// // Check nodes and reroutes positions
// for (const item of [...parsed.nodes, ...parsed.reroutes]) {
// if (item.pos[0] < offsetX) offsetX = item.pos[0];
// if (item.pos[1] < offsetY) offsetY = item.pos[1];
// }
// // Check group positions
// if (parsed.groups) {
// for (const group of parsed.groups) {
// if (group.bounding[0] < offsetX) offsetX = group.bounding[0];
// if (group.bounding[1] < offsetY) offsetY = group.bounding[1];
// }
// }
// // Initialize results
// const results: LoadResults = {
// created: [],
// nodes: new Map(),
// links: new Map(),
// reroutes: new Map()
// };
// const { created, nodes, links, reroutes } = results;
// // Add groups
// for (const info of parsed.groups) {
// info.id = undefined;
// const group = new LGraphGroup();
// group.configure(info);
// graph.add(group);
// created.push(group);
// }
// // Add nodes
// for (const info of parsed.nodes) {
// const node = LiteGraph.createNode(info.type);
// if (!node) continue;
// nodes.set(info.id, node);
// info.id = undefined;
// node.configure(info);
// graph.add(node);
// created.push(node);
// }
// // Add reroutes
// for (const info of parsed.reroutes) {
// const { id } = info;
// info.id = undefined;
// const reroute = graph.setReroute(info);
// created.push(reroute);
// reroutes.set(id, reroute);
// }
// // Update reroute parent IDs
// for (const reroute of reroutes.values()) {
// const mapped = reroutes.get(reroute.parentId);
// if (mapped) reroute.parentId = mapped.id;
// }
// // Add links
// for (const info of parsed.links) {
// const outNode = nodes.get(info.origin_id);
// const inNode = nodes.get(info.target_id);
// const afterRerouteId = reroutes.get(info.parentId)?.id;
// if (inNode) {
// const link = outNode?.connect(
// info.origin_slot,
// inNode,
// info.target_slot,
// afterRerouteId
// );
// if (link) links.set(info.id, link);
// }
// }
// // Validate and update reroutes
// for (const reroute of reroutes.values()) {
// const ids = [...reroute.linkIds].map(x => links.get(x)?.id ?? x);
// reroute.update(reroute.parentId, undefined, ids);
// if (!reroute.validateLinks(graph.links)) {
// graph.removeReroute(reroute.id);
// }
// }
// // Adjust positions relative to mouse position
// for (const item of created) {
// item.pos[0] += mousePos[0] - offsetX;
// item.pos[1] += mousePos[1] - offsetY;
// }
// // Update graph
// graph.afterChange();
// return results;
// }
+68
View File
@@ -0,0 +1,68 @@
type JsonValue =
| string
| number
| boolean
| { [key: string]: JsonValue }
| JsonValue[]
| undefined;
export function deepCompare(
json1: JsonValue,
json2: JsonValue,
path: string = "",
): boolean {
if (
typeof json1 === "object" &&
json1 !== null &&
typeof json2 === "object" &&
json2 !== null
) {
const keys1 = Object.keys(json1);
const keys2 = Object.keys(json2);
const allKeys = new Set([...keys1, ...keys2]);
for (const key of allKeys) {
const newPath = path ? `${path}.${key}` : key;
const value1 = (json1 as { [key: string]: JsonValue })[key];
const value2 = (json2 as { [key: string]: JsonValue })[key];
if (
(value1 === undefined && !keys2.includes(key)) ||
(value2 === undefined && !keys1.includes(key))
) {
continue; // Skip comparison if the value is undefined in one and missing in the other
}
if (!keys1.includes(key)) {
console.log(
`${newPath}: Missing in first JSON keys1`,
keys1,
"keys2",
keys2,
value1,
value2,
);
return false;
} else if (!keys2.includes(key)) {
console.log(
`${newPath}: Missing in 222 JSON keys2`,
keys1,
"keys2",
keys2,
value1,
value2,
);
return false;
} else {
const isEqual = deepCompare(value1, value2, newPath);
if (!isEqual) {
return false;
}
}
}
} else if (json1 !== json2 && json1 !== undefined && json2 !== undefined) {
console.log(`${path}: ${json1} != ${json2}`);
return false;
}
return true;
}
+263
View File
@@ -0,0 +1,263 @@
export function getPngMetadata(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
// Get the PNG data as a Uint8Array
// @ts-expect-error
const pngData = new Uint8Array(event.target.result);
const dataView = new DataView(pngData.buffer);
// Check that the PNG signature is present
if (dataView.getUint32(0) !== 0x89504e47) {
console.error("Not a valid PNG file");
reject();
}
// Start searching for chunks after the PNG signature
let offset = 8;
const txt_chunks: Record<string, string> = {};
// Loop through the chunks in the PNG file
while (offset < pngData.length) {
// Get the length of the chunk
const length = dataView.getUint32(offset);
// Get the chunk type
const type = String.fromCharCode(
...pngData.slice(offset + 4, offset + 8),
);
if (type === "tEXt" || type == "comf") {
// Get the keyword
let keyword_end = offset + 8;
while (pngData[keyword_end] !== 0) {
keyword_end++;
}
const keyword = String.fromCharCode(
...pngData.slice(offset + 8, keyword_end),
);
// Get the text
const contentArraySegment = pngData.slice(
keyword_end + 1,
offset + 8 + length,
);
const contentJson = Array.from(contentArraySegment)
.map((s) => String.fromCharCode(s))
.join("");
txt_chunks[keyword] = contentJson;
}
offset += 12 + length;
}
resolve(txt_chunks);
};
reader.readAsArrayBuffer(file);
});
}
function parseExifData(exifData: any) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949;
// Function to read 16-bit and 32-bit integers from binary data
function readInt(
offset: number,
isLittleEndian: boolean | undefined,
length: number,
) {
const arr = exifData.slice(offset, offset + length);
if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
0,
isLittleEndian,
);
} else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
0,
isLittleEndian,
);
}
}
// Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4);
function parseIFD(offset: number) {
const numEntries = readInt(offset, isLittleEndian, 2);
const result = {};
for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12;
const tag = readInt(entryOffset, isLittleEndian, 2);
const type = readInt(entryOffset + 2, isLittleEndian, 2);
const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
// Read the value(s) based on the data type
let value;
if (type === 2) {
// ASCII string
value = String.fromCharCode(
...exifData.slice(valueOffset, valueOffset + numValues - 1),
);
}
result[tag] = value;
}
return result;
}
// Parse the first IFD
const ifdData = parseIFD(ifdOffset);
return ifdData;
}
export function getWebpMetadata(file: File) {
return new Promise((r) => {
const reader = new FileReader();
reader.onload = (event) => {
const webp = new Uint8Array(event?.target?.result as Iterable<number>);
const dataView = new DataView(webp.buffer);
// Check that the WEBP signature is present
if (
dataView.getUint32(0) !== 0x52494646 ||
dataView.getUint32(8) !== 0x57454250
) {
console.error("Not a valid WEBP file");
r();
return;
}
// Start searching for chunks after the WEBP signature
let offset = 12;
const txt_chunks = {};
// Loop through the chunks in the WEBP file
while (offset < webp.length) {
const chunk_length = dataView.getUint32(offset + 4, true);
const chunk_type = String.fromCharCode(
...webp.slice(offset, offset + 4),
);
if (chunk_type === "EXIF") {
if (
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
"Exif\0\0"
) {
offset += 6;
}
const data = parseExifData(
webp.slice(offset + 8, offset + 8 + chunk_length),
);
for (const key in data) {
const value = data[key];
const index = value.indexOf(":");
txt_chunks[value.slice(0, index)] = value.slice(index + 1);
}
}
offset += 8 + chunk_length;
}
r(txt_chunks);
};
reader.readAsArrayBuffer(file);
});
}
export function isVideoName(name: string) {
if (name?.endsWith(".webm")) {
return true;
}
if (name?.endsWith(".mp4")) {
return true;
}
return false;
}
export function getVideoMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
reader.onload = (event) => {
const videoData = new Uint8Array(event.target.result);
const dataView = new DataView(videoData.buffer);
const decoder = new TextDecoder();
// Check for known valid magic strings
if (dataView.getUint32(0) == 0x1a45dfa3) {
//webm
//see http://wiki.webmproject.org/webm-metadata/global-metadata
//and https://www.matroska.org/technical/elements.html
//contrary to specs, tag seems consistently at start
//COMMENT + 0x4487 + packed length?
//length 0x8d8 becomes 0x48d8
//
//description for variable length ints https://github.com/ietf-wg-cellar/ebml-specification/blob/master/specification.markdown
let offset = 4 + 8; //COMMENT is 7 chars + 1 to realign
while (offset < videoData.length - 16) {
//Check for text tags
if (dataView.getUint16(offset) == 0x4487) {
//check that name of tag is COMMENT
const name = String.fromCharCode(
...videoData.slice(offset - 7, offset),
);
if (name === "COMMENT") {
const vint = dataView.getUint32(offset + 2);
const n_octets = Math.clz32(vint) + 1;
if (n_octets < 4) {
//250MB sanity cutoff
const length =
(vint >> (8 * (4 - n_octets))) & ~(1 << (7 * n_octets));
const content = decoder.decode(
videoData.slice(
offset + 2 + n_octets,
offset + 2 + n_octets + length,
),
);
const json = JSON.parse(content);
r(json);
return;
}
}
}
offset += 1;
}
} else if (
dataView.getUint32(4) == 0x66747970 &&
dataView.getUint32(8) == 0x69736f6d
) {
//mp4
//see https://developer.apple.com/documentation/quicktime-file-format
//Seems to make no guarantee for alignment
let offset = videoData.length - 4;
while (offset > 16) {
//rough safe guess
if (dataView.getUint32(offset) == 0x64617461) {
//any data tag
if (dataView.getUint32(offset - 8) == 0xa9636d74) {
//cmt data tag
const type = dataView.getUint32(offset + 4); //seemingly 1
const locale = dataView.getUint32(offset + 8); //seemingly 0
const size = dataView.getUint32(offset - 4) - 4 * 4;
const content = decoder.decode(
videoData.slice(offset + 12, offset + 12 + size),
);
const json = JSON.parse(content);
r(json);
return;
}
}
offset -= 1;
}
} else {
console.error("Unknown magic: " + dataView.getUint32(0));
r();
return;
}
};
reader.readAsArrayBuffer(file);
});
}
+14
View File
@@ -0,0 +1,14 @@
import type { EWorkflowPrivacy } from "../types/dbTypes";
export function getPrivacyEmoji(privacy: EWorkflowPrivacy) {
switch (privacy) {
case "PRIVATE":
return "🔒";
case "PUBLIC":
return "🌐";
case "UNLISTED":
return "🔗";
default:
return "";
}
}
+6
View File
@@ -0,0 +1,6 @@
import { encodeKey } from "./encryptUtils";
export function saveShareKey(sharekey: string) {
const key = encodeKey(sharekey);
localStorage.setItem("workspace_manager_shareKey", key);
}
+39
View File
@@ -0,0 +1,39 @@
/* Styles for the modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
}
/* Styles for the dialog */
.dialog {
padding: 20px;
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 80%;
background-color: #fff;
border-color: #000; /* Default, will be changed */
color: #000; /* Default, will be changed */
}
/* Specific styles for different alert levels */
.dialog.error {
border-color: #ff3860;
color: #ff3860;
}
.dialog.info {
border-color: #209cee;
color: #209cee;
}
.dialog.success {
border-color: #23d160;
color: #23d160;
}
+37
View File
@@ -0,0 +1,37 @@
import "./showAlert.css";
// Function to show a modal alert
export function showAlert({
message,
level,
}: {
message: string;
level: "error" | "info" | "success";
}): void {
// Create the modal container
const modal = document.createElement("div");
modal.classList.add("wokspace_modal");
// Create the dialog
const dialog = document.createElement("div");
dialog.classList.add("workspace_dialog");
dialog.classList.add(level); // Add class based on the alert level
// Create the message paragraph
const messageP = document.createElement("p");
messageP.textContent = message;
dialog.appendChild(messageP);
modal.appendChild(dialog);
document.body.appendChild(modal);
// Function to remove the modal when clicked outside
const removeModal = (event: MouseEvent) => {
if (event.target === modal) {
modal.removeEventListener("click", removeModal);
document.body.removeChild(modal);
}
};
// Attach the event listener to the modal
modal.addEventListener("click", removeModal);
}
+13
View File
@@ -0,0 +1,13 @@
export function generateUUID(): string {
// 检查是否支持 crypto.randomUUID()
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// 降级方案:手动实现 UUID v4
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+348
View File
@@ -0,0 +1,348 @@
import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from "react";
import { Message } from "../types/types";
import { WorkflowChatAPI } from "../apis/workflowChatApi";
import { app } from "../utils/comfyapp";
import { ChatHeader } from "../components/chat/ChatHeader";
import { ChatInput } from "../components/chat/ChatInput";
import { SelectedNodeInfo } from "../components/chat/SelectedNodeInfo";
import { MessageList } from "../components/chat/MessageList";
import { generateUUID } from "../utils/uuid";
import { getInstalledNodes, getObjectInfo } from "../apis/comfyApiCustom";
import { UploadedImage } from '../components/chat/ChatInput';
import React from "react";
interface WorkflowChatProps {
onClose?: () => void;
visible?: boolean;
}
export default function WorkflowChat({ onClose, visible = true }: WorkflowChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState<string>('');
const [latestInput, setLatestInput] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [sessionId, setSessionId] = useState<string>();
const messageDivRef = useRef<HTMLDivElement>(null);
const [selectedNodeInfo, setSelectedNodeInfo] = useState<any>(null);
const [installedNodes, setInstalledNodes] = useState<any[]>([]);
const [width, setWidth] = useState(window.innerWidth / 3);
const [isResizing, setIsResizing] = useState(false);
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
useEffect(() => {
if (messageDivRef.current) {
messageDivRef.current.scrollTop = messageDivRef.current.scrollHeight
}
}, [messages])
useEffect(() => {
const fetchInstalledNodes = async () => {
console.log('[WorkflowChat] Fetching installed nodes');
const nodes = await getInstalledNodes();
console.log('[WorkflowChat] Received installed nodes:', nodes);
setInstalledNodes(nodes);
};
fetchInstalledNodes();
}, []);
// 获取历史消息
const fetchMessages = async (sid: string) => {
try {
const data = await WorkflowChatAPI.fetchMessages(sid);
setMessages(data);
} catch (error) {
console.error('Error fetching messages:', error);
}
};
useEffect(() => {
let sid = localStorage.getItem("sessionId");
if (sid) {
setSessionId(sid);
fetchMessages(sid);
} else {
sid = generateUUID();
setSessionId(sid);
localStorage.setItem("sessionId", sid);
}
}, []);
useEffect(() => {
const handleNodeSelection = () => {
const selectedNodes = app.canvas.selected_nodes;
if (Object.keys(selectedNodes ?? {}).length) {
// Object.values(app.canvas.selected_nodes)[0].comfyClass
const nodeInfo = Object.values(selectedNodes)[0];
setSelectedNodeInfo(nodeInfo);
} else {
setSelectedNodeInfo(null);
}
};
// Add event listeners
document.addEventListener("click", handleNodeSelection);
// Cleanup
return () => {
document.removeEventListener("click", handleNodeSelection);
};
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
document.documentElement.style.setProperty('--mouse-x', `${e.clientX}px`);
document.documentElement.style.setProperty('--mouse-y', `${e.clientY}px`);
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
const handleMessageChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value);
}
const handleSendMessage = async () => {
if ((input.trim() === "" && !selectedNodeInfo) || !sessionId) return;
setLoading(true);
setLatestInput(input);
const userMessage: Message = {
id: generateUUID(),
role: "user",
content: input,
};
setMessages(prev => [...prev, userMessage]);
setInput("");
try {
for await (const response of WorkflowChatAPI.streamInvokeServer(
sessionId,
input,
uploadedImages.map(img => img.file)
)) {
const aiMessage: Message = {
id: generateUUID(),
role: "ai",
content: JSON.stringify(response),
type: response.type,
format: response.format,
finished: response.finished,
name: "Assistant"
};
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage.role === 'ai') {
return [...prev.slice(0, -1), aiMessage];
}
return [...prev, aiMessage];
});
if (response.finished) {
setLoading(false);
}
}
} catch (error) {
console.error('Error sending message:', error);
setLoading(false);
} finally {
setUploadedImages([]);
}
};
const handleKeyPress = (event: KeyboardEvent) => {
if (event.metaKey && event.key === "Enter") {
handleSendMessage();
}
}
const handleClearMessages = () => {
setMessages([]);
localStorage.removeItem("sessionId");
const newSessionId = generateUUID();
setSessionId(newSessionId);
localStorage.setItem("sessionId", newSessionId);
};
const avatar = (name?: string) => {
return `https://ui-avatars.com/api/?name=${name || 'User'}&background=random`;
}
const handleClose = () => {
onClose?.();
};
const handleOptionClick = (option: string) => {
setInput(option);
};
const handleSendMessageWithIntent = async (intent: string, ext?: any) => {
if (!sessionId || !selectedNodeInfo) return;
setLoading(true);
const userMessage: Message = {
id: generateUUID(),
role: "user",
content: selectedNodeInfo.comfyClass || selectedNodeInfo.type,
};
setMessages(prev => [...prev, userMessage]);
try {
for await (const response of WorkflowChatAPI.streamInvokeServer(sessionId, selectedNodeInfo.comfyClass || selectedNodeInfo.type, [], intent, ext)) {
const aiMessage: Message = {
id: generateUUID(),
role: "ai",
content: JSON.stringify(response),
format: response.format,
finished: response.finished,
name: "Assistant"
};
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage.role === 'ai') {
return [...prev.slice(0, -1), aiMessage];
}
return [...prev, aiMessage];
});
if (response.finished) {
setLoading(false);
}
}
} catch (error) {
console.error('Error sending message:', error);
setLoading(false);
}
};
const handleAddMessage = (message: Message) => {
console.log('[WorkflowChat] Adding new message:', message);
setMessages(prev => {
const newMessages = [...prev, message];
console.log('[WorkflowChat] Updated messages:', newMessages);
return newMessages;
});
};
const handleMouseDown = (e: React.MouseEvent) => {
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
const clampedWidth = Math.min(
Math.max(300, newWidth),
window.innerWidth * 0.8
);
setWidth(clampedWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
const handleUploadImages = (files: FileList) => {
const newImages = Array.from(files).map(file => ({
id: Math.random().toString(36).substr(2, 9),
file,
preview: URL.createObjectURL(file)
}));
setUploadedImages(prev => [...prev, ...newImages]);
};
const handleRemoveImage = (imageId: string) => {
setUploadedImages(prev => {
const newImages = prev.filter(img => img.id !== imageId);
return newImages;
});
};
React.useEffect(() => {
return () => {
uploadedImages.forEach(image => URL.revokeObjectURL(image.preview));
};
}, [uploadedImages]);
return (
<div
className="fixed top-0 right-0 h-full shadow-lg bg-white
transition-all duration-300 ease-in-out hover:shadow-xl text-gray-700"
style={{
display: visible ? 'block' : 'none',
width: `${width}px`
}}
>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-gray-300 active:bg-gray-400"
onMouseDown={handleMouseDown}
/>
<div className="flex h-full flex-col">
<ChatHeader
onClose={onClose}
onClear={handleClearMessages}
hasMessages={messages.length > 0}
/>
<div>
{installedNodes.map((node: any) => (
<div key={node.name}>{node.name}</div>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 scroll-smooth" ref={messageDivRef}>
<MessageList
messages={messages}
latestInput={latestInput}
onOptionClick={handleOptionClick}
installedNodes={installedNodes}
onAddMessage={handleAddMessage}
loading={loading}
/>
</div>
<div className="border-t px-4 py-3 border-gray-200 bg-white sticky bottom-0">
{selectedNodeInfo && (
<SelectedNodeInfo
nodeInfo={selectedNodeInfo}
onSendWithIntent={handleSendMessageWithIntent}
loading={loading}
/>
)}
<ChatInput
input={input}
loading={loading}
onChange={handleMessageChange}
onSend={handleSendMessage}
onKeyPress={handleKeyPress}
onUploadImages={handleUploadImages}
uploadedImages={uploadedImages}
onRemoveImage={handleRemoveImage}
/>
</div>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
animation: {
bounce: 'bounce 1s infinite',
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "public/comfyui", "public/entry.js"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+79
View File
@@ -0,0 +1,79 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { exec } from 'child_process';
const rewriteImportPlugin = ({ isDev }) => {
return {
name: "rewrite-import-plugin", // this name will show up in warnings and errors
resolveId(source) {
if (!isDev) {
return;
}
if (source === "/scripts/app.js") {
// Change the path to the new host
return "http://127.0.0.1:8188/scripts/app.js";
}
if (source === "/scripts/api.js") {
return "http://127.0.0.1:8188/scripts/api.js";
}
return null; // Other imports should not be affected
},
};
};
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
envDir: ".",
build: {
watch: mode === "development" ? {
include: ["src/**"],
buildStart() {
// 只在开发模式下启动 Tailwind CSS watch 进程
console.log("Starting Tailwind CSS watch process...");
const tailwindProcess = exec(
'npx tailwindcss -i ./src/input.css -o ./src/output.css --watch',
(error, stdout, stderr) => {
if (error) {
console.error(`Tailwind CSS watch error: ${error}`);
return;
}
if (stderr) {
console.error(`Tailwind CSS stderr: ${stderr}`);
return;
}
console.log(`Tailwind CSS stdout: ${stdout}`);
}
);
// Cleanup process on build end
process.on('exit', () => {
tailwindProcess.kill();
});
console.log("Tailwind CSS watch process started.");
}
} : undefined, // 在生产环境下不启用 watch
// minify: false, // ___DEBUG__MODE only
// sourcemap: true, // ___DEBUG___MODE only
emptyOutDir: true,
rollupOptions: {
// externalize deps that shouldn't be bundled into your library
external: ["/scripts/app.js", "/scripts/api.js"],
input: {
input: "/src/main.tsx",
},
output: {
// Provide global variables to use in the UMD build for externalized deps
globals: {
app: "app",
Litegraph: "LiteGraph",
},
dir: "../dist",
// assetFileNames: "[name]-[hash][extname]",
entryFileNames: "copilot_web/[name].js",
chunkFileNames: `copilot_web/[name]-[hash].js`,
assetFileNames: `copilot_web/assets/[name]-[hash].[ext]`,
},
},
},
plugins: [react(), rewriteImportPlugin({ isDev: mode === "development" })],
}));