Compare commits

..
6 Commits
Author SHA1 Message Date
KarrixLee ffda951cfa Bump version to 2.3.5 in Manager 2025-09-10 15:14:56 +08:00
KarrixLee ac0d8ba725 Bump version to 2.3.5 in Manager (#107)
* init

* tweak

* add: model display

* test

* add: real logic to upload model

* fix native mode

* tweak again

* add: workflow version control

* fix: versioning display

* refactor: remove unnecessary code
2025-09-10 15:13:04 +08:00
bennykok 31c38ddb9b version bump 2025-08-29 15:48:42 -07:00
bennykok 2f2c63fa11 proxy interrupt request 2025-08-29 15:47:54 -07:00
BennyKok 7675c5ba90 Update README with demo link and recruitment note
Added a link to the latest local demo and a call for team members.
2025-08-29 13:03:39 -07:00
bennykok 7d65d23f84 fix: add null check in findIndex to prevent TypeError in context menu
- Fixed TypeError when accessing 'content' property of null/undefined array elements
- Removed empty options.push() that was adding undefined to the array
- Bump version to 2.3.3
2025-08-26 18:34:20 -07:00
8 changed files with 2509 additions and 67 deletions
+2
View File
@@ -2,6 +2,8 @@
Open source comfyui deployment platform, a `vercel` for generative workflow infra. (serverless hosted gpu with vertical intergation with comfyui)
Check out our latest lcoal demo -> https://github.com/comfy-deploy/comfyui-api-comfydeploy
> [!NOTE]
> Im looking for creative hacker to join ComfyDeploy's core team! DM me on [twitter](https://x.com/BennyKokMusic)
+359
View File
@@ -672,6 +672,14 @@ async def comfy_deploy_run(request):
return web.json_response(res, status=status)
@server.PromptServer.instance.routes.post("/comfyui-deploy/interrupt")
async def interrupt_prompt(request):
data = await request.json()
prompt_id = data.get("prompt_id")
await update_run(prompt_id, Status.CANCELLED)
return web.json_response({"message": "Prompt interrupted"}, status=200)
async def stream_prompt(data, token):
# In older version, we use workflow_api, but this has inputs already swapped in nextjs frontend, which is tricky
workflow_api = data.get("workflow_api_raw")
@@ -3140,6 +3148,68 @@ async def get_workflow_proxy(request):
return web.json_response({"error": str(e)}, status=500)
# fetch workflow versions (infinite scroll support)
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflow/versions")
async def get_workflow_versions_proxy(request):
workflow_id = request.rel_url.query.get("workflow_id")
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
search = request.rel_url.query.get("search", "")
limit = request.rel_url.query.get("limit", "20")
offset = request.rel_url.query.get("offset", "0")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
# Build target URL with query params
params = {"limit": limit, "offset": offset}
if search:
params["search"] = search
query = urlencode(params)
target_url = f"{api_url}/api/workflow/{workflow_id}/versions"
if query:
target_url += f"?{query}"
try:
await ensure_client_session()
async with client_session.get(
target_url, headers={"Authorization": auth_header}
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# fetch a specific workflow version json
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflow/version")
async def get_workflow_version_proxy(request):
workflow_id = request.rel_url.query.get("workflow_id")
version = request.rel_url.query.get("version")
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/workflow/{workflow_id}/version/{version}"
try:
await ensure_client_session()
async with client_session.get(
target_url, headers={"Authorization": auth_header}
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# for getting a machine by id
@server.PromptServer.instance.routes.get("/comfyui-deploy/machine")
async def get_machine_proxy(request):
@@ -3282,3 +3352,292 @@ async def get_comfyui_version_proxy(request):
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: generate single-part upload URL
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/generate-upload-url"
)
async def proxy_generate_upload_url(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/generate-upload-url"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"filename": data.get("filename"),
"contentType": data.get("contentType"),
"size": data.get("size"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: initiate multipart upload
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/initiate-multipart-upload"
)
async def proxy_initiate_multipart_upload(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/initiate-multipart-upload"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"filename": data.get("filename"),
"contentType": data.get("contentType"),
"size": data.get("size"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: generate part upload URL
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/generate-part-upload-url"
)
async def proxy_generate_part_upload_url(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/generate-part-upload-url"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"uploadId": data.get("uploadId"),
"key": data.get("key"),
"partNumber": data.get("partNumber"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: complete multipart upload
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/complete-multipart-upload"
)
async def proxy_complete_multipart_upload(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/complete-multipart-upload"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"uploadId": data.get("uploadId"),
"key": data.get("key"),
"parts": data.get("parts"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: abort multipart upload
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/abort-multipart-upload"
)
async def proxy_abort_multipart_upload(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/abort-multipart-upload"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"uploadId": data.get("uploadId"),
"key": data.get("key"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: add model (unified endpoint)
@server.PromptServer.instance.routes.post("/comfyui-deploy/volume/model")
async def proxy_add_model(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/model"
# pass body through but remove api_url key
forward_body = dict(data)
if "api_url" in forward_body:
forward_body.pop("api_url")
try:
await ensure_client_session()
async with client_session.post(
target_url, json=forward_body, headers={"Authorization": auth_header}
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# FS: stat file (size)
@server.PromptServer.instance.routes.get("/comfyui-deploy/fs/stat")
async def fs_stat(request):
try:
import os
file_path = request.rel_url.query.get("path")
if not file_path:
return web.json_response({"error": "path is required"}, status=400)
# Basic safeguard: ensure it's a ComfyUI models path
if "/models/" not in file_path:
return web.json_response({"error": "invalid path"}, status=400)
st = os.stat(file_path)
return web.json_response({"size": st.st_size})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Upload a multipart part directly from machine filesystem to S3 upload URL
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/upload-part-from-path"
)
async def upload_part_from_path(request):
try:
import os
import io
data = await request.json()
file_path = data.get("filePath")
upload_url = data.get("uploadUrl")
start = int(data.get("start", 0))
end = int(data.get("end", 0))
if not file_path or not upload_url:
return web.json_response(
{"error": "filePath and uploadUrl are required"}, status=400
)
if "/models/" not in file_path:
return web.json_response({"error": "invalid filePath"}, status=400)
if end <= start:
return web.json_response({"error": "invalid byte range"}, status=400)
size = end - start
await ensure_client_session()
# Important: S3 pre-signed part uploads do not support chunked transfer
# Buffer the exact part into memory to provide a Content-Length header
buffer = bytearray()
chunk_size = 4 * 1024 * 1024
with open(file_path, "rb") as f:
f.seek(start)
remaining = size
while remaining > 0:
to_read = chunk_size if remaining >= chunk_size else remaining
chunk = f.read(to_read)
if not chunk:
break
buffer.extend(chunk)
remaining -= len(chunk)
if len(buffer) != size:
return web.json_response(
{
"error": "read size mismatch",
"expected": size,
"actual": len(buffer),
},
status=500,
)
headers = {
"Content-Length": str(size),
"Content-Type": "application/octet-stream",
}
async with client_session.put(
upload_url, data=bytes(buffer), headers=headers
) as resp:
text = await resp.text()
if resp.status < 200 or resp.status >= 300:
return web.json_response(
{"error": f"upload failed: {resp.status}", "body": text},
status=resp.status,
)
etag = resp.headers.get("ETag") or resp.headers.get("etag") or ""
etag = etag.replace('"', "")
return web.json_response({"eTag": etag, "bytesSent": size})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+1
View File
@@ -18,6 +18,7 @@ class Status(Enum):
SUCCESS = "success"
FAILED = "failed"
UPLOADING = "uploading"
CANCELLED = "cancelled"
class StreamingPrompt(BaseModel):
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-deploy"
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
version = "2.3.2"
version = "2.3.5"
license = { file = "LICENSE" }
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg", "tabulate", "brotli"]
+177 -38
View File
@@ -3,13 +3,15 @@ import { api } from "../../scripts/api.js";
// import { LGraphNode } from "../../scripts/widgets.js";
LGraphNode = LiteGraph.LGraphNode;
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { initializeWorkflowsList, addWorkflowSearch } from "./workflow-list.js";
import {
initializeWorkflowsList,
addWorkflowSearch,
refreshCurrentWorkflowCard,
} from "./workflow-list.js";
import { initializeMachineManager } from "./machine-manager.js";
import { initializeModelManager } from "./model-manager.js";
import { fetchSnapshotSimple } from "./snapshot-utils.js";
import { generateDependencyGraph } from "https://esm.sh/comfyui-json@0.1.25";
// import { ComfyDeploy } from "https://esm.sh/comfydeploy@2.0.0-beta.69";
const styles = `
.comfydeploy-menu-item {
background: linear-gradient(to right, rgba(74, 144, 226, 0.9), rgba(103, 178, 111, 0.9)) !important;
@@ -538,9 +540,9 @@ const ext = {
}
if (toInput.length) {
if (true) {
options.push();
let optionIndex = options.findIndex((o) => o.content === "Outputs");
let optionIndex = options.findIndex(
(o) => o && o.content === "Outputs"
);
if (optionIndex === -1) optionIndex = options.length;
else optionIndex++;
options.splice(
@@ -1332,6 +1334,14 @@ async function deployWorkflow() {
deployMetaNode.widgets[2].value = data.version;
graph.change();
if (window.refreshCurrentWorkflowCard) {
console.log(
"Refreshing current workflow card after version update with version",
data.version
);
window.refreshCurrentWorkflowCard(workflow_id, data.version);
}
}
} catch (e) {
infoDialog.showError("Error", e.message);
@@ -1426,6 +1436,20 @@ async function deployWorkflow() {
if (new_version_data.status !== 200) {
throw new Error(await new_version_data.text());
} else {
// Parse the JSON response to get the actual version data
const versionData = await new_version_data.json();
if (window.refreshCurrentWorkflowCard) {
console.log(
"Refreshing current workflow card with new version",
versionData.version
);
window.refreshCurrentWorkflowCard(
data.workflow_id,
versionData.version
);
}
}
}
@@ -2534,14 +2558,12 @@ export class ConfigDialog extends ComfyDialog {
export const configDialog = new ConfigDialog();
const currentOrigin = window.location.origin;
// const client = new ComfyDeploy({
// bearerAuth: getData().apiKey,
// serverURL: `${currentOrigin}/comfydeploy/api/`,
// });
const referrer = document.referrer;
const isComfyDeployDashboard = currentOrigin.includes("comfydeploy.com");
const isComfyDeployDashboard =
currentOrigin.includes("comfydeploy.com") ||
(referrer && referrer.includes("comfydeploy.com"));
// Only register the sidebar tab if we're on localhost
if (!isComfyDeployDashboard) {
app.extensionManager.registerSidebarTab({
id: "search",
@@ -2555,11 +2577,67 @@ if (!isComfyDeployDashboard) {
<h3>Comfy Deploy</h3>
<div id="deploy-container" style="margin-bottom: 20px;"></div>
<div id="machine-container" style="margin-bottom: 20px;">
<h4>Serverless Machine</h4>
<div id="machine-loading" style="display: flex; justify-content: center; align-items: center; height: 100px;">
${loadingIcon}
<div style="
display: flex;
border-bottom: 1px solid #333;
margin-bottom: 16px;
position: relative;
">
<button
id="machine-tab"
onclick="switchTab('machine')"
style="
background: transparent;
color: white;
border: none;
padding: 12px 16px 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
border-bottom: 2px solid #3498db;
"
onmouseover="this.style.color='#fff'"
onmouseout="if(this.style.borderBottomColor !== 'rgb(52, 152, 219)') this.style.color='#999'"
>
Machine
</button>
<button
id="model-tab"
onclick="switchTab('model')"
style="
background: transparent;
color: #999;
border: none;
padding: 12px 16px 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
"
onmouseover="this.style.color='#fff'"
onmouseout="if(this.style.borderBottomColor !== 'rgb(52, 152, 219)') this.style.color='#999'"
>
Model
</button>
</div>
<div id="machine-tab-content">
<div id="machine-loading" style="display: flex; justify-content: center; align-items: center; height: 100px;">
${loadingIcon}
</div>
<ul id="machine-list" style="list-style-type: none; padding: 0; display: none;"></ul>
</div>
<div id="model-tab-content" style="display: none;">
<div id="model-loading" style="display: flex; justify-content: center; align-items: center; height: 100px;">
${loadingIcon}
</div>
<ul id="model-list" style="list-style-type: none; padding: 0; display: none;"></ul>
</div>
<ul id="machine-list" style="list-style-type: none; padding: 0; display: none;"></ul>
</div>
<div id="workflows-container" style="display: none;">
<h4>Your Workflows</h4>
@@ -2621,10 +2699,41 @@ if (!isComfyDeployDashboard) {
const workflowsList = el.querySelector("#workflows-list");
const workflowsLoading = el.querySelector("#workflows-loading");
// Initialize machine manager
// Initialize machine and model managers
const data = getData();
if (data.apiKey) {
await initializeMachineManager(el, getData);
await initializeModelManager(el, getData);
// Add tab switching functionality
window.switchTab = function (tabName) {
const machineTab = el.querySelector("#machine-tab");
const modelTab = el.querySelector("#model-tab");
const machineContent = el.querySelector("#machine-tab-content");
const modelContent = el.querySelector("#model-tab-content");
if (tabName === "machine") {
// Active machine tab
machineTab.style.color = "white";
machineTab.style.borderBottomColor = "#3498db";
// Inactive model tab
modelTab.style.color = "#999";
modelTab.style.borderBottomColor = "transparent";
// Show/hide content
machineContent.style.display = "block";
modelContent.style.display = "none";
} else if (tabName === "model") {
// Active model tab
modelTab.style.color = "white";
modelTab.style.borderBottomColor = "#3498db";
// Inactive machine tab
machineTab.style.color = "#999";
machineTab.style.borderBottomColor = "transparent";
// Show/hide content
modelContent.style.display = "block";
machineContent.style.display = "none";
}
};
} else {
// Hide machine container when no API key
const machineContainer = el.querySelector("#machine-container");
@@ -2680,31 +2789,57 @@ const orginal_fetch_api = api.fetchApi;
api.fetchApi = async (route, options) => {
// console.log("Fetch API called with args:", route, options, ext.native_mode);
if (route.startsWith("/prompt") && ext.native_mode) {
const info = await getSelectedWorkflowInfo();
if (ext.native_mode) {
if (route.startsWith("/prompt")) {
const info = await getSelectedWorkflowInfo();
if (!info.workflow_id) {
console.log("No workflow id found, fallback to original fetch");
return await orginal_fetch_api.call(api, route, options);
}
if (!info.workflow_id) {
console.log("No workflow id found, fallback to original fetch");
return await orginal_fetch_api.call(api, route, options);
}
console.log("info", info);
if (info) {
console.log("info", info);
if (info) {
const body = JSON.parse(options.body);
const data = {
client_id: body.client_id,
workflow_api_json: body.prompt,
workflow: body?.extra_data?.extra_pnginfo?.workflow,
is_native_run: true,
machine_id: info.machine_id,
workflow_id: info.workflow_id,
native_run_api_endpoint: info.native_run_api_endpoint,
gpu_event_id: info.gpu_event_id,
gpu: info.gpu,
};
return await fetch("/comfyui-deploy/run", {
method: "POST",
headers: {
Authorization: `Bearer ${info.cd_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}
} else if (route.startsWith("/interrupt")) {
const info = await getSelectedWorkflowInfo();
if (!info.workflow_id) {
console.log("No workflow id found, fallback to original fetch");
return await orginal_fetch_api.call(api, route, options);
}
const body = JSON.parse(options.body);
const data = {
client_id: body.client_id,
workflow_api_json: body.prompt,
workflow: body?.extra_data?.extra_pnginfo?.workflow,
is_native_run: true,
machine_id: info.machine_id,
workflow_id: info.workflow_id,
native_run_api_endpoint: info.native_run_api_endpoint,
gpu_event_id: info.gpu_event_id,
gpu: info.gpu,
prompt_id: body.prompt_id,
};
return await fetch("/comfyui-deploy/run", {
const original_response = await orginal_fetch_api.call(
api,
route,
options
);
await fetch("/comfyui-deploy/interrupt", {
method: "POST",
headers: {
Authorization: `Bearer ${info.cd_token}`,
@@ -2712,6 +2847,7 @@ api.fetchApi = async (route, options) => {
},
body: JSON.stringify(data),
});
return original_response;
}
}
@@ -3046,6 +3182,9 @@ async function refreshWorkflowListIfOpen() {
// Reinitialize the workflow list
await initializeWorkflowsList(document, getData, getTimeAgo);
// Also refresh the current workflow card
refreshCurrentWorkflowCard();
console.log("Workflow list refreshed successfully");
} catch (error) {
console.error("Error refreshing workflow list:", error);
+1 -1
View File
@@ -88,7 +88,7 @@ function showNoMachineMessage(machineLoading, machineList) {
<line x1="6" x2="6.01" y1="18" y2="18"/>
</svg>
</div>
<div style="font-weight: 500; margin-bottom: 12px;">No machine found</div>
<div style="font-weight: 500; margin-bottom: 12px; font-size: 12px;">No machine found</div>
<div style="display: flex; gap: 8px; justify-content: center;">
<button
onclick="showAddMachineDialog()"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff