commit 4435a00dec10bb770ad75e57025846385b551743 Author: kyj@bowong.ai Date: Wed Jul 30 17:38:50 2025 +0800 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..059b2e1 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# ComfyUI 工作流管理 +![这是图片](https://cdn.roasmax.cn/static/publisher.png) + +## 功能 +- 工作流上传 +- 工作流版本控制 +- 工作流加载 +- 需配置工作流服务器(详见workflow_server_demo.py文件) \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dfb19df --- /dev/null +++ b/__init__.py @@ -0,0 +1,234 @@ +import os +import json +import re +import urllib + +import aiohttp +from aiohttp import web +from server import PromptServer + +# 获取当前插件的目录 +NODE_DIR = os.path.dirname(os.path.abspath(__file__)) +CONFIG_FILE = os.path.join(NODE_DIR, "config.json") + +# 默认配置 +DEFAULT_CONFIG = { + "api_url": "" +} + + +def load_config(): + """加载配置文件,如果文件不存在则创建""" + if not os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'w') as f: + json.dump(DEFAULT_CONFIG, f) + return DEFAULT_CONFIG + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + + +def save_config(config_data): + """保存配置到文件""" + with open(CONFIG_FILE, 'w') as f: + json.dump(config_data, f, indent=4) + + +# 创建一个虚拟节点类,虽然它不会出现在图表中,但这是ComfyUI加载自定义节点的标准方式 +class WorkflowPublisherNode: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + # 这个节点实际上是UI扩展,不处理任何输入输出,但需要定义这些方法 + return { + "required": {}, + } + + RETURN_TYPES = () + FUNCTION = "do_nothing" + OUTPUT_NODE = True + CATEGORY = "utilities" + + def do_nothing(self): + return () + + +# ----------------- +# API 端点定义 +# ----------------- + +# 添加自定义API路由 +@PromptServer.instance.routes.get("/publisher/settings") +async def get_publisher_settings(request): + """获取发布器设置""" + config = load_config() + return web.json_response(config) + + +@PromptServer.instance.routes.post("/publisher/settings") +async def save_publisher_settings(request): + """保存发布器设置""" + try: + data = await request.json() + api_url = data.get("api_url", "") + config = load_config() + config["api_url"] = api_url + save_config(config) + return web.json_response({"status": "success", "message": "Settings saved"}) + except Exception as e: + return web.json_response({"status": "error", "message": str(e)}, status=500) + + +@PromptServer.instance.routes.post("/publisher/publish") +async def publish_workflow_handler(request): + """处理工作流发布请求""" + try: + data = await request.json() + workflow = data.get("workflow") + workflow_name = data.get("name") + + if not workflow or not workflow_name: + return web.json_response({"status": "error", "message": "Missing workflow data or name"}, status=400) + + config = load_config() + target_api_url = config.get("api_url") + + if not target_api_url: + return web.json_response({"status": "error", "message": "API URL not configured"}, status=400) + + # 准备要发送到目标API的数据 + payload = { + "name": workflow_name, + "workflow": workflow + } + + headers = {'Content-Type': 'application/json'} + + # 使用 aiohttp 发送异步请求 + async with aiohttp.ClientSession() as session: + async with session.post(target_api_url, json=payload, headers=headers) as response: + response_text = await response.text() + if response.status == 200: + return web.json_response( + {"status": "success", "message": "Workflow published successfully!", "details": response_text}) + else: + return web.json_response({ + "status": "error", + "message": f"Failed to publish workflow. Target API returned status {response.status}", + "details": response_text + }, status=500) + + except Exception as e: + import traceback + traceback.print_exc() + return web.json_response({"status": "error", "message": str(e)}, status=500) + + +@PromptServer.instance.routes.get("/publisher/workflows") +async def get_workflows_from_server(request): + """从目标服务器获取工作流列表,并按基础名称进行分组""" + try: + config = load_config() + target_api_url = config.get("api_url") + + if not target_api_url: + return web.json_response({"status": "error", "message": "API URL not configured"}, status=400) + + async with aiohttp.ClientSession() as session: + async with session.get(target_api_url) as response: + if response.status != 200: + response_text = await response.text() + return web.json_response({ + "status": "error", + "message": f"Failed to fetch workflows. Target API returned status {response.status}", + "details": response_text + }, status=500) + + workflows = await response.json() + + # --- [核心逻辑] 对工作流进行分组和版本化 --- + grouped_workflows = {} + # 正则表达式匹配 '任意字符 [YYYYMMDDHHMMSS]' 格式 + version_pattern = re.compile(r"^(.*) \[(20\d{12})\]$") + + for wf in workflows: + match = version_pattern.match(wf.get("name", "")) + if match: + base_name = match.group(1).strip() + version = match.group(2) + else: + # 对于没有版本号的旧工作流,将整个名称作为基础名称 + base_name = wf.get("name", "Unnamed Workflow") + version = "N/A" # 无版本信息 + + if base_name not in grouped_workflows: + grouped_workflows[base_name] = [] + + grouped_workflows[base_name].append({ + "version": version, + "workflow": wf.get("workflow") + }) + + # 对每个工作流的版本进行降序排序(最新版本在前) + for base_name in grouped_workflows: + grouped_workflows[base_name].sort(key=lambda x: x['version'], reverse=True) + + return web.json_response(grouped_workflows) + + except Exception as e: + import traceback + traceback.print_exc() + return web.json_response({"status": "error", "message": str(e)}, status=500) + + +@PromptServer.instance.routes.post("/publisher/workflow/delete") +async def delete_workflow_version(request): + """接收前端的删除请求,并将其转发到目标API服务器""" + try: + data = await request.json() + workflow_name = data.get("name") + if not workflow_name: + return web.json_response({"status": "error", "message": "Missing workflow name"}, status=400) + + config = load_config() + target_api_url = config.get("api_url") + + if not target_api_url: + return web.json_response({"status": "error", "message": "API URL not configured"}, status=400) + + # 构建目标URL,需要对工作流名称进行URL编码 + delete_url = f"{target_api_url}/{urllib.parse.quote(workflow_name)}" + + async with aiohttp.ClientSession() as session: + async with session.delete(delete_url) as response: + if response.status == 200: + return web.json_response({"status": "success", "message": "Workflow version deleted"}) + else: + details = await response.text() + return web.json_response({ + "status": "error", + "message": f"Target API failed to delete. Status: {response.status}", + "details": details + }, status=response.status) + except Exception as e: + return web.json_response({"status": "error", "message": str(e)}, status=500) + +# ----------------- +# ComfyUI 注册 +# ----------------- + +# 告诉 ComfyUI 我们有一个包含JS文件的web目录 +WEB_DIRECTORY = "js" + +# 节点映射 +NODE_CLASS_MAPPINGS = { + # "WorkflowPublisher": WorkflowPublisherNode +} + +# 节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = { + # "WorkflowPublisher": "Workflow Publisher (UI)" +} + +print("✅ Loaded Workflow Publisher Node") diff --git a/js/publisher.js b/js/publisher.js new file mode 100644 index 0000000..5b5a761 --- /dev/null +++ b/js/publisher.js @@ -0,0 +1,123 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +// Modal class (保持不变) +class Modal { + constructor(id, title, full_width = false) { + this.id = id; this.element = document.createElement("div"); this.element.id = this.id; + this.element.style.cssText = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--comfy-menu-bg); color: var(--fg-color); padding: 20px; border-radius: 8px; box-shadow: 0 0 20px rgba(0,0,0,0.5); z-index: 1001; display: none; flex-direction: column; gap: 15px; min-width: ${full_width ? '500px' : '350px'}; max-width: 600px; max-height: 80vh;`; + this.element.innerHTML = `

