first commit

This commit is contained in:
Shirley727
2025-02-14 18:15:30 +08:00
commit ee910dbb23
88 changed files with 20124 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) 2025 AIDC-AI
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.
+110
View File
@@ -0,0 +1,110 @@
<div align="center">
# 🚀 ComfyUI-Copilot: Your Intelligent Assistant for Comfy-UI
<!-- Enhancing Image Generation Development with Smart Assistance -->
<h4 align="center">
<div align="center">
<img src="https://img.shields.io/badge/Version-1.0.0-blue.svg" alt="Version">
<img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License">
<img src="https://img.shields.io/github/stars/your-repo/ComfyUI-Copilot?color=yellow" alt="Stars">
<img src="https://img.shields.io/github/issues/your-repo/ComfyUI-Copilot?color=red" alt="Issues">
<img src="https://img.shields.io/badge/python-3.10%2B-purple.svg" alt="Python">
</h4>
👾 _**AIDC**_
[**Github**](https://github.com/AIDC-AI/ComfyUI-Copilot) 📑
</div>
<视频链接占位符>
## 🌟 Introduction
Welcome to **ComfyUI-Copilot**, an intelligent assistant built on the Comfy-UI framework that simplifies and enhances the AI algorithm debugging and deployment process through natural language interactions.
Whether it's generating text, images, or audio, ComfyUI-Copilot offers intuitive node recommendations, workflow building aids, and model querying services to streamline your development process.
<div align="center">
<img src="assets/Framework.png"/>
</div>
---
## 🤔 Why Choose ComfyUI-Copilot?
- **Ease of Use**: Lower the barriers to entry with natural language interaction, making Comfy-UI accessible even for beginners.
- **Smart Recommendations**: Leverage AI-driven node suggestions and workflow implementations to boost development efficiency.
- **Real-Time Assistance**: Benefit from round-the-clock interactive support to address any issues encountered during development.
---
## ⚙️ Core Features
- **Interactive Q&A Bot**: Access a robust Q&A platform where users can inquire about model intricacies, node details, and parameter utilization with ease.
- **Natural Language Node Suggestions**: Employ our advanced search mechanism to swiftly identify desired nodes and enhance workflow construction efficacy.
<img src="assets/comfycopilot_nodes_recommend.gif"/>
- **Node Query System**: Dive deeper into nodes by exploring their explanations, parameter definitions, usage tips, and downstream workflow recommendations.
<img src="assets/comfycopilot_nodes_search.gif"/>
- **Smart Workflow Assistance**: Automatically discern developer needs to recommend and build fitting workflow frameworks, minimizing manual setup time.
<img src="assets/工作流检索.png"/>
- **Model Querying**: Prompt Copilot to seek foundational models and 'lora' based on requirements.
- **Up-and-Coming Features**:
- **Automated Parameter Tuning**: Exploit machine learning algorithms for seamless analysis and optimization of critical workflow parameters.
- **Error Diagnosis and Fix Suggestions**: Receive comprehensive error insights and corrective advice to swiftly pinpoint and resolve issues.
---
## 🚀 Getting Started
**Repository Overview**: Visit the [GitHub Repository](https://code.alibaba-inc.com/aidc-innovation/ComfyUI-Copilot) to access the complete codebase.
1. **Installation**:
```bash
cd ComfyUI/custom_nodes
git clone git@gitlab.alibaba-inc.com:aidc-innovation/ComfyUI-Copilot.git
```
or
```bash
cd ComfyUI/custom_nodes
git clone http://gitlab.alibaba-inc.com/aidc-innovation/ComfyUI-Copilot.git
```
2. **Activation**: After running the ComfyUI project, find the Copilot activation button at the top-right corner of the board to launch its service.
3. **API Key Application**: For internal users, obtain the API key from the [access link](https://aidc-appsmith.alibaba-inc.com/app/comfyui-copilot-website/page1-677648e0b007d02d2b193eeb) and insert it on the Copilot settings page for uninterrupted ComfyUI-Copilot services.
---
## 🤝 Contributions
We welcome any form of contribution! Feel free to make issues, pull requests, or suggest new features.
---
## 📞 Contact Us
For any queries or suggestions, please feel free to contact: ComfyUI-Copilot@service.alibaba.com
---
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](https://opensource.org/licenses/MIT) file for details.
---
### [Image Placement Placeholder]
_(A visually engaging illustration or screenshot of ComfyUI-Copilot in action, highlighting its interface and showcasing user interaction.)_
---
This refactored README not only enhances the technical prowess of ComfyUI-Copilot but also makes it more appealing and accessible to users. It strategically spots where visual aids could further engage viewers and clarifies the setup and utilization of the tool.
+1
View File
@@ -0,0 +1 @@
test
+25
View File
@@ -0,0 +1,25 @@
# Copyright (C) 2025 AIDC-AI
# Licensed under the MIT License.
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}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+1
View File
@@ -0,0 +1 @@
mkdir -p static/workflows
+6
View File
@@ -0,0 +1,6 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["copilot_web/workflowChat-l1EPklk-.js","copilot_web/input.js","copilot_web/assets/input-CAIoqnDa.css"].map(path => {
const apiBase = window.comfyAPI?.api?.api?.api_base;
const prefix = apiBase ? `${apiBase.substring(1)}/` : '';
return `${prefix}${path}`;
}))))=>i.map(i=>d[i]);
import{r as t,j as s,R as w,_}from"./input.js";const D=9999999,f={EXPLAIN_NODE:"copilot:explain-node"},b=w.lazy(()=>_(()=>import("./workflowChat-l1EPklk-.js"),__vite__mapDeps([0,1,2])));function N(){const g=t.useRef(null),[E,r]=t.useState(!1),[i,c]=t.useState(),[o,u]=t.useState(!1),[a,m]=t.useState({x:0,y:0}),[h,l]=t.useState(!1);t.useEffect(()=>{const e=()=>{console.log("Explain node event received"),r(!0),l(!0)};return window.addEventListener(f.EXPLAIN_NODE,e),()=>window.removeEventListener(f.EXPLAIN_NODE,e)},[]),t.useEffect(()=>{c({x:window.innerWidth-160,y:20})},[]);const x=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=d=>{if(o){const p=Math.min(Math.max(0,d.clientX-a.x),window.innerWidth-100),v=Math.min(Math.max(0,d.clientY-a.y),window.innerHeight-40);c({x:p,y:v})}},n=()=>{u(!1)};return o&&(document.addEventListener("mousemove",e),document.addEventListener("mouseup",n)),()=>{document.removeEventListener("mousemove",e),document.removeEventListener("mouseup",n)}},[o,a]),i?s.jsx("div",{ref:g,children:s.jsxs("div",{className:"fixed",style:{zIndex:D,left:i.x,top:i.y,cursor:o?"grabbing":"grab"},children:[s.jsx("button",{onMouseDown:x,onClick:()=>{r(!0)},className:"px-4 py-2 border border-gray-300 rounded-md bg-white text-black hover:bg-green-600 transition-colors select-none w-32",children:"Copilot"}),s.jsx(b,{onClose:()=>{r(!1),l(!1)},visible:E,triggerUsage:h,onUsageTriggered:()=>l(!1)})]})}):null}export{N as default};
File diff suppressed because one or more lines are too long
+40
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// 定义事件常量
const COPILOT_EVENTS = {
EXPLAIN_NODE: 'copilot:explain-node'
};
function addExtraMenuOptions(nodeType, nodeData, app) {
const original_getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
original_getExtraMenuOptions?.apply(this, arguments);
options.push({
content: "Explain with Copilot",
callback: async () => {
const nodeTypeUniqueId = nodeType?.comfyClass;
// 触发自定义事件
window.dispatchEvent(new CustomEvent(COPILOT_EVENTS.EXPLAIN_NODE, {
detail: { nodeType: nodeTypeUniqueId }
}));
}
})
}
}
app.registerExtension({
name: "ComfyUI-Copilot-Bridge",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
addExtraMenuOptions(nodeType, nodeData, app);
},
})
+10
View File
@@ -0,0 +1,10 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
//@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 = ""
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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();
+243
View File
@@ -0,0 +1,243 @@
# Copyright (C) 2025 AIDC-AI
# Licensed under the MIT License.
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进行后续处理...
+195
View File
@@ -0,0 +1,195 @@
# Copyright (C) 2025 AIDC-AI
# Licensed under the MIT License.
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>
+10625
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
{
"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",
"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",
"@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.4.14"
},
"overrides": {
"cross-spawn": ">=7.0.5",
"rollup": ">=4.22.4",
"pagefinder": ">=1.1.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+51
View File
@@ -0,0 +1,51 @@
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(/const __vite__mapDeps=\(.*?m\.f=\[(.*?)\]/);
if (depsMatch && depsMatch[1]) {
const originalDeps = depsMatch[1];
// 如果文件中还没有路径转换逻辑,则添加
if (!content.includes('window.comfyAPI?.api?.api?.api_base')) {
content = content.replace(
/const __vite__mapDeps=.*?\)=>i\.map\(i=>d\[i\]\);/,
`const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[${originalDeps}].map(path => {
const apiBase = window.comfyAPI?.api?.api?.api_base;
const prefix = apiBase ? \`\${apiBase.substring(1)}/\` : '';
return \`\${prefix}\${path}\`;
}))))=>i.map(i=>d[i]);`
);
} else {
// 如果已经有路径转换逻辑,只替换依赖数组部分
content = content.replace(
/const __vite__mapDeps=\(.*?\)=>i\.map\(i=>d\[i\]\);/,
`const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[${originalDeps}])))=>i.map(i=>d[i]);`
);
}
fs.writeFileSync(file, content, 'utf-8');
console.log(`Modified ${path.basename(file)}`);
} else {
console.log(`No deps pattern found in ${path.basename(file)}`);
}
} else {
console.log(`No __vite__mapDeps found in ${path.basename(file)}`);
}
});
+45
View File
@@ -0,0 +1,45 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import React, { useRef, useState, useEffect } from "react";
import { DRAWER_Z_INDEX } from "./const";
import { COPILOT_EVENTS } from "./constants/events";
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 });
const [shouldTriggerUsage, setShouldTriggerUsage] = useState(false);
useEffect(() => {
const handleExplainNode = () => {
console.log("Explain node event received");
setShowChat(true);
setShouldTriggerUsage(true);
};
window.addEventListener(COPILOT_EVENTS.EXPLAIN_NODE, handleExplainNode);
return () => window.removeEventListener(COPILOT_EVENTS.EXPLAIN_NODE, handleExplainNode);
}, []);
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 w-32"
>
Copilot
</button>
<WorkflowChat
onClose={() => {
setShowChat(false);
setShouldTriggerUsage(false);
}}
visible={showChat}
triggerUsage={shouldTriggerUsage}
onUsageTriggered={() => setShouldTriggerUsage(false)}
/>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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);
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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();
if (!apiKey) {
yield {
text: 'API key is required. Please set your API key first.⚙\nIf you don\'t have an API key, please email us at ComfyUI-Copilot@service.alibaba.com and we will get back to you as soon as possible.',
finished: true
} as ChatResponse;
return;
}
// 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);
// Handle ext parameter
let finalExt = ext ? (Array.isArray(ext) ? ext : [ext]) : [];
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: finalExt,
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, please refresh the page and try again.');
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 + ', please refresh the page and try again.');
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' + ', please refresh the page and try again.');
throw error;
}
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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="mb-4 text-sm text-gray-600">
<span>Don't have an API key? </span>
<a
href="https://form.typeform.com/to/d1AaMgIL"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 underline"
>
Click here to request one
</a>
</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>
);
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import { useState } from 'react';
import { XIcon, TrashIcon, CogIcon } 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 text-gray-500"
>
<CogIcon className="h-5 w-5" />
</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>
);
}
+241
View File
@@ -0,0 +1,241 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
selectedModel: string;
onModelChange: (model: 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,
selectedModel,
onModelChange,
}: 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) {
if (e.nativeEvent.isComposing) {
return;
}
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"
/>
{/* Bottom toolbar */}
<div className="absolute bottom-3 left-3 flex items-center gap-2">
{/* Model selector dropdown */}
<select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
className="px-1.5 py-0.5 text-xs rounded-md
border border-gray-200 bg-white text-gray-700
focus:outline-none focus:ring-2 focus:ring-blue-500
focus:border-transparent hover:bg-gray-50
transition-colors border-0"
>
<option value="gpt-4o">gpt-4o</option>
<option value="gpt-4o-mini">gpt-4o-mini</option>
<option value="DeepSeek-V3">DeepSeek-V3</option>
</select>
{/* Upload image button */}
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="p-1.5 text-gray-500
hover:bg-gray-100 hover:text-gray-600
transition-all duration-200 outline-none">
<ImageIcon className="h-4 w-4" />
</button>
</div>
{/* Send 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>
);
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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>
)
}
export function CogIcon(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="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
+241
View File
@@ -0,0 +1,241 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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";
import { generateUUID } from "../../utils/uuid";
import { app } from "../../utils/comfyapp";
import { addNodeOnGraph } from "../../utils/graphUtils";
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] = addNodeOnGraph(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);
// Add success message
const successMessage = {
id: generateUUID(),
role: 'tool',
content: JSON.stringify({
text: 'The workflow has been successfully loaded to the canvas',
ext: []
}),
format: 'markdown',
name: 'Assistant'
};
onAddMessage?.(successMessage);
}
}}
/>
);
}
// 如果有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,76 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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>Selected 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}>
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}>
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 Nodes
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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 }) => (
<div className="w-1/2 mx-auto">
<img
{...props}
loading="lazy"
className="w-full h-auto block"
onError={(e) => {
console.warn('Image failed to load:', props.src, 'Error:', e);
e.currentTarget.style.opacity = '0';
}}
/>
</div>
),
}}
>
{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-[12px] w-[calc(50%-0.25rem)]"
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,18 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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,292 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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";
import { addNodeOnGraph } from "../../../utils/graphUtils";
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] = addNodeOnGraph(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,19 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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,84 @@
/*
* @Author: 晴知 qingli.hql@alibaba-inc.com
* @Date: 2024-12-26 17:16:51
* @LastEditors: 晴知 qingli.hql@alibaba-inc.com
* @LastEditTime: 2025-02-13 21:02:17
* @FilePath: /comfyui_copilot/ui/src/components/chat/messages/NodeInstallGuide.tsx
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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="grid grid-cols-1 sm:grid-cols-2 gap-3">
{nodeInfos.map((node: any, index: number) => (
<div
key={index}
className="w-full p-3 bg-white rounded-lg border border-gray-200
hover:shadow-sm transition-all duration-200"
>
<div className="flex flex-col">
<h3 className="text-[12px] font-medium text-gray-800 mb-4">
{node.name}
</h3>
<div className="flex justify-end">
{node.repository_url ? (
<a
href={node.repository_url}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1 bg-white text-gray-900 rounded-md
border border-gray-900 hover:bg-gray-100
transition-colors text-[10px] flex items-center gap-1"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</a>
) : (
<a
href={`https://www.google.com/search?q=${encodeURIComponent(node.name + ' comfyui custom node')}`}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1 bg-gray-100 text-gray-500 rounded-md
border border-gray-300 text-[10px] flex items-center gap-1"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 21h7a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v11m0 5l4.879-4.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242z" />
</svg>
Search on Google
</a>
)}
</div>
</div>
</div>
))}
</div>
<p className="mt-4"></p>
<p>安装完成后,请点击继续加载graph按钮,将graph加载到画布中:</p>
<div className="mt-4">
<button
className="px-3 py-2 bg-blue-500 text-white rounded-md
hover:bg-blue-600 transition-colors text-xs block mx-auto"
onClick={onLoadSubgraph}
>
继续加载graph
</button>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import { app } from "../../../utils/comfyapp";
import { useState } from 'react';
import { ChatResponse, Node } from "../../../types/types";
import { addNodeOnGraph } from "../../../utils/graphUtils";
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 = addNodeOnGraph(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,156 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import { app } from "../../../utils/comfyapp";
import { BaseMessage } from './BaseMessage';
import { useState } from 'react';
import { ChatResponse, Node } from "../../../types/types";
import { addNodeOnGraph } from "../../../utils/graphUtils";
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));
// 添加一个格式化数字的辅助函数
const formatNumber = (num: number) => {
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}w`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}k`;
}
return num.toString();
};
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 font-medium">Installed nodes:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{installedNodesList.map((node: Node) => (
<div
key={node.name}
className="w-full p-3 bg-white rounded-lg border border-gray-200
hover:shadow-sm transition-all duration-200 relative group"
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
<h3 className="text-sm font-medium text-gray-800 mb-4">
{node.name}
</h3>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1 text-gray-600">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .25a.75.75 0 0 1 .673.418l3.058 6.197 6.839.994a.75.75 0 0 1 .415 1.279l-4.948 4.823 1.168 6.811a.75.75 0 0 1-1.088.791L12 18.347l-6.117 3.216a.75.75 0 0 1-1.088-.79l1.168-6.812-4.948-4.823a.75.75 0 0 1 .416-1.28l6.838-.993L11.327.668A.75.75 0 0 1 12 .25z"/>
</svg>
<span>{formatNumber(node.github_stars || 0)}</span>
</div>
<button
className="px-2 py-1 bg-blue-500 text-white rounded-md
hover:bg-blue-600 transition-colors text-[10px] flex items-center gap-1"
onClick={() => {
const addNode = addNodeOnGraph(node.name);
if (addNode) {
addNode.connect(node.from_index, addNode, node.to_index);
}
}}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4"/>
</svg>
Add to Canvas
</button>
</div>
{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 font-medium">Uninstalled nodes:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{uninstalledNodesList.map((node: Node) => (
<div
key={node.name}
className="w-full p-3 bg-white rounded-lg border border-gray-200
hover:shadow-sm transition-all duration-200 relative group"
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
>
<h3 className="text-[12px] font-medium text-gray-800 mb-4">
{node.name}
</h3>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1 text-gray-600">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .25a.75.75 0 0 1 .673.418l3.058 6.197 6.839.994a.75.75 0 0 1 .415 1.279l-4.948 4.823 1.168 6.811a.75.75 0 0 1-1.088.791L12 18.347l-6.117 3.216a.75.75 0 0 1-1.088-.79l1.168-6.812-4.948-4.823a.75.75 0 0 1 .416-1.28l6.838-.993L11.327.668A.75.75 0 0 1 12 .25z"/>
</svg>
<span>{formatNumber(node.github_stars || 0)}</span>
</div>
<a
href={node.github_url}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1 bg-white text-gray-900 rounded-md
border border-gray-900 hover:bg-gray-100
transition-colors text-[10px] flex items-center gap-1"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</a>
</div>
{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,18 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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,179 @@
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);
}
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);
// Add success message
const successMessage = {
id: generateUUID(),
role: 'tool',
content: JSON.stringify({
text: 'The workflow has been successfully loaded to the canvas',
ext: []
}),
format: 'markdown',
name: 'Assistant'
};
onAddMessage?.(successMessage);
};
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 h-[4.5rem] justify-between">
<div>
<h3 className="font-medium text-sm line-clamp-2 h-10 overflow-hidden">{workflow.name}</h3>
</div>
<div className="flex justify-between items-center mt-1">
{workflow.description && (
<div className="relative group">
<div className="w-5 h-5 flex items-center justify-center text-gray-500 cursor-help">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
</div>
<div className="absolute bottom-full left-0 mb-2 hidden group-hover:block">
<div className="bg-gray-900 text-white text-xs rounded-md py-1 px-2 max-w-xs">
{workflow.description}
</div>
</div>
</div>
)}
<button
onClick={() => handleAcceptWorkflow(workflow)}
className="px-3 py-1.5 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-xs"
>
Accept
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
// </BaseMessage>
);
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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
)
+12
View File
@@ -0,0 +1,12 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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)
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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";
+6
View File
@@ -0,0 +1,6 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
export const COPILOT_EVENTS = {
EXPLAIN_NODE: 'copilot:explain-node'
} as const;
+54
View File
@@ -0,0 +1,54 @@
/* Copyright (C) 2025 AIDC-AI */
/* Licensed under the MIT License. */
: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;
+6
View File
@@ -0,0 +1,6 @@
/* Copyright (C) 2025 AIDC-AI */
/* Licensed under the MIT License. */
@tailwind base;
@tailwind components;
@tailwind utilities;
+37
View File
@@ -0,0 +1,37 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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>,
);
});
+2270
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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];
+167
View File
@@ -0,0 +1,167 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
};
+1509
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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][]; // 优化后的参数
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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 {};
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
// @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);
}
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
};
+15
View File
@@ -0,0 +1,15 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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);
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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);
});
};
+21
View File
@@ -0,0 +1,21 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
// 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);
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import LiteGraph from "../types/litegraph.d";
import { app } from "./comfyapp";
export function addNodeOnGraph(type: string, options: any = {}) {
const node = LiteGraph.createNode(type, "", options);
app.graph.add(node);
return node;
}
// 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;
// }
+71
View File
@@ -0,0 +1,71 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
}
+266
View File
@@ -0,0 +1,266 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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);
});
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import type { EWorkflowPrivacy } from "../types/dbTypes";
export function getPrivacyEmoji(privacy: EWorkflowPrivacy) {
switch (privacy) {
case "PRIVATE":
return "🔒";
case "PUBLIC":
return "🌐";
case "UNLISTED":
return "🔗";
default:
return "";
}
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
import { encodeKey } from "./encryptUtils";
export function saveShareKey(sharekey: string) {
const key = encodeKey(sharekey);
localStorage.setItem("workspace_manager_shareKey", key);
}
+42
View File
@@ -0,0 +1,42 @@
/* Copyright (C) 2025 AIDC-AI */
/* Licensed under the MIT License. */
/* 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;
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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);
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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" />
+368
View File
@@ -0,0 +1,368 @@
// Copyright (C) 2025 AIDC-AI
// Licensed under the MIT License.
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;
triggerUsage?: boolean;
onUsageTriggered?: () => void;
}
export default function WorkflowChat({ onClose, visible = true, triggerUsage = false, onUsageTriggered }: 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[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("gpt-4o");
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 {
// Create ext array with model selection
const modelExt = { type: "model_select", data: [selectedModel] };
for await (const response of WorkflowChatAPI.streamInvokeServer(
sessionId,
input,
uploadedImages.map(img => img.file),
null, // intent
modelExt // ext
)) {
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]);
useEffect(() => {
if (triggerUsage && onUsageTriggered) {
handleSendMessageWithIntent('node_explain');
onUsageTriggered();
}
}, [triggerUsage]);
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}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
import typography from '@tailwindcss/typography'
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
animation: {
bounce: 'bounce 1s infinite',
},
},
},
plugins: [
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" })],
}));