first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
dist/
|
||||
entry/
|
||||
@@ -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']
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
test
|
||||
+25
@@ -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 |
Vendored
+6
@@ -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};
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+40
File diff suppressed because one or more lines are too long
+466
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
},
|
||||
})
|
||||
@@ -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);
|
||||
@@ -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 |
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 = ""
|
||||
@@ -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();
|
||||
@@ -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进行后续处理...
|
||||
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+10625
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Copyright (C) 2025 AIDC-AI */
|
||||
/* Licensed under the MIT License. */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Vendored
+94
@@ -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];
|
||||
@@ -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;
|
||||
};
|
||||
Vendored
+1509
File diff suppressed because it is too large
Load Diff
@@ -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][]; // 优化后的参数
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
// }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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" })],
|
||||
}));
|
||||
Reference in New Issue
Block a user