${title}

`; + document.body.appendChild(this.element); + } + show() { this.element.style.display = 'flex'; } + hide() { this.element.style.display = 'none'; } + setContent(html) { this.element.querySelector('.content').innerHTML = html; } + addButtons(buttons) { const container = this.element.querySelector('.buttons'); container.innerHTML = ''; buttons.forEach(btn => { const button = document.createElement('button'); button.textContent = btn.label; button.onclick = btn.callback; container.appendChild(button); }); } +} + + +app.registerExtension({ + name: "Comfy.WorkflowPublisher", + async setup() { + const addCustomStyles = () => { + const styleId = 'jags-publisher-styles'; + if (document.getElementById(styleId)) return; + const style = document.createElement('style'); style.id = styleId; + style.innerHTML = ` + .jags-button { background-color: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--fg-color); border-radius: 8px; padding: 5px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease-in-out; outline: none; position: relative; } + .jags-button:hover { background-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.2); transform: translateY(-1px); box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.2); } + .jags-button:active { background-color: rgba(255, 255, 255, 0.15); transform: translateY(0px) scale(0.98); box-shadow: none; } + .jags-accordion-header { background-color: var(--comfy-input-bg); width: 100%; border: 1px solid var(--border-color); padding: 10px; text-align: left; cursor: pointer; transition: background-color 0.2s; display: flex; justify-content: space-between; align-items: center; border-radius: 4px; } + .jags-accordion-header:hover { background-color: var(--comfy-menu-bg); } + .jags-accordion-panel { padding-left: 15px; margin-top: 5px; border-left: 2px solid var(--border-color); max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out, margin-top 0.3s ease-out; } + .jags-version-item { display: flex; justify-content: space-between; align-items: center; padding: 2px 5px; border-radius: 3px; cursor: pointer; } + .jags-version-item:hover { background-color: var(--comfy-input-bg); } + .jags-version-item-label { flex-grow: 1; padding: 6px 0; } + .jags-delete-btn { background: transparent; border: none; color: var(--error-color); cursor: pointer; font-size: 1.2em; padding: 4px 8px; border-radius: 50%; line-height: 1; } + .jags-delete-btn:hover { background-color: var(--error-color); color: var(--comfy-menu-bg); } + `; + document.head.appendChild(style); + }; + addCustomStyles(); + + const waitForElement = (selector, callback, timeout = 10000) => { + const startTime = Date.now(); const interval = setInterval(() => { const element = document.getElementsByClassName(selector)[0]; if (element) { clearInterval(interval); callback(element); } else if (Date.now() - startTime > timeout) { clearInterval(interval); console.error(`Workflow Publisher: Timed out waiting for element: "${selector}"`); } }, 100); + }; + + waitForElement("queue-button-group flex", (menu) => { + const container = document.createElement("div"); container.id = "jags-publisher-container"; container.style.display = "flex"; container.style.alignItems = "center"; container.style.gap = "8px"; + const createButton = (innerHTML, title, onClick) => { const btn = document.createElement("button"); btn.className = "jags-button"; btn.innerHTML = innerHTML; btn.title = title; btn.onclick = onClick; return btn; }; + const iconGet = ``; + const iconPublish = ``; + const btnGet = createButton(iconGet, "从服务器获取工作流", () => showWorkflowListDialog()); + const btnPublish = createButton(iconPublish, "发布当前工作流", () => showPublishDialog()); + const btnSettings = createButton(`⚙️`, "发布设置", () => showSettingsDialog()); + const separator = document.createElement("div"); separator.style.cssText = "border-left: 1px solid var(--border-color); height: 22px; margin: 0 4px;"; + container.append(btnGet, btnPublish, btnSettings, separator); + menu.insertBefore(container, menu.firstChild); + }); + + const settingsDialog = new Modal('workflow-publisher-settings-dialog', '发布设置'); + const publishDialog = new Modal('workflow-publisher-publish-dialog', '发布工作流'); + const workflowListDialog = new Modal('workflow-publisher-list-dialog', '工作流库', true); + async function deleteWorkflowVersion(baseName, version, fullVersionedName, itemElement) { if (!confirm(`确定要永久删除工作流 "${baseName}" 的版本 [${version}] 吗?\n此操作无法撤销!`)) { return; } try { const response = await api.fetchApi("/publisher/workflow/delete", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: fullVersionedName }) }); if (!response.ok) { const err = await response.json(); throw new Error(err.message || "服务器删除失败"); } const panel = itemElement.parentElement; itemElement.remove(); const remainingItems = panel.querySelectorAll('.jags-version-item'); const header = panel.previousElementSibling; if (header.matches('.jags-accordion-header')) { const countSpan = header.querySelector('span:last-child'); if(remainingItems.length > 0) { countSpan.textContent = `${remainingItems.length} 个版本`; } else { header.remove(); panel.remove(); } } } catch (error) { alert(`删除失败: ${error.message}`); console.error("Delete failed:", error); } } + + async function showWorkflowListDialog() { + workflowListDialog.setContent('

正在从服务器加载工作流...

'); + workflowListDialog.addButtons([{ label: '关闭', callback: () => workflowListDialog.hide() }]); + workflowListDialog.show(); + try { + const response = await api.fetchApi("/publisher/workflows"); + if (!response.ok) throw new Error(`服务器错误: ${response.statusText}`); + const workflows = await response.json(); + const workflowKeys = Object.keys(workflows); + if (workflowKeys.length === 0) { workflowListDialog.setContent('

服务器上没有找到任何工作流。

'); return; } + const accordionContainer = document.createElement('div'); accordionContainer.style.cssText = 'display: flex; flex-direction: column; gap: 8px;'; + workflowKeys.sort().forEach(baseName => { + const versions = workflows[baseName]; + const header = document.createElement('button'); header.className = 'jags-accordion-header'; + header.innerHTML = `${baseName}${versions.length} 个版本`; + const panel = document.createElement('div'); panel.className = 'jags-accordion-panel'; + versions.forEach(versionData => { + const fullVersionedName = versionData.version === "N/A" ? baseName : `${baseName} [${versionData.version}]`; + const item = document.createElement('div'); item.className = 'jags-version-item'; + const label = document.createElement('span'); label.className = 'jags-version-item-label'; + label.textContent = `版本: ${versionData.version}`; + + // --- [核心升级] 使用 app.handleFile 加载工作流 --- + label.onclick = () => { + if (confirm(`确定要加载工作流 "${baseName}" 的版本 [${versionData.version}] 吗?\n这将覆盖当前画布。`)) { + // 1. 将工作流JSON对象转换为字符串 + const jsonString = JSON.stringify(versionData.workflow, null, 2); // 格式化JSON + + // 2. 创建一个虚拟的File对象 + const file = new File([jsonString], fullVersionedName, { + type: "application/json", + }); + + // 3. 调用ComfyUI的官方文件处理函数 + app.handleFile(file); + + // 4. 关闭对话框 + workflowListDialog.hide(); + } + }; + + const deleteBtn = document.createElement('button'); deleteBtn.className = 'jags-delete-btn'; deleteBtn.innerHTML = '✖'; deleteBtn.title = `删除此版本`; + deleteBtn.onclick = (e) => { e.stopPropagation(); deleteWorkflowVersion(baseName, versionData.version, fullVersionedName, item); }; + item.appendChild(label); item.appendChild(deleteBtn); panel.appendChild(item); + }); + header.onclick = () => { header.classList.toggle('active'); if (panel.style.maxHeight) { panel.style.maxHeight = null; panel.style.marginTop = '0px'; } else { panel.style.marginTop = '5px'; panel.style.maxHeight = panel.scrollHeight + "px"; }}; + accordionContainer.appendChild(header); accordionContainer.appendChild(panel); + }); + workflowListDialog.setContent(''); workflowListDialog.element.querySelector('.content').appendChild(accordionContainer); + } catch (error) { workflowListDialog.setContent(`

加载失败: ${error.message}

`); } + } + + // ... (其余函数保持不变) ... + async function publishWorkflow() { const baseName = document.getElementById('publisher-workflow-name').value.trim(); if (!baseName) { alert("请输入工作流的基础名称。"); return; } const statusEl = document.getElementById('publisher-status'); statusEl.textContent = "正在准备发布..."; statusEl.style.color = "orange"; const d = new Date(); const versionString = `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}${d.getHours().toString().padStart(2,'0')}${d.getMinutes().toString().padStart(2,'0')}${d.getSeconds().toString().padStart(2,'0')}`; const finalName = `${baseName} [${versionString}]`; try { const workflow = await app.graph.serialize(); const body = { name: finalName, workflow: workflow }; statusEl.textContent = `正在发布为 "${finalName}"...`; const response = await api.fetchApi("/publisher/publish", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) throw new Error(`服务器返回错误: ${response.status}`); const result = await response.json(); statusEl.textContent = `发布成功! ${result.message || ''}`; statusEl.style.color = 'var(--success-color)'; setTimeout(() => publishDialog.hide(), 2000); } catch (error) { statusEl.textContent = `发布失败: ${error.message}`; statusEl.style.color = 'var(--error-color)'; console.error("发布失败:", error); } } + async function showSettingsDialog() { try { const resp = await api.fetchApi("/publisher/settings", { cache: "no-store" }); const settings = await resp.json(); settingsDialog.setContent(``); settingsDialog.addButtons([{ label: '取消', callback: () => settingsDialog.hide() },{ label: '保存', callback: saveSettings }]); settingsDialog.show(); } catch (error) { alert(`加载设置失败: ${error}`); } } + async function saveSettings() { const apiUrl = document.getElementById('publisher-api-url').value; try { await api.fetchApi("/publisher/settings", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_url: apiUrl }) }); alert("设置已保存!"); settingsDialog.hide(); } catch (error) { alert(`保存设置失败: ${error}`); } } + async function showPublishDialog() { const settingsResp = await api.fetchApi("/publisher/settings", { cache: "no-store" }); const settings = await settingsResp.json(); if (!settings.api_url) { alert("请先点击 ⚙️ 按钮设置发布API的URL。"); return; } publishDialog.setContent(`

`); publishDialog.addButtons([{ label: '取消', callback: () => publishDialog.hide() },{ label: '确认发布', callback: publishWorkflow }]); publishDialog.show(); } + } +}); \ No newline at end of file diff --git a/workflow_server_demo.py b/workflow_server_demo.py new file mode 100644 index 0000000..ba04602 --- /dev/null +++ b/workflow_server_demo.py @@ -0,0 +1,108 @@ +from http.server import HTTPServer, BaseHTTPRequestHandler +import json +import threading +import urllib + +# "数据库" 和线程锁保持不变 +WORKFLOWS_DB = {} +db_lock = threading.Lock() + + +class SimpleAPIHandler(BaseHTTPRequestHandler): + + def _send_cors_headers(self): + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') # 添加 DELETE + self.send_header("Access-Control-Allow-Headers", "X-Requested-With, Content-Type") + + def do_OPTIONS(self): + self.send_response(200, "ok") + self._send_cors_headers() + self.end_headers() + + def do_GET(self): + # ... (do_GET 保持不变) ... + if self.path == '/api/workflow': + self.send_response(200) + self.send_header('Content-type', 'application/json') + self._send_cors_headers() + self.end_headers() + with db_lock: + response_data = list(WORKFLOWS_DB.values()) + self.wfile.write(json.dumps(response_data).encode()) + else: + self.send_response(404) + self.end_headers() + self.wfile.write(b'Not Found') + + def do_POST(self): + # ... (do_POST 保持不变) ... + if self.path == '/api/workflow': + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + try: + data = json.loads(post_data) + name = data.get('name') + workflow_data = data.get('workflow') + if not name or not workflow_data: + raise ValueError("Missing 'name' or 'workflow' in request body") + with db_lock: + WORKFLOWS_DB[name] = {"name": name, "workflow": workflow_data} + print(f"--- Received/Updated workflow: {name} ---") + self.send_response(200) + self.send_header('Content-type', 'application/json') + self._send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"status": "received", "name": name}).encode()) + except Exception as e: + self.send_response(400); + self.end_headers(); + self.wfile.write(f'Bad Request: {e}'.encode()) + else: + self.send_response(404); + self.end_headers(); + self.wfile.write(b'Not Found') + + # --- [核心新增] 添加 do_DELETE 方法 --- + def do_DELETE(self): + """处理删除指定工作流的请求""" + # 我们期望的路径格式: /api/workflow/工作流名称[版本号] + parts = self.path.split('/api/workflow/') + if len(parts) == 2 and parts[1]: + # URL解码工作流名称,因为它可能包含空格等特殊字符 + workflow_name_to_delete = urllib.parse.unquote(parts[1]) + + with db_lock: + if workflow_name_to_delete in WORKFLOWS_DB: + del WORKFLOWS_DB[workflow_name_to_delete] + print(f"--- Deleted workflow: {workflow_name_to_delete} ---") + self.send_response(200) + self.send_header('Content-type', 'application/json') + self._send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"status": "deleted", "name": workflow_name_to_delete}).encode()) + else: + # 如果找不到要删除的工作流 + self.send_response(404) + self._send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"status": "error", "message": "Workflow not found"}).encode()) + else: + self.send_response(400) + self._send_cors_headers() + self.end_headers() + self.wfile.write(b'Bad Request: Invalid delete path') + + +def run(server_class=HTTPServer, handler_class=SimpleAPIHandler, port=8000): + server_address = ('', port) + httpd = server_class(server_address, handler_class) + print(f"测试API服务器已在 http://localhost:{port} 上启动...") + print(" - POST /api/workflow: 发布新工作流") + print(" - GET /api/workflow: 获取所有工作流") + print(" - DELETE /api/workflow/: 删除指定工作流") + httpd.serve_forever() + + +if __name__ == '__main__': + run()