From 745319962cd9fef9e6df069f2b409339fd012c48 Mon Sep 17 00:00:00 2001 From: iHeyTang Date: Mon, 11 Aug 2025 15:24:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0CORS=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96API=E5=93=8D=E5=BA=94?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=9E=84=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AD=98=E5=82=A8=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 153 +++++++++----- js/publisher.js | 381 ++++++++++++++++------------------- run_service.py | 17 ++ workflow_service/database.py | 22 +- workflow_service/main.py | 42 +++- 5 files changed, 338 insertions(+), 277 deletions(-) create mode 100644 run_service.py diff --git a/__init__.py b/__init__.py index a0e16fd..bdabe92 100644 --- a/__init__.py +++ b/__init__.py @@ -13,7 +13,6 @@ CONFIG_FILE = os.path.join(NODE_DIR, "config.json") # 默认配置 DEFAULT_CONFIG = { - "api_url": "", "host": "" } @@ -75,7 +74,11 @@ class WorkflowPublisherNode: async def get_publisher_settings(request): """获取发布器设置""" config = load_config() - return web.json_response(config) + response = web.json_response(config) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response @PromptServer.instance.routes.post("/publisher/settings") @@ -83,15 +86,21 @@ async def save_publisher_settings(request): """保存发布器设置""" try: data = await request.json() - api_url = data.get("api_url", "") host = data.get("host", "") config = load_config() - config["api_url"] = api_url config["host"] = host save_config(config) - return web.json_response({"status": "success", "message": "Settings saved"}) + response = web.json_response({"status": "success", "message": "Settings saved"}) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response except Exception as e: - return web.json_response({"status": "error", "message": str(e)}, status=500) + response = web.json_response({"status": "error", "message": str(e)}, status=500) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response @PromptServer.instance.routes.post("/publisher/publish") @@ -103,22 +112,24 @@ async def publish_workflow_handler(request): 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) + response = web.json_response({"status": "error", "message": "Missing workflow data or name"}, status=400) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response config = load_config() - target_api_url = config.get("api_url") host = config.get("host", "") - if not target_api_url: - return web.json_response({"status": "error", "message": "API URL not configured"}, status=400) + if not host: + response = web.json_response({"status": "error", "message": "Host not configured"}, status=400) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response - # 构建完整的URL - if host and not target_api_url.startswith(('http://', 'https://')): - # 如果提供了host且api_url不是完整URL,则组合它们 - full_url = host.rstrip('/') + '/' + target_api_url.lstrip('/') - else: - # 否则直接使用api_url - full_url = target_api_url + # 构建完整的URL - 直接使用 host + /api/workflow + full_url = host.rstrip('/') + '/api/workflow' # 准备要发送到目标API的数据 payload = { @@ -133,19 +144,31 @@ async def publish_workflow_handler(request): async with session.post(full_url, json=payload, headers=headers) as response: response_text = await response.text() if response.status == 200 or response.status == 201: - return web.json_response( + result_response = web.json_response( {"status": "success", "message": "Workflow published successfully!", "details": response_text}) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response else: - return web.json_response({ + result_response = web.json_response({ "status": "error", "message": f"Failed to publish workflow. Target API returned status {response.status}", "details": response_text }, status=500) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response except Exception as e: import traceback traceback.print_exc() - return web.json_response({"status": "error", "message": str(e)}, status=500) + result_response = web.json_response({"status": "error", "message": str(e)}, status=500) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response @PromptServer.instance.routes.get("/publisher/workflows") @@ -153,29 +176,31 @@ async def get_workflows_from_server(request): """从目标服务器获取工作流列表,并按基础名称进行分组""" try: config = load_config() - target_api_url = config.get("api_url") host = config.get("host", "") - if not target_api_url: - return web.json_response({"status": "error", "message": "API URL not configured"}, status=400) + if not host: + response = web.json_response({"status": "error", "message": "Host not configured"}, status=400) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response - # 构建完整的URL - if host and not target_api_url.startswith(('http://', 'https://')): - # 如果提供了host且api_url不是完整URL,则组合它们 - full_url = host.rstrip('/') + '/' + target_api_url.lstrip('/') - else: - # 否则直接使用api_url - full_url = target_api_url + # 构建完整的URL - 直接使用 host + /api/workflow + full_url = host.rstrip('/') + '/api/workflow' async with aiohttp.ClientSession() as session: async with session.get(full_url) as response: if response.status != 200: response_text = await response.text() - return web.json_response({ + result_response = web.json_response({ "status": "error", "message": f"Failed to fetch workflows. Target API returned status {response.status}", "details": response_text }, status=500) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response workflows = await response.json() @@ -206,12 +231,20 @@ async def get_workflows_from_server(request): for base_name in grouped_workflows: grouped_workflows[base_name].sort(key=lambda x: x['version'], reverse=True) - return web.json_response(grouped_workflows) + result_response = web.json_response(grouped_workflows) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response except Exception as e: import traceback traceback.print_exc() - return web.json_response({"status": "error", "message": str(e)}, status=500) + result_response = web.json_response({"status": "error", "message": str(e)}, status=500) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response @PromptServer.instance.routes.post("/publisher/workflow/delete") @@ -221,22 +254,24 @@ async def delete_workflow_version(request): 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) + response = web.json_response({"status": "error", "message": "Missing workflow name"}, status=400) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response config = load_config() - target_api_url = config.get("api_url") host = config.get("host", "") - if not target_api_url: - return web.json_response({"status": "error", "message": "API URL not configured"}, status=400) + if not host: + response = web.json_response({"status": "error", "message": "Host not configured"}, status=400) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response - # 构建完整的URL - if host and not target_api_url.startswith(('http://', 'https://')): - # 如果提供了host且api_url不是完整URL,则组合它们 - base_url = host.rstrip('/') + '/' + target_api_url.lstrip('/') - else: - # 否则直接使用api_url - base_url = target_api_url + # 构建完整的URL - 直接使用 host + /api/workflow + base_url = host.rstrip('/') + '/api/workflow' # 构建目标URL,需要对工作流名称进行URL编码 delete_url = f"{base_url}/{urllib.parse.quote(workflow_name)}" @@ -244,16 +279,40 @@ async def delete_workflow_version(request): 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"}) + result_response = web.json_response({"status": "success", "message": "Workflow version deleted"}) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response else: details = await response.text() - return web.json_response({ + result_response = web.json_response({ "status": "error", "message": f"Target API failed to delete. Status: {response.status}", "details": details }, status=response.status) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response except Exception as e: - return web.json_response({"status": "error", "message": str(e)}, status=500) + result_response = web.json_response({"status": "error", "message": str(e)}, status=500) + result_response.headers['Access-Control-Allow-Origin'] = '*' + result_response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + result_response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return result_response + + +# 添加OPTIONS请求处理,用于CORS预检请求 +@PromptServer.instance.routes.options("/publisher/{path:.*}") +async def handle_options(request): + """处理CORS预检请求""" + response = web.Response() + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + response.headers['Access-Control-Max-Age'] = '86400' # 24小时 + return response # ----------------- # ComfyUI 注册 diff --git a/js/publisher.js b/js/publisher.js index aba716b..25e1161 100644 --- a/js/publisher.js +++ b/js/publisher.js @@ -1,6 +1,93 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +// 本地存储的 key +const SETTINGS_STORAGE_KEY = "comfyui_publisher_settings"; + +// 获取本地设置 +function getLocalSettings() { + try { + const stored = localStorage.getItem(SETTINGS_STORAGE_KEY); + return stored ? JSON.parse(stored) : { host: "" }; + } catch (error) { + console.error("读取本地设置失败:", error); + return { host: "" }; + } +} + +// 保存设置到本地 +function saveLocalSettings(settings) { + try { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch (error) { + console.error("保存本地设置失败:", error); + } +} + +// 自定义 fetchApi 函数,使用本地存储的 host +async function customFetchApi(endpoint, options = {}) { + try { + // 从本地存储获取设置 + const settings = getLocalSettings(); + + // 如果没有配置 host,回退到默认的 api.fetchApi + if (!settings.host) { + console.warn("未配置 host,使用默认的 ComfyUI API"); + return await api.fetchApi(endpoint, options); + } + + // 构建完整的 URL + const host = settings.host.replace(/\/$/, ""); // 移除末尾的斜杠 + const fullUrl = `${host}${endpoint}`; + + console.log(`使用本地存储的 host 请求: ${fullUrl}`); + + // 使用 fetch 发送请求 + const response = await fetch(fullUrl, { + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + + return response; + } catch (error) { + console.error("自定义 fetchApi 失败,回退到默认 API:", error); + // 如果自定义请求失败,回退到默认的 api.fetchApi + return await api.fetchApi(endpoint, options); + } +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch (error) { + console.error("JSON解析失败:", error, "原始数据:", text); + return null; + } +} + +function createElement(tagName, props) { + const element = document.createElement(tagName); + if (props.style) element.style = props.style; + if (props.className) element.className = props.className; + if (props.id) element.id = props.id; + if (props.textContent) element.textContent = props.textContent; + if (props.innerHTML) element.innerHTML = props.innerHTML; + if (props.onclick) element.onclick = props.onclick; + if (props.children) { + if (Array.isArray(props.children)) { + props.children.forEach((child) => { + element.appendChild(child); + }); + } else { + element.appendChild(props.children); + } + } + return element; +} + // Modal class (保持不变) class Modal { constructor(id, title, full_width = false) { @@ -34,6 +121,15 @@ class Modal { } } +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; +}; + app.registerExtension({ name: "Comfy.WorkflowPublisher", async setup() { @@ -81,25 +177,19 @@ app.registerExtension({ 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 btnGet = createButton(iconGet, "从服务器获取工作流", { + onClick: () => showWorkflowListDialog(), + }); + const btnPublish = createButton(iconPublish, "发布当前工作流", { + onClick: () => showPublishDialog(), + }); + const btnSettings = createButton(`⚙️`, "发布设置", { + onClick: () => showSettingsDialog(), + }); + const separator = document.createElement("div"); separator.style.cssText = "border-left: 1px solid var(--border-color); height: 22px; margin: 0 4px;"; @@ -111,63 +201,17 @@ app.registerExtension({ "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 errorText = await response.text(); - let err; - try { - err = errorText ? JSON.parse(errorText) : {}; - } catch (parseError) { - console.error("JSON解析失败:", parseError, "原始响应:", errorText); - err = { message: "服务器删除失败" }; - } - 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( @@ -178,23 +222,16 @@ app.registerExtension({ ]); workflowListDialog.show(); try { - const response = await api.fetchApi("/publisher/workflows"); + const response = await customFetchApi("/api/workflow"); if (!response.ok) { throw new Error( `服务器错误: ${response.status} ${response.statusText}` ); } const text = await response.text(); - let workflows; - try { - workflows = text ? JSON.parse(text) : {}; - } catch (parseError) { - console.error("JSON解析失败:", parseError, "原始响应:", text); - throw new Error("服务器返回的数据格式无效"); - } + const workflows = parseJson(text); - const workflowKeys = Object.keys(workflows); - if (workflowKeys.length === 0) { + if (workflows.length === 0) { workflowListDialog.setContent( '

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

' ); @@ -203,80 +240,24 @@ app.registerExtension({ 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 + workflows.forEach((workflow) => { + const workflowLabel = document.createElement("span"); + workflowLabel.textContent = workflow.name; + workflowLabel.onclick = () => { + if ( + confirm( + `确定要加载工作流 "${workflow.name}" 吗?\n这将覆盖当前画布。` + ) + ) { + const file = new File( + [JSON.stringify(workflow.workflow)], + workflow.name, + { type: "application/json" } ); - }; - 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"; + app.handleFile(file); } }; - accordionContainer.appendChild(header); - accordionContainer.appendChild(panel); + accordionContainer.appendChild(workflowLabel); }); workflowListDialog.setContent(""); workflowListDialog.element @@ -290,34 +271,23 @@ app.registerExtension({ } } - // ... (其余函数保持不变) ... async function publishWorkflow() { - const baseName = document + const flowName = document .getElementById("publisher-workflow-name") .value.trim(); - if (!baseName) { + if (!flowName) { 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}]`; + const versionString = new Date().toISOString(); try { const workflow = await app.graph.serialize(); - const body = { name: finalName, workflow: workflow }; - statusEl.textContent = `正在发布为 "${finalName}"...`; - const response = await api.fetchApi("/publisher/publish", { + const body = { name: flowName, workflow: workflow }; + statusEl.textContent = `正在发布为 "${flowName}",版本号: ${versionString}...`; + const response = await customFetchApi("/api/workflow", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -347,34 +317,15 @@ app.registerExtension({ console.error("发布失败:", error); } } + async function showSettingsDialog() { try { - let settings = { api_url: "", host: "" }; - - await api - .fetchApi("/publisher/settings", { - cache: "no-store", - }) - .then(async (resp) => { - if (!resp.ok) { - console.error(`服务器错误: ${resp.status} ${resp.statusText}`); - return; - } - const text = await resp.text(); - try { - settings = text ? JSON.parse(text) : {}; - } catch (parseError) { - console.error("JSON解析失败:", parseError, "原始响应:", text); - settings = { api_url: "", host: "" }; - } - }); + // 首先尝试从本地存储获取设置 + const settings = getLocalSettings(); settingsDialog.setContent(` - - - `); settingsDialog.addButtons([ { label: "取消", callback: () => settingsDialog.hide() }, @@ -386,51 +337,57 @@ app.registerExtension({ alert(`加载设置失败: ${error.message}`); } } + async function saveSettings() { - const apiUrl = document.getElementById("publisher-api-url").value; const host = document.getElementById("publisher-host").value; + + // 构建新的设置对象 + const newSettings = { host: host }; + try { - await api.fetchApi("/publisher/settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ api_url: apiUrl, host: host }), - }); - alert("设置已保存!"); + // 保存到本地存储 + saveLocalSettings(newSettings); + + alert("设置已保存到本地!"); settingsDialog.hide(); } catch (error) { alert(`保存设置失败: ${error}`); } } + async function showPublishDialog() { try { - const settingsResp = await api.fetchApi("/publisher/settings", { - cache: "no-store", - }); - if (!settingsResp.ok) { - throw new Error( - `服务器错误: ${settingsResp.status} ${settingsResp.statusText}` - ); - } - const text = await settingsResp.text(); - let settings; - try { - settings = text ? JSON.parse(text) : {}; - } catch (parseError) { - console.error("JSON解析失败:", parseError, "原始响应:", text); - settings = { api_url: "", host: "" }; + // 从本地存储获取设置 + let settings = getLocalSettings(); + + // 如果本地没有设置,尝试从服务器获取 + if (!settings.host) { + try { + const settingsResp = await customFetchApi("/publisher/settings", { + cache: "no-store", + }); + if (settingsResp.ok) { + const text = await settingsResp.text(); + try { + const serverSettings = text ? JSON.parse(text) : {}; + settings = { ...settings, ...serverSettings }; + // 保存到本地存储 + saveLocalSettings(settings); + } catch (parseError) { + console.error("JSON解析失败:", parseError, "原始响应:", text); + } + } + } catch (error) { + console.error("从服务器获取设置失败:", error); + } } // 确保设置对象有必需的字段 settings = { - api_url: settings.api_url || "", host: settings.host || "", ...settings, }; - if (!settings.api_url) { - alert("请先点击 ⚙️ 按钮设置发布API的URL。"); - return; - } publishDialog.setContent( `

` ); diff --git a/run_service.py b/run_service.py new file mode 100644 index 0000000..e567aa9 --- /dev/null +++ b/run_service.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +""" +启动脚本 - 从项目根目录运行 workflow_service +""" +import sys +import os + +# 将项目根目录添加到Python路径 +project_root = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, project_root) + +# 现在可以正常导入 +from workflow_service.main import web_app +import uvicorn + +if __name__ == "__main__": + uvicorn.run(web_app, host="0.0.0.0", port=18000) diff --git a/workflow_service/database.py b/workflow_service/database.py index 3611b81..020c720 100644 --- a/workflow_service/database.py +++ b/workflow_service/database.py @@ -1,3 +1,4 @@ +from datetime import datetime import aiosqlite import json import re @@ -20,23 +21,28 @@ async def init_db(): print(f"数据库 '{DATABASE_FILE}' 已准备就绪。") async def save_workflow(name: str, workflow_json: str): - # 正则表达式修正为匹配更精确的版本格式 [YYYYMMDDHHMMSS] - version_match = re.search(r"\[(\d{14})\]$", name) - if not version_match: - raise ValueError("Workflow name must have a version suffix like [YYYYMMDDHHMMSS]") - version = version_match.group(1) - base_name = re.sub(r"\s*\[\d{14}\]$", "", name).strip() + version = datetime.now().strftime("%Y%m%d%H%M%S") async with aiosqlite.connect(DATABASE_FILE) as db: await db.execute( "INSERT OR REPLACE INTO workflows (name, base_name, version, workflow_json) VALUES (?, ?, ?, ?)", - (name, base_name, version, workflow_json) + (f"{name} [{version}]", name, version, workflow_json) ) await db.commit() async def get_all_workflows() -> list[dict]: async with aiosqlite.connect(DATABASE_FILE) as db: db.row_factory = aiosqlite.Row - cursor = await db.execute("SELECT name, workflow_json FROM workflows") + # 按 base_name 分组,获取每个基础名称的最新版本记录 + cursor = await db.execute(""" + SELECT w1.name, w1.workflow_json, w1.base_name, w1.version + FROM workflows w1 + INNER JOIN ( + SELECT base_name, MAX(version) as max_version + FROM workflows + GROUP BY base_name + ) w2 ON w1.base_name = w2.base_name AND w1.version = w2.max_version + ORDER BY w1.base_name + """) rows = await cursor.fetchall() return [{"name": row["name"], "workflow": json.loads(row["workflow_json"])} for row in rows] diff --git a/workflow_service/main.py b/workflow_service/main.py index 93ba7c2..8cc7da1 100644 --- a/workflow_service/main.py +++ b/workflow_service/main.py @@ -9,6 +9,7 @@ import aiohttp import uvicorn from fastapi import FastAPI, Request, HTTPException, Path from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from workflow_service import comfyui_client @@ -22,6 +23,15 @@ settings = Settings() web_app = FastAPI(title="ComfyUI Workflow Service & Management API") +# 配置跨域支持 +web_app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 允许所有来源,生产环境中建议限制具体域名 + allow_credentials=True, + allow_methods=["*"], # 允许所有HTTP方法 + allow_headers=["*"], # 允许所有请求头 +) + # --- Pydantic Response Models for New Endpoints --- @@ -72,11 +82,11 @@ async def startup_event(): # --- Section 1: 工作流管理API --- -BASE_MANAGEMENT_PATH = "/api/workflow" - - -@web_app.post(BASE_MANAGEMENT_PATH, status_code=201) +@web_app.post("/api/workflow", status_code=201) async def publish_workflow_endpoint(request: Request): + """ + 发布工作流 + """ try: data = await request.json() name, wf_json = data.get("name"), data.get("workflow") @@ -92,8 +102,11 @@ async def publish_workflow_endpoint(request: Request): raise HTTPException(status_code=500, detail=f"Failed to save workflow: {e}") -@web_app.get(BASE_MANAGEMENT_PATH, response_model=List[dict]) +@web_app.get("/api/workflow", response_model=List[dict]) async def get_all_workflows_endpoint(): + """ + 获取所有工作流 + """ try: return await database.get_all_workflows() except Exception as e: @@ -101,9 +114,12 @@ async def get_all_workflows_endpoint(): # [BUG修复] 修正API路径,使其与其他管理端点保持一致 -@web_app.delete(f"{BASE_MANAGEMENT_PATH}/{{workflow_name:path}}") +@web_app.delete(f"/api/workflow/{{workflow_name:path}}") async def delete_workflow_endpoint(workflow_name: str = Path(..., description="The full, unique name of the workflow to delete, e.g., 'my_workflow [20250101120000]'")): + """ + 删除工作流 + """ try: success = await database.delete_workflow(workflow_name) if success: @@ -142,6 +158,9 @@ async def handle_file_upload(file_path: str, base_name: str) -> str: @web_app.post("/api/run/") async def execute_workflow_endpoint(base_name: str, request_data_raw: Dict[str, Any], version: Optional[str] = None): + """ + 执行工作流 + """ cleanup_paths = [] try: # 1. 获取工作流定义 @@ -261,6 +280,9 @@ async def execute_workflow_endpoint(base_name: str, request_data_raw: Dict[str, # --- Section 3: 工作流元数据/规范API --- @web_app.get("/api/spec/") async def get_workflow_spec_endpoint(base_name: str, version: Optional[str] = None): + """ + 获取工作流规范 + """ if version: workflow_data = await database.get_workflow_by_version(base_name, version) else: @@ -373,7 +395,7 @@ def read_root(): "step": 1, "action": "发布一个工作流 (Publish a Workflow)", "description": "从 ComfyUI 保存你的工作流 (API格式),然后使用一个唯一的名称将其发布到服务中。名称中必须包含一个版本号,格式为 `[YYYYMMDDHHMMSS]`。", - "endpoint": f"POST {BASE_MANAGEMENT_PATH}", + "endpoint": f"/api/workflow", "example_curl": "curl -X POST http://127.0.0.1:18000/api/workflow -H 'Content-Type: application/json' -d '{\n \"name\": \"my_t2i [20250801120000]\",\n \"workflow\": { ... your_workflow_api_json ... }\n}'" }, { @@ -404,11 +426,11 @@ def read_root(): "action": "管理工作流 (Manage Workflows)", "description": "你也可以列出所有已发布的工作流或删除不再需要的工作流。", "endpoints": [ - {"method": "GET", "path": f"{BASE_MANAGEMENT_PATH}", "description": "列出所有工作流。"}, - {"method": "DELETE", "path": f"{BASE_MANAGEMENT_PATH}/{{workflow_name}}", + {"method": "GET", "path": f"/api/workflow", "description": "列出所有工作流。"}, + {"method": "DELETE", "path": f"/api/workflow/{{workflow_name}}", "description": "按完整名称删除一个工作流。"} ], - "example_curl_list": f"curl -X GET http://127.0.0.1:18000{BASE_MANAGEMENT_PATH}", + "example_curl_list": f"curl -X GET http://127.0.0.1:18000/api/workflow", "example_curl_delete": f"curl -X DELETE 'http://127.0.0.1:18000/api/workflow/my_t2i%20[20250801120000]'" } ],