Compare commits

..

50 Commits

Author SHA1 Message Date
bymyself
3fdbc28dac Restore lost work from manager/menu-items-migration feature branch
This merge restores work that was lost during a bad rebase ~2 months ago.
Key restored functionality:
- ComfyUI Manager service with queue/task API endpoint
- Manager queue composable and WebSocket status handling
- Enhanced manager dialog commands and UI labels
- Manager websocket queue status enum types
- Server feature flag support functions

Resolves conflicts between recovery commit 730b278fa0 and
feature branch manager/menu-items-migration to combine all lost work.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 07:37:04 -07:00
bymyself
8075db41fa [Update to v2 API] update WS done message 2025-07-19 10:32:55 -07:00
bymyself
4f9470751d dont show missing nodes button in legacy manager mode 2025-07-19 10:32:55 -07:00
bymyself
f1610db470 improve command names 2025-07-19 10:29:12 -07:00
bymyself
1aee43425a use correct response shape 2025-07-19 10:29:12 -07:00
github-actions
bdd1230902 Update locales [skip ci] 2025-07-19 10:29:12 -07:00
bymyself
b01056e31f add "Check for Updates", "Install Missing" menu items 2025-07-19 10:28:57 -07:00
github-actions
e780e1d6d6 Update locales [skip ci] 2025-07-19 10:28:57 -07:00
bymyself
f8953a874d Add banner indicating how to use legacy manager UI 2025-07-19 10:28:49 -07:00
bymyself
aba2e5edfc move legacy option to startup arg 2025-07-19 10:27:16 -07:00
bymyself
eb5c49f67b await promises. update settings schema 2025-07-19 10:26:33 -07:00
bymyself
54a0981031 re-arrange menu items 2025-07-19 10:26:33 -07:00
bymyself
de6ed34836 switch to v2 manager API endpoints 2025-07-19 10:26:33 -07:00
github-actions
588bd99c3a Update locales [skip ci] 2025-07-19 10:26:33 -07:00
bymyself
c28d4514fc migrate manager menu items 2025-07-19 10:23:58 -07:00
Jin Yi
730b278fa0 [test] Update PackVersionBadge test to use role selector instead of Button component 2025-07-11 21:44:35 +09:00
github-actions
2e2647d1d0 Update locales [skip ci] 2025-07-11 08:26:14 +00:00
bymyself
7073c9d57f fix rebase error 2025-07-11 01:21:18 -07:00
Jin Yi
1cb94ca677 [Manager] “Restarting” state after clicking restart button (#4269) 2025-07-11 01:00:32 -07:00
Jin Yi
558cfcc527 [Manager] Add update all button functionality
- Add PackUpdateButton component for bulk updates
- Add useUpdateAvailableNodes composable to track available updates
- Integrate update all button in RegistrySearchBar
- Add localization strings for update functionality
- Add comprehensive tests for update functionality
- Add loading state to PackActionButton
2025-07-11 01:00:03 -07:00
bymyself
babe324f8b [tests] Update useServerLogs test after log subscription change
The test was expecting subscribeLogs(false) to be called, but this was commented out in commit 33d64475 to fix logs stopping after the first of multiple queue tasks. Updated test to reflect this temporary change.
2025-07-11 01:00:03 -07:00
bymyself
a83a7faea4 remove the temporary check for legacy custom node version of manager 2025-07-11 01:00:03 -07:00
bymyself
dc8589672c fix: logs stops listening after 1st of multiple queue tasks 2025-07-11 01:00:03 -07:00
bymyself
0f7e3f7100 [tests] Update useServerLogs test to handle task-started events
Update test to simulate cm-task-started events before logs events to match the actual behavior of the composable.
2025-07-11 01:00:03 -07:00
Christian Byrne
8fbd076b9c [Manager] Fix: failed tasks logs not correctly partitioned in UI (#4242) 2025-07-11 01:00:03 -07:00
bymyself
096e0c9ad0 fix failed task tab state binding 2025-07-11 01:00:03 -07:00
Christian Byrne
60e2f14516 [Manager] Filter task queue and history by client id (#4241) 2025-07-11 01:00:03 -07:00
github-actions
0f9d1f833d Update locales [skip ci] 2025-07-11 01:00:02 -07:00
bymyself
4249fe7a76 fix rebase errors 2025-07-11 00:59:30 -07:00
bymyself
5e4371507f [manager] Fix test failures and missing type definitions
- Fix ManagerProgressDialogContent test mock to include all required store methods
- Add missing MergedNodePack, RegistryPack type definitions and isMergedNodePack type guard
- Ensure all unit tests (548 passed) and component tests (174 passed) are working
- Fix TypeScript compilation errors related to manager store interfaces
2025-07-11 00:59:30 -07:00
bymyself
39ad01826a [manager] Update tests for new manager API
Updated tests for manager queue composable, server logs composable, and manager store to work with the new API signatures and functionality.
2025-07-11 00:59:30 -07:00
bymyself
b683a81239 [manager] Update UI components for new manager interface
Updated manager dialog components, pack cards, version selectors, and action buttons to work with the new manager API and state management structure.
2025-07-11 00:59:30 -07:00
bymyself
ba1d067c04 [manager] Update composables and state management
Updated manager queue composable, server logs composable, workflow packs composable, and manager store to support the new manager API structure and state management patterns.
2025-07-11 00:56:30 -07:00
bymyself
f241b7f7a0 [manager] Update core services for new manager API
Updated ComfyUI Manager service and dialog service to support the new menu items structure and API endpoints.
2025-07-11 00:56:30 -07:00
bymyself
b3486695d3 [manager] Update type definitions and schemas for menu items migration
Updated ComfyUI Manager types and API schemas to support the new menu items structure and manager functionality.
2025-07-11 00:56:30 -07:00
github-actions
7725b87c80 Update locales [skip ci] 2025-07-11 00:56:30 -07:00
bymyself
ced03ceee8 [Update to v2 API] update WS done message 2025-07-11 00:56:30 -07:00
bymyself
4a67a83252 dont show missing nodes button in legacy manager mode 2025-07-11 00:56:30 -07:00
bymyself
3862d78fb3 improve command names 2025-07-11 00:56:30 -07:00
bymyself
05ec65f636 use correct response shape 2025-07-11 00:56:30 -07:00
github-actions
dc2a4708f3 Update locales [skip ci] 2025-07-11 00:56:30 -07:00
bymyself
eee3b74547 add "Check for Updates", "Install Missing" menu items 2025-07-11 00:56:30 -07:00
github-actions
3c8adf4f80 Update locales [skip ci] 2025-07-11 00:56:30 -07:00
bymyself
d3a629ce89 Add banner indicating how to use legacy manager UI 2025-07-11 00:56:30 -07:00
bymyself
7c14b26321 move legacy option to startup arg 2025-07-11 00:55:24 -07:00
bymyself
5867103981 await promises. update settings schema 2025-07-11 00:55:02 -07:00
bymyself
363a46b1bf re-arrange menu items 2025-07-11 00:55:02 -07:00
bymyself
1f41367454 switch to v2 manager API endpoints 2025-07-11 00:55:02 -07:00
github-actions
2aed31ade1 Update locales [skip ci] 2025-07-11 00:55:02 -07:00
bymyself
366c55070c migrate manager menu items 2025-07-11 00:55:02 -07:00
105 changed files with 3302 additions and 7553 deletions

View File

@@ -76,10 +76,10 @@ jobs:
- name: Check for screen recording
id: check-recording
if: steps.check-changes.outputs.should_run == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Check PR body for screen recording
PR_BODY="${{ github.event.pull_request.body }}"
# Check for GitHub user attachments or YouTube links
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
echo "has_recording=true" >> "$GITHUB_OUTPUT"

View File

@@ -1,244 +0,0 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [
627.5973510742188,
423.0972900390625
],
"size": [
144.15234375,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
347.90441582814213,
417.3822440655296,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
892.5973510742188,
416.0972900390625,
120,
60
]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
1
],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
2
],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
554.8743286132812,
100.95539093017578
],
"size": [
270,
262
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [
685.1265869140625,
439.1734619140625
],
"size": [
140,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [
58.7671207025881,
137.7124650620126
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,412 +0,0 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
791.59912109375,
386.13336181640625
],
"size": [
210,
202
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {},
"widgets_values": [
"",
""
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 12,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
160
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
128.6640625,
60
]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
},
{
"id": "736e5a03-0f7f-4e48-93e4-fd66ea6c30f1",
"name": "text_1",
"type": "STRING",
"linkIds": [
11
],
"pos": {
"0": 581.59912109375,
"1": 419.13336181640625
}
},
{
"id": "b62e7a0b-cc7e-4ca5-a4e1-c81607a13f58",
"name": "model",
"type": "MODEL",
"linkIds": [
13
],
"pos": {
"0": 581.59912109375,
"1": 439.13336181640625
}
},
{
"id": "7a2628da-4879-4f82-a7d3-7b1c00db50a5",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
14
],
"pos": {
"0": 581.59912109375,
"1": 459.13336181640625
}
},
{
"id": "651cf4ad-e8bf-47f6-b181-8f8aeacd6669",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [
15
],
"pos": {
"0": 581.59912109375,
"1": 479.13336181640625
}
},
{
"id": "c41765ea-61ef-4a77-8cc6-74113903078f",
"name": "latent_image",
"type": "LATENT",
"linkIds": [
16
],
"pos": {
"0": 581.59912109375,
"1": 499.13336181640625
}
}
],
"outputs": [
{
"id": "55dd1505-12bd-4cb4-8e75-031a97bb4387",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [
12
],
"pos": {
"0": 1141.59912109375,
"1": 399.13336181640625
}
}
],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 11,
"type": "CLIPTextEncode",
"pos": [
668.755859375,
571.7766723632812
],
"size": [
400,
200
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
12
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 12,
"type": "KSampler",
"pos": [
671.7379760742188,
1.621593713760376
],
"size": [
270,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 16
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 1,
"type": "STRING"
},
{
"id": 12,
"origin_id": 11,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 2,
"target_id": 12,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 3,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 12,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 5,
"target_id": 12,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.9581355200690549,
"offset": [
184.687451089395,
80.38288288288285
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,341 +0,0 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
400,
300
],
"size": [
210,
168
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
},
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
""
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
160
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
120,
40
]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [
11
],
"pos": {
"0": 581.59912109375,
"1": 419.13336181640625
}
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [
12
],
"pos": {
"0": 581.59912109375,
"1": 439.13336181640625
}
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
13
],
"pos": {
"0": 581.59912109375,
"1": 459.13336181640625
}
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [
14
],
"pos": {
"0": 581.59912109375,
"1": 479.13336181640625
}
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [
15
],
"pos": {
"0": 581.59912109375,
"1": 499.13336181640625
}
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 11,
"type": "KSampler",
"pos": [
674.1234741210938,
570.5839233398438
],
"size": [
270,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.9581355200690549,
"offset": [
258.6405769416877,
147.17927927927929
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,153 +0,0 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
791.59912109375,
386.13336181640625
],
"size": [
140,
26
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
120,
40
]
},
"inputs": [
{
"id": "79e69fca-ad12-499b-8d9b-9f1656b85354",
"name": "clip",
"type": "CLIP",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.24.1",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"ds": {
"scale": 0.9581355200690549,
"offset": [
258.6405769416877,
147.17927927927929
]
}
},
"version": 0.4
}

View File

@@ -21,7 +21,7 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import type { Position, Size } from './types'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import { NodeReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config()
@@ -776,524 +776,11 @@ export class ComfyPage {
await this.nextFrame()
}
/**
* Clicks on a litegraph context menu item (uses .litemenu-entry selector).
* Use this for canvas/node context menus, not PrimeVue menus.
*/
async clickLitegraphContextMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
await this.nextFrame()
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
*
* This method uses the actual slot positions from the subgraph.inputs array,
* which contain the correct coordinates for each input slot. These positions
* are different from the visual node positions and are specifically where
* the slots are rendered on the input node.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries all available input slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetInputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the input node
const inputNode = currentGraph.inputNode
if (!inputNode) {
throw new Error('No input node found in subgraph')
}
// Get available inputs
const inputs = currentGraph.inputs
if (!inputs || inputs.length === 0) {
throw new Error('No input slots found in subgraph')
}
// Filter to specific input if requested
const inputsToTry = targetInputName
? inputs.filter((inp) => inp.name === targetInputName)
: inputs
if (inputsToTry.length === 0) {
throw new Error(
targetInputName
? `Input slot '${targetInputName}' not found`
: 'No input slots available to try'
)
}
// Try right-clicking on each input slot position until one works
for (const input of inputsToTry) {
if (!input.pos) continue
const testX = input.pos[0]
const testY = input.pos[1]
// Create a right-click event at the input slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the input node's right-click handler
if (inputNode.onPointerDown) {
inputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, inputName: input.name, x: testX, y: testY }
}
}
return { success: false }
}, inputName)
if (!foundSlot.success) {
throw new Error(
inputName
? `Could not open context menu for input slot '${inputName}'`
: 'Could not find any input slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
}
/**
* Right-clicks on a subgraph output slot to open the context menu.
* Must be called when inside a subgraph.
*
* Similar to rightClickSubgraphInputSlot but for output slots.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries all available output slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the output node
const outputNode = currentGraph.outputNode
if (!outputNode) {
throw new Error('No output node found in subgraph')
}
// Get available outputs
const outputs = currentGraph.outputs
if (!outputs || outputs.length === 0) {
throw new Error('No output slots found in subgraph')
}
// Filter to specific output if requested
const outputsToTry = targetOutputName
? outputs.filter((out) => out.name === targetOutputName)
: outputs
if (outputsToTry.length === 0) {
throw new Error(
targetOutputName
? `Output slot '${targetOutputName}' not found`
: 'No output slots available to try'
)
}
// Try right-clicking on each output slot position until one works
for (const output of outputsToTry) {
if (!output.pos) continue
const testX = output.pos[0]
const testY = output.pos[1]
// Create a right-click event at the output slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the output node's right-click handler
if (outputNode.onPointerDown) {
outputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, outputName: output.name, x: testX, y: testY }
}
}
return { success: false }
}, outputName)
if (!foundSlot.success) {
throw new Error(
outputName
? `Could not open context menu for output slot '${outputName}'`
: 'Could not find any output slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
}
/**
* Get a reference to a subgraph input slot
*/
async getSubgraphInputSlot(
slotName?: string
): Promise<SubgraphSlotReference> {
return new SubgraphSlotReference('input', slotName || '', this)
}
/**
* Get a reference to a subgraph output slot
*/
async getSubgraphOutputSlot(
slotName?: string
): Promise<SubgraphSlotReference> {
return new SubgraphSlotReference('output', slotName || '', this)
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
*/
async connectToSubgraphInput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetInputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = await this.getSubgraphInputSlot(targetInputName)
const targetPosition = targetInputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
await this.nextFrame()
}
/**
* Connect a subgraph input to a regular node input.
* This creates a new input slot on the subgraph if sourceInputName is not provided.
*/
async connectFromSubgraphInput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceInputName?: string
): Promise<void> {
const sourceSlot = await this.getSubgraphInputSlot(sourceInputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceInputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
const targetPosition = await targetSlot.getPosition()
// Debug: Log the positions we're trying to use
console.log('Drag positions:', {
source: sourcePosition,
target: targetPosition
})
await this.dragAndDrop(sourcePosition, targetPosition)
await this.nextFrame()
}
/**
* Connect a regular node output to a subgraph output.
* This creates a new output slot on the subgraph if targetOutputName is not provided.
*/
async connectToSubgraphOutput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetOutputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = await this.getSubgraphOutputSlot(targetOutputName)
const targetPosition = targetOutputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
await this.nextFrame()
}
/**
* Connect a subgraph output to a regular node input.
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
*/
async connectFromSubgraphOutput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceOutputName?: string
): Promise<void> {
const sourceSlot = await this.getSubgraphOutputSlot(sourceOutputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceOutputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(sourcePosition, await targetSlot.getPosition())
await this.nextFrame()
}
/**
* Add a visual marker at a position for debugging
*/
async debugAddMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
// Remove existing marker if present
const existing = document.getElementById(markerId)
if (existing) existing.remove()
// Create marker
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
/**
* Remove debug markers
*/
async debugRemoveMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
/**
* Take a screenshot and attach it to the test report for debugging
* This is a convenience method that combines screenshot capture and test attachment
*
* @param testInfo The Playwright TestInfo object (from test parameters)
* @param name Name for the attachment
* @param options Optional screenshot options (defaults to page screenshot)
*/
async debugAttachScreenshot(
testInfo: any,
name: string,
options?: {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
): Promise<void> {
// Add markers if requested
if (options?.markers) {
for (const marker of options.markers) {
await this.debugAddMarker(marker.position, marker.id)
}
}
// Take screenshot - default to page if not specified
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
// Attach to test report
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
// Clean up markers if we added any
if (options?.markers) {
await this.debugRemoveMarkers()
}
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
/**
* Capture the canvas as a PNG and save it for debugging
*/
async debugSaveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
// Convert canvas to blob
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
// Create a download link and trigger it
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
// Wait a bit for the download to process
await this.page.waitForTimeout(500)
}
/**
* Capture canvas as base64 data URL for inspection
*/
async debugGetCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
/**
* Create an overlay div with the canvas image for easier Playwright screenshot
*/
async debugShowCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
// Remove existing overlay if present
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
// Create overlay div
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
// Create image from canvas
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
/**
* Remove the debug canvas overlay
*/
async debugHideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
async clickEmptyLatentNode() {
await this.canvas.click({
position: {

View File

@@ -12,128 +12,6 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => {
}
}
export class SubgraphSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly slotName: string,
readonly comfyPage: ComfyPage
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type, slotName]) => {
const currentGraph = window['app'].canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!slots || slots.length === 0) {
throw new Error(`No ${type} slots found in subgraph`)
}
// Find the specific slot or use the first one if no name specified
const slot = slotName
? slots.find((s) => s.name === slotName)
: slots[0]
if (!slot) {
throw new Error(`${type} slot '${slotName}' not found`)
}
if (!slot.pos) {
throw new Error(`${type} slot '${slotName}' has no position`)
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slot.pos[0],
slot.pos[1]
])
return canvasPos
},
[this.type, this.slotName] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getOpenSlotPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type]) => {
const currentGraph = window['app'].canvas.graph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${type} node found in subgraph`)
}
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slotX,
slotY
])
return canvasPos
},
[this.type] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
@@ -143,27 +21,11 @@ export class NodeSlotReference {
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const rawPos = node.getConnectionPos(type === 'input', index)
const convertedPos =
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float32Arrays to regular arrays for visibility
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{
nodePos: [node.pos[0], node.pos[1]],
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window['app'].canvas.graph.constructor.name
}
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
return convertedPos
},
[this.type, this.node.id, this.index] as const
)
@@ -175,7 +37,7 @@ export class NodeSlotReference {
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
@@ -188,7 +50,7 @@ export class NodeSlotReference {
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
@@ -213,7 +75,7 @@ export class NodeWidgetReference {
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
@@ -272,7 +134,7 @@ export class NodeWidgetReference {
const pos = await this.getPosition()
const canvas = this.node.comfyPage.canvas
const canvasPos = (await canvas.boundingBox())!
await this.node.comfyPage.dragAndDrop(
this.node.comfyPage.dragAndDrop(
{
x: canvasPos.x + pos.x,
y: canvasPos.y + pos.y
@@ -304,7 +166,7 @@ export class NodeReference {
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
@@ -323,7 +185,7 @@ export class NodeReference {
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
@@ -356,7 +218,7 @@ export class NodeReference {
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
@@ -397,8 +259,7 @@ export class NodeReference {
await this.comfyPage.canvas.click({
...options,
position: clickPos,
force: true
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
@@ -458,18 +319,6 @@ export class NodeReference {
}
return nodes[0]
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(256)
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
if (nodes.length !== 1) {
throw new Error(
`Did not find single subgraph node (found=${nodes.length})`
)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
@@ -478,58 +327,4 @@ export class NodeReference {
this.comfyPage.page.locator('.comfy-group-manage')
)
}
async navigateIntoSubgraph() {
const titleHeight = await this.comfyPage.page.evaluate(() => {
return window['LiteGraph']['NODE_TITLE_HEIGHT']
})
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
// Try multiple positions to avoid DOM widget interference
const clickPositions = [
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
let isInSubgraph = false
let attempts = 0
const maxAttempts = 3
while (!isInSubgraph && attempts < maxAttempts) {
attempts++
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 50, y: 50 },
force: true
})
await this.comfyPage.nextFrame()
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(500)
// Check if we successfully entered the subgraph
isInSubgraph = await this.comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
if (isInSubgraph) break
}
if (!isInSubgraph && attempts < maxAttempts) {
await this.comfyPage.page.waitForTimeout(500)
}
}
if (!isInSubgraph) {
throw new Error(
'Failed to navigate into subgraph after ' + attempts + ' attempts'
)
}
}
}

View File

@@ -27,21 +27,6 @@ test.describe('Node search box', () => {
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test('New user (1.24.1+) gets search box by default on link release', async ({
comfyPage
}) => {
// Start fresh to test new user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate new user with 1.24.1+ installed version
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add node', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
@@ -187,10 +172,10 @@ test.describe('Node search box', () => {
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
// Verify the filter selection panel is hidden
await expect(panel.header).not.toBeVisible()
expect(panel.header).not.toBeVisible()
// Verify the node search dialog is still visible
await expect(comfyPage.searchBox.input).toBeVisible()
expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add multiple filters', async ({ comfyPage }) => {
@@ -279,38 +264,4 @@ test.describe('Release context menu', () => {
'link-context-menu-search.png'
)
})
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
comfyPage
}) => {
// Start fresh to test existing user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate existing user with pre-1.24.1 version
await comfyPage.setSetting('Comfy.InstalledVersion', '1.23.0')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.disconnectEdge()
// Context menu should appear, search box should not
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
test('Explicit setting overrides versioned defaults', async ({
comfyPage
}) => {
// Start fresh and simulate new user who should get search box by default
await comfyPage.setup({ clearStorage: true })
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
// But explicitly set to context menu (overriding versioned default)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.disconnectEdge()
// Context menu should appear due to explicit setting, not search box
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
})

View File

@@ -1,463 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
promptDialog: '.graphdialog input',
nodeSearchContainer: '.node-search-container',
domWidget: '.comfy-multiline-input'
} as const
test.describe('Subgraph Operations', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// Helper to get subgraph slot count
async function getSubgraphSlotCount(
comfyPage: typeof test.prototype.comfyPage,
type: 'inputs' | 'outputs'
): Promise<number> {
return await comfyPage.page.evaluate((slotType) => {
return window['app'].canvas.graph[slotType]?.length || 0
}, type)
}
// Helper to get current graph node count
async function getGraphNodeCount(
comfyPage: typeof test.prototype.comfyPage
): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes?.length || 0
})
}
// Helper to verify we're in a subgraph
async function isInSubgraph(
comfyPage: typeof test.prototype.comfyPage
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
}
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphInputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphOutputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can rename I/O slots', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
const initialNodeCount = await getGraphNodeCount(comfyPage)
await comfyPage.ctrlA()
await comfyPage.nextFrame()
const node = await comfyPage.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await getGraphNodeCount(comfyPage)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
})
test.describe('Operations Inside Subgraphs', () => {
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await getGraphNodeCount(comfyPage)
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window['app'].canvas.graph.nodes
return nodes?.[0]?.id || null
})
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await getGraphNodeCount(comfyPage)
// Undo
await comfyPage.ctrlZ()
await comfyPage.nextFrame()
const afterUndoCount = await getGraphNodeCount(comfyPage)
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.ctrlY()
await comfyPage.nextFrame()
const afterRedoCount = await getGraphNodeCount(comfyPage)
expect(afterRedoCount).toBe(initialCount)
})
})
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10
},
delay: 5
})
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
await comfyPage.nextFrame()
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.rightClickSubgraphInputSlot('text')
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
await comfyPage.page.waitForTimeout(200)
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb).first()
await breadcrumb.click()
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(300)
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-multiple-promoted-widgets')
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
})
})

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph Breadcrumb Title Sync', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
// Load a workflow with subgraphs
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
// Get the subgraph node by ID (node 10 is the subgraph)
const subgraphNode = await comfyPage.getNodeRefById('10')
// Get node position and double-click on it to enter the subgraph
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2 + 10
}
})
await comfyPage.nextFrame()
// Wait for breadcrumb to appear
await comfyPage.page.waitForSelector('.subgraph-breadcrumb', {
state: 'visible',
timeout: 20000
})
// Get initial breadcrumb text
const breadcrumb = comfyPage.page.locator('.subgraph-breadcrumb')
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back to main graph
await comfyPage.page.keyboard.press('Escape')
// Double-click on the title area of the subgraph node to edit
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10 // Title area is above the node body
},
delay: 5
})
// Wait for title editor to appear
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
// Clear existing text and type new title
await comfyPage.page.keyboard.press('Control+a')
const newTitle = 'Updated Subgraph Title'
await comfyPage.page.keyboard.type(newTitle)
await comfyPage.page.keyboard.press('Enter')
// Wait a frame for the update to complete
await comfyPage.nextFrame()
// Enter the subgraph again
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2
},
delay: 5
})
// Wait for breadcrumb
await comfyPage.page.waitForSelector('.subgraph-breadcrumb')
// Check that breadcrumb now shows the new title
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(newTitle)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})

View File

@@ -48,7 +48,7 @@ test.describe('Combo text widget', () => {
await comfyPage.page.keyboard.press('r')
// Wait for nodes' widgets to be updated
await comfyPage.page.waitForTimeout(500)
await comfyPage.nextFrame()
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.2",
"version": "1.24.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.2",
"version": "1.24.1",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.16",
"@comfyorg/litegraph": "^0.16.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -949,9 +949,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.16.16",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.16.tgz",
"integrity": "sha512-6kfTpu6Z7gF0OxSypUsGM7Ggq7JYP/4dhHGhzA3p+C8tak/2dMuQDiOtc5YXRlcYNEk79A/xekpojGC/DtO6iw==",
"version": "0.16.9",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.9.tgz",
"integrity": "sha512-ZsvqkLqdG65e2UyM8oTOUTv/7VFEyGbG/C9dCZnhxdNq30UaE+F0iLaKq/17u6w4yewyZuqIn5MoOtjpxPqLDQ==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.24.2",
"version": "1.24.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -77,7 +77,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.16",
"@comfyorg/litegraph": "^0.16.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -0,0 +1,130 @@
<template>
<div
class="inline-flex items-center justify-center"
:style="{ width: size + 'px', height: size + 'px' }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 14 14"
fill="none"
class="animate-spin"
:style="{ animationDuration: duration }"
>
<g clip-path="url(#clip0_776_9582)">
<!-- Top dot -->
<path
class="dot-animation"
style="animation-delay: 0s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7 2.21053C7.61042 2.21053 8.10526 1.71568 8.10526 1.10526C8.10526 0.494843 7.61042 0 7 0C6.38958 0 5.89474 0.494843 5.89474 1.10526C5.89474 1.71568 6.38958 2.21053 7 2.21053Z"
:fill="color"
/>
<!-- Left dot -->
<path
class="dot-animation"
style="animation-delay: 0.25s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.21053 7C2.21053 7.61042 1.71568 8.10526 1.10526 8.10526C0.494843 8.10526 0 7.61042 0 7C0 6.38958 0.494843 5.89474 1.10526 5.89474C1.71568 5.89474 2.21053 6.38958 2.21053 7Z"
:fill="color"
/>
<!-- Right dot -->
<path
class="dot-animation"
style="animation-delay: 0.5s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 7C14 7.61042 13.5052 8.10526 12.8947 8.10526C12.2843 8.10526 11.7895 7.61042 11.7895 7C11.7895 6.38958 12.2843 5.89474 12.8947 5.89474C13.5052 5.89474 14 6.38958 14 7Z"
:fill="color"
/>
<!-- Bottom dot -->
<path
class="dot-animation"
style="animation-delay: 0.75s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.10526 12.8947C8.10526 13.5052 7.61041 14 6.99999 14C6.38957 14 5.89473 13.5052 5.89473 12.8947C5.89473 12.2843 6.38957 11.7895 6.99999 11.7895C7.61041 11.7895 8.10526 12.2843 8.10526 12.8947Z"
:fill="color"
/>
<!-- Top-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.125s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 3.61349C2.48203 4.04513 3.18184 4.04513 3.61347 3.61349C4.0451 3.18186 4.0451 2.48205 3.61347 2.05042C3.18184 1.61878 2.48203 1.61878 2.05039 2.05042C1.61876 2.48205 1.61876 3.18186 2.05039 3.61349Z"
:fill="color"
/>
<!-- Bottom-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.625s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 11.9496C11.518 12.3812 10.8182 12.3812 10.3865 11.9496C9.9549 11.5179 9.9549 10.8181 10.3865 10.3865C10.8182 9.95485 11.518 9.95485 11.9496 10.3865C12.3812 10.8181 12.3812 11.5179 11.9496 11.9496Z"
:fill="color"
/>
<!-- Bottom-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.875s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 11.9496C2.48203 12.3812 3.18184 12.3812 3.61347 11.9496C4.0451 11.5179 4.0451 10.8181 3.61347 10.3865C3.18184 9.95485 2.48203 9.95485 2.05039 10.3865C1.61876 10.8181 1.61876 11.5179 2.05039 11.9496Z"
:fill="color"
/>
<!-- Top-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.375s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 3.61349C11.518 4.04513 10.8182 4.04513 10.3865 3.61349C9.9549 3.18186 9.9549 2.48205 10.3865 2.05042C10.8182 1.61878 11.518 1.61878 11.9496 2.05042C12.3812 2.48205 12.3812 3.18186 11.9496 3.61349Z"
:fill="color"
/>
</g>
<defs>
<clipPath id="clip0_776_9582">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const { size = 24, duration = '2s' } = defineProps<{
size?: number
duration?: string
}>()
const colorPaletteStore = useColorPaletteStore()
const color = computed(() =>
colorPaletteStore.completedActivePalette.light_theme ? '#2C2B30' : '#D4D4D4'
)
</script>
<style scoped>
.dot-animation {
animation: dot-pulse 1s ease-in-out infinite;
}
@keyframes dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@@ -31,7 +31,7 @@
</div>
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<div v-if="!isLegacyManager" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
@@ -45,14 +45,12 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -60,22 +58,11 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const isLegacyManager = ref(false)
const uniqueNodes = computed(() => {
const seenTypes = new Set()
@@ -103,6 +90,13 @@ const openManager = () => {
initialTab: ManagerTab.Missing
})
}
onMounted(async () => {
const isLegacyResponse = await useComfyManagerService().isLegacyManagerUI()
if (isLegacyResponse?.is_legacy_manager_ui) {
isLegacyManager.value = true
}
})
</script>
<style scoped>

View File

@@ -30,11 +30,20 @@ const defaultMockTaskLogs = [
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))

View File

@@ -18,7 +18,7 @@
'max-h-0': !isExpanded
}"
>
<div v-for="(panel, index) in taskPanels" :key="index">
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] || false"
toggleable
@@ -27,7 +27,7 @@
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
@@ -52,24 +52,24 @@
</template>
<div
:ref="
index === taskPanels.length - 1
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
'h-64': index !== focusedLogs.length - 1,
'flex-grow': index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(log, logIndex) in panel.logs"
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
</div>
</div>
</div>
@@ -90,17 +90,23 @@ import {
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const { taskLogs } = useComfyManagerStore()
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
index === comfyManagerStore.managerQueue.historyCount - 1 &&
comfyManagerStore.isLoading
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
@@ -115,7 +121,7 @@ const { y: scrollY } = useScroll(sectionsContainerRef, {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false

View File

@@ -34,6 +34,7 @@
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div

View File

@@ -4,6 +4,16 @@
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
<Tag
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
severity="info"
icon="pi pi-info-circle"
:value="$t('manager.legacyManagerUI')"
class="cursor-help"
:pt="{
root: { class: 'text-xs' }
}"
/>
</div>
</div>
</template>

View File

@@ -6,7 +6,6 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
@@ -120,7 +119,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -134,7 +133,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('toggles the popover when button is clicked', async () => {

View File

@@ -42,8 +42,7 @@ import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
@@ -64,11 +63,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const installedVersion = computed(() => {
if (!nodePack.id) return SelectedVersion.NIGHTLY
if (!nodePack.id) return 'nightly'
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)

View File

@@ -8,7 +8,8 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
@@ -123,8 +124,8 @@ describe('PackVersionSelectorPopover', () => {
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
// Check that special options exist
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
@@ -304,7 +305,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
@@ -325,7 +326,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
})

View File

@@ -69,12 +69,8 @@ import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { isSemVer } from '@/utils/formatUtil'
const { nodePack } = defineProps<{
@@ -92,19 +88,20 @@ const managerStore = useComfyManagerStore()
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersion.LATEST)
const selectedVersion = ref<string>('latest')
onMounted(() => {
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
const initialVersion = getInitialSelectedVersion() ?? 'latest'
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
isSemVer(initialVersion) ? initialVersion : 'nightly'
})
const getInitialSelectedVersion = () => {
if (!nodePack.id) return
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed') return SelectedVersion.NIGHTLY
if (nodePack.publisher?.name === 'Unclaimed')
return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
// If node pack is installed, set selected version to the installed version
if (managerStore.isPackInstalled(nodePack.id))
@@ -143,7 +140,7 @@ const onNodePackChange = async () => {
// Add Latest option
const defaultVersions = [
{
value: SelectedVersion.LATEST,
value: 'latest' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.latestVersion')
}
]
@@ -151,7 +148,7 @@ const onNodePackChange = async () => {
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: SelectedVersion.NIGHTLY,
value: 'nightly' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.nightlyVersion')
})
}
@@ -172,12 +169,16 @@ whenever(
const handleSubmit = async () => {
isQueueing.value = true
if (!nodePack.id) {
throw new Error('Node ID is required for installation')
}
await managerStore.installPack.call({
id: nodePack.id,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: ManagerDatabaseSource.CACHE,
version: selectedVersion.value,
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: selectedVersion.value
})

View File

@@ -28,6 +28,7 @@ import Button from 'primevue/button'
const {
label,
loading = false,
loadingMessage,
fullWidth = false,
variant = 'default'

View File

@@ -15,12 +15,8 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstallPackParams,
ManagerChannel,
SelectedVersion
} from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
@@ -28,37 +24,42 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { isPackEnabled, enablePack, disablePack } = useComfyManagerStore()
const isLoading = ref(false)
const isEnabled = computed(() => isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return SelectedVersion.NIGHTLY
return (
installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
)
})
const handleEnable = () =>
enablePack.call({
const handleEnable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
return enablePack.call({
id: nodePack.id,
version: version.value,
selected_version: version.value,
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
selected_version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: 'default' as InstallPackParams['mode']
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
})
}
const handleDisable = () =>
disablePack({
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
id: nodePack.id,
version: version.value
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
})
}
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
@@ -67,10 +68,16 @@ const handleToggle = async (enable: boolean) => {
if (enable) {
await handleEnable()
} else {
handleDisable()
await handleDisable()
}
isLoading.value = false
}
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
const onToggle = debounce(
(enable: boolean) => {
void handleToggle(enable)
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
</script>

View File

@@ -19,13 +19,9 @@ import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
@@ -43,19 +39,26 @@ const onClick = (): void => {
const managerStore = useComfyManagerStore()
const createPayload = (installItem: NodePack) => {
const createPayload = (
installItem: NodePack
): ManagerComponents['schemas']['InstallPackParams'] => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? SelectedVersion.NIGHTLY
: installItem.latest_version?.version ?? SelectedVersion.LATEST
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
return {
id: installItem.id,
version: versionToInstall,
repository: installItem.repository ?? '',
channel: ManagerChannel.DEV,
mode: ManagerDatabaseSource.CACHE,
selected_version: versionToInstall,
version: versionToInstall
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall
}
}
@@ -65,7 +68,7 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
isInstalling.value = true
// isInstalling.value = true
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)

View File

@@ -15,7 +15,6 @@
<script setup lang="ts">
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
@@ -26,16 +25,16 @@ const { nodePacks } = defineProps<{
const managerStore = useComfyManagerStore()
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version
const uninstallPack = (item: NodePack) => {
if (!item.id) {
throw new Error('Node ID is required for uninstallation')
}
return managerStore.uninstallPack({
id: item.id,
version: item.latest_version?.version ?? ''
})
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))

View File

@@ -0,0 +1,78 @@
<template>
<PackActionButton
v-bind="$attrs"
variant="black"
:label="$t('manager.updateAll')"
:loading="isUpdating"
:loading-message="$t('g.updating')"
@action="updateAllPacks"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const isUpdating = ref<boolean>(false)
const managerStore = useComfyManagerStore()
const createPayload = (updateItem: NodePack) => {
return {
id: updateItem.id!,
version: updateItem.latest_version!.version!
}
}
const updatePack = async (item: NodePack) => {
if (!item.id || !item.latest_version?.version) {
console.warn('Pack missing required id or version:', item)
return
}
await managerStore.updatePack.call(createPayload(item))
}
const updateAllPacks = async () => {
if (!nodePacks?.length) {
console.warn('No packs provided for update')
return
}
isUpdating.value = true
const updatablePacks = nodePacks.filter((pack) =>
managerStore.isPackInstalled(pack.id)
)
if (!updatablePacks.length) {
console.info('No installed packs available for update')
isUpdating.value = false
return
}
console.info(`Starting update of ${updatablePacks.length} packs`)
try {
await Promise.all(updatablePacks.map(updatePack))
managerStore.updatePack.clear()
console.info('All packs updated successfully')
} catch (error) {
console.error('Pack update failed:', error)
console.error(
'Failed packs info:',
updatablePacks.map((p) => p.id)
)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -119,12 +119,20 @@ provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
const isDisabled = ref(false)
const managerStore = useComfyManagerStore()
whenever(isInstalled, () => (isInstalling.value = false))
// Watch the installedPacks object directly (which gets updated from WebSocket)
whenever(
() => managerStore.installedPacksIds,
() => {
const isInstalled = isPackInstalled(nodePack?.id)
isDisabled.value = isInstalled && !isPackEnabled(nodePack?.id)
// Update isInstalling state after installation is complete
if (isInstalling.value && isInstalled) isInstalling.value = false
}
)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined

View File

@@ -33,6 +33,10 @@
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
@@ -65,8 +69,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
@@ -96,6 +103,10 @@ const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } =
useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)

View File

@@ -1,41 +1,44 @@
<template>
<div
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="justify-center text-sm font-bold leading-none">
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<i class="pi pi-check-circle mr-2 text-green-500" />
<span class="leading-none">{{
$t('manager.restartToApplyChanges')
}}</span>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress"
v-if="!isInProgress && !isRestartCompleted"
rounded
outlined
class="px-4 py-2 rounded-md mr-4"
class="mr-4 rounded-md border-2 px-3 text-neutral-600 border-neutral-900 hover:bg-neutral-100 !dark-theme:bg-transparent dark-theme:text-white dark-theme:border-white dark-theme:hover:bg-neutral-800"
@click="handleRestart"
>
{{ $t('g.restart') }}
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
@@ -44,6 +47,7 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
@@ -53,6 +57,7 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
@@ -65,9 +70,10 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useWorkflowService } from '@/services/workflowService'
@@ -77,41 +83,93 @@ import {
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useSettingStore } from '@/stores/settingStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
// State management for restart process
const isRestarting = ref<boolean>(false)
const isRestartCompleted = ref<boolean>(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('g.installing')
const fallbackTaskName = t('manager.installingDependencies')
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
const onReconnect = async () => {
// Refresh manager state
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
comfyManagerStore.clearLogs()
comfyManagerStore.setStale()
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
// Refresh node definitions
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
isRestarting.value = true
// Reload workflow
await useWorkflowService().reloadCurrentWorkflow()
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeDialog()
comfyManagerStore.resetTaskState()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
// If restart fails, restore settings and reset state
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeDialog() // Close dialog on error
throw error
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
closeDialog()
}
</script>

View File

@@ -18,16 +18,40 @@
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const activeTabIndex = ref(0)
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value: number) => progressDialogContent.setActiveTabIndex(value)
})
const { t } = useI18n()
const tabs = [
{ label: t('manager.installationQueue') },
{ label: t('manager.failed', { count: 0 }) }
]
const failedCount = computed(() => comfyManagerStore.failedTasksIds.length)
const queueSuffix = computed(() => {
const queueLength = comfyManagerStore.managerQueue.queueLength
if (queueLength === 0) {
return ''
}
return ` (${queueLength})`
})
const failedSuffix = computed(() => {
if (failedCount.value === 0) {
return ''
}
return ` (${failedCount.value})`
})
const tabs = computed(() => [
{ label: t('manager.installationQueue') + queueSuffix.value },
{ label: t('manager.failed') + failedSuffix.value }
])
</script>

View File

@@ -11,6 +11,7 @@
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
@@ -20,35 +21,24 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
const node = widget.node as LGraphNode
// Early exit for non-visible widgets
if (!widget.isVisible()) {
widgetState.visible = false
continue
}
// Check if the widget's node is in the current graph
const node = widget.node
const isInCorrectGraph = currentGraph?.nodes.includes(node)
widgetState.visible =
!!isInCorrectGraph &&
const visible =
lgCanvas.isNodeVisible(node) &&
!(widget.options.hideOnZoom && lowQuality)
!(widget.options.hideOnZoom && lowQuality) &&
widget.isVisible()
if (widgetState.visible && node) {
widgetState.visible = visible
if (visible) {
const margin = widget.margin
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [

View File

@@ -72,6 +72,7 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -191,26 +192,22 @@ watch(
}
)
// Update the progress of executing nodes
// Update the progress of the executing node
watch(
() =>
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
const progressState = nodeLocationProgressStates[nodeLocatorId]
if (progressState && progressState.state === 'running') {
node.progress = progressState.value / progressState.max
[
executionStore.executingNodeId,
executionStore.executingNodeProgress
] satisfies [NodeId | null, number | null],
([executingNodeId, executingNodeProgress]) => {
for (const node of comfyApp.graph.nodes) {
if (node.id == executingNodeId) {
node.progress = executingNodeProgress ?? undefined
} else {
node.progress = undefined
}
}
// Force canvas redraw to ensure progress updates are visible
canvas.graph.setDirtyCanvas(true, false)
},
{ deep: true }
}
)
// Update node slot errors

View File

@@ -19,7 +19,7 @@
<script setup lang="ts">
import { useElementBounding, useEventListener } from '@vueuse/core'
import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
@@ -61,13 +61,10 @@ const updateDomClipping = () => {
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
if (!selectedNode) {
// Clear clipping when no node is selected
updateClipPath(widgetElement.value, lgCanvas.canvas, false, undefined)
return
}
if (!selectedNode) return
const isSelected = selectedNode === widget.node
const node = widget.node
const isSelected = selectedNode === node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -125,10 +122,7 @@ watch(
}
)
// Set up event listeners only after the widget is mounted and visible
const setupDOMEventListeners = () => {
if (!isDOMWidget(widget) || !widgetState.visible) return
if (isDOMWidget(widget)) {
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
@@ -146,46 +140,14 @@ const setupDOMEventListeners = () => {
}
}
// Set up event listeners when widget becomes visible
watch(
() => widgetState.visible,
(visible) => {
if (visible) {
setupDOMEventListeners()
}
},
{ immediate: true }
)
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
// Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => {
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
// Only append if not already a child
if (!widgetElement.value.contains(widget.element)) {
widgetElement.value.appendChild(widget.element)
}
}
}
// Check on mount - but only after next tick to ensure visibility is calculated
onMounted(() => {
nextTick(() => {
mountElementIfVisible()
}).catch((error) => {
console.error('Error mounting DOM widget element:', error)
})
})
// And watch for visibility changes
watch(
() => widgetState.visible,
() => {
mountElementIfVisible()
if (isDOMWidget(widget) && widgetElement.value) {
widgetElement.value.appendChild(widget.element)
}
)
})
</script>
<style scoped>

View File

@@ -20,44 +20,33 @@ import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
const props = defineProps<{
defineProps<{
widget?: object
nodeId: NodeId
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let parentNodeId: NodeId | null = null
let executingNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId
executingNodeId = executionStore.executingNodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
[() => executionStore.executingNode, () => executionStore.isIdle],
() => {
if (executionStore.isIdle) {
isParentNodeExecuting.value = false
stopWatching()
return
}
// Check if parent node is no longer in the executing nodes list
if (
parentNodeId &&
!executionStore.executingNodeIds.includes(parentNodeId)
executionStore.isIdle ||
(executionStore.executingNode &&
executionStore.executingNode.id !== executingNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
// Set parent node ID if not set yet
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
parentNodeId = executionStore.executingNodeIds[0]
if (!executingNodeId) {
executingNodeId = executionStore.executingNodeId
}
}
)

View File

@@ -55,6 +55,7 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
@export-model="handleExportModel"
/>
<div
@@ -214,6 +215,30 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
node.properties['Background Image'] = backgroundImage.value
}
const handleUploadTexture = async (file: File) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const texturePath = await Load3dUtils.uploadFile(file, subfolder)
await load3DSceneRef.value.load3d.applyTexture(texturePath)
node.properties['Texture'] = texturePath
} catch (error) {
console.error('Error applying texture:', error)
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
}
}
const handleUpdateFOV = (value: number) => {
fov.value = value

View File

@@ -51,6 +51,7 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
/>
<CameraControls
@@ -182,6 +183,7 @@ const emit = defineEmits<{
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
(e: 'uploadTexture', file: File): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -230,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUploadTexture = (file: File) => {
emit('uploadTexture', file)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}

View File

@@ -69,6 +69,9 @@ const eventConfig = {
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
textureLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
recordingStatusChange: (value: boolean) =>
emit('recordingStatusChange', value)
} as const

View File

@@ -59,6 +59,32 @@
</div>
</div>
<div
v-if="
materialMode === 'original' &&
!props.inputSpec.isAnimation &&
!props.inputSpec.isPreview
"
class="relative show-texture-upload"
>
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
<i
v-tooltip.right="{
value: t('load3d.uploadTexture'),
showDelay: 300
}"
class="pi pi-image text-white text-lg"
/>
<input
ref="texturePickerRef"
type="file"
accept="image/*"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
@change="uploadTexture"
/>
</Button>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@@ -116,6 +142,7 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'uploadTexture', file: File): void
}>()
const upDirection = ref(props.upDirection || 'original')
@@ -124,6 +151,7 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const texturePickerRef = ref<HTMLInputElement | null>(null)
const upDirections: UpDirection[] = [
'original',
@@ -219,6 +247,18 @@ const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const openTextureUpload = () => {
texturePickerRef.value?.click()
}
const uploadTexture = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('uploadTexture', input.files[0])
}
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -1004,194 +1004,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$2.25/Run'
}
},
// Runway nodes - using actual node names from ComfyUI
RunwayTextToImageNode: {
displayPrice: '$0.08/Run'
},
RunwayImageToVideoNodeGen3a: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
RunwayImageToVideoNodeGen4: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
RunwayFirstLastFrameNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
// Rodin nodes - all have the same pricing structure
Rodin3D_Regular: {
displayPrice: '$0.4/Run'
},
Rodin3D_Detail: {
displayPrice: '$1.2/Run'
},
Rodin3D_Smooth: {
displayPrice: '$1.2/Run'
},
Rodin3D_Sketch: {
displayPrice: '$0.4/Run'
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model' || w.name === 'model_version'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!modelWidget)
return '$0.2-0.3/Run (varies with model & texture quality)'
const model = String(modelWidget.value)
const textureQuality = String(textureQualityWidget?.value || 'standard')
// V2.5 pricing
if (model.includes('v2.5') || model.includes('2.5')) {
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
}
// V2.0 pricing
else if (model.includes('v2.0') || model.includes('2.0')) {
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
}
// V1.4 or legacy pricing
else {
return '$0.2/Run'
}
}
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model' || w.name === 'model_version'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!modelWidget)
return '$0.3-0.4/Run (varies with model & texture quality)'
const model = String(modelWidget.value)
const textureQuality = String(textureQualityWidget?.value || 'standard')
// V2.5 and V2.0 have same pricing structure
if (
model.includes('v2.5') ||
model.includes('2.5') ||
model.includes('v2.0') ||
model.includes('2.0')
) {
return textureQuality.includes('detailed') ? '$0.4/Run' : '$0.3/Run'
}
// V1.4 or legacy pricing (image_to_model is always $0.3)
else {
return '$0.3/Run'
}
}
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!textureQualityWidget) return '$0.1-0.2/Run (varies with quality)'
const textureQuality = String(textureQualityWidget.value)
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.0035/$0.0008 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0015/$0.0004 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o1-pro')) {
return '$0.15/$0.60 per 1K tokens'
} else if (model.includes('o1')) {
return '$0.015/$0.06 per 1K tokens'
} else if (model.includes('o3-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o3')) {
return '$0.01/$0.04 per 1K tokens'
} else if (model.includes('gpt-4o')) {
return '$0.0025/$0.01 per 1K tokens'
} else if (model.includes('gpt-4.1-nano')) {
return '$0.0001/$0.0004 per 1K tokens'
} else if (model.includes('gpt-4.1-mini')) {
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
}
return 'Token-based'
}
}
}
@@ -1263,19 +1075,7 @@ export const useNodePricing = () => {
RecraftGenerateVectorImageNode: ['n'],
MoonvalleyTxt2VideoNode: ['length'],
MoonvalleyImg2VideoNode: ['length'],
MoonvalleyVideo2VideoNode: ['length'],
// Runway nodes
RunwayImageToVideoNodeGen3a: ['duration'],
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['model', 'model_version', 'texture_quality'],
TripoImageToModelNode: ['model', 'model_version', 'texture_quality'],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
// OpenAI nodes
OpenAIChatNode: ['model']
MoonvalleyVideo2VideoNode: ['length']
}
return widgetMap[nodeType] || []
}

View File

@@ -0,0 +1,65 @@
import { computed, onMounted } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
/**
* Composable to find NodePacks that have updates available
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches installed pack data when initialized
*/
export const useUpdateAvailableNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { installedPacks, isLoading, error, startFetchInstalled } =
useInstalledPacks()
// Check if a pack has updates available (same logic as usePackUpdateStatus)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const isInstalled = comfyManagerStore.isPackInstalled(pack?.id)
if (!isInstalled) return false
const installedVersion = comfyManagerStore.getInstalledPackVersion(
pack.id ?? ''
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !isSemVer(installedVersion)
if (isNightlyPack || !latestVersion) {
return false
}
return compareVersions(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
// Filter only outdated packs from installed packs
const updateAvailableNodePacks = computed(() => {
if (!installedPacks.value.length) return []
return filterOutdatedPacks(installedPacks.value)
})
// Check if there are any outdated packs
const hasUpdateAvailable = computed(() => {
return updateAvailableNodePacks.value.length > 0
})
// Automatically fetch installed pack data when composable is used
onMounted(async () => {
if (!installedPacks.value.length && !isLoading.value) {
await startFetchInstalled()
}
})
return {
updateAvailableNodePacks,
hasUpdateAvailable,
isLoading,
error
}
}

View File

@@ -7,7 +7,7 @@ import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
import { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type WorkflowPack = {
@@ -65,8 +65,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ??
SelectedVersion.NIGHTLY
systemStatsStore.systemStats?.system?.comfyui_version ?? 'nightly'
}
}
@@ -76,7 +75,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
if (pack) {
return {
id: pack.id,
version: pack.latest_version?.version ?? SelectedVersion.NIGHTLY
version: pack.latest_version?.version ?? 'nightly'
}
}

View File

@@ -1,7 +1,6 @@
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
@@ -37,34 +36,11 @@ export const useBrowserTabTitle = () => {
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() => {
// Check if any nodes are in progress
const nodeProgressEntries = Object.entries(
executionStore.nodeProgressStates
)
const runningNodes = nodeProgressEntries.filter(
([_, state]) => state.state === 'running'
)
if (runningNodes.length === 0) {
return ''
}
// If multiple nodes are running
if (runningNodes.length > 1) {
return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]`
}
// If only one node is running
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
return `${executionText.value}[${progress}%] ${nodeType}`
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const workflowTitle = computed(
() =>

View File

@@ -15,10 +15,11 @@ import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { type ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
@@ -29,6 +30,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -660,12 +662,54 @@ export function useCoreCommands(): ComfyCommand[] {
}
},
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Toggle the Custom Nodes Manager',
id: 'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
icon: 'pi pi-objects-column',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: async () => {
const { is_legacy_manager_ui } =
(await useComfyManagerService().isLegacyManagerUI()) ?? {}
if (is_legacy_manager_ui === true) {
try {
await useCommandStore().execute(
'Comfy.Manager.Menu.ToggleVisibility' // This command is registered by legacy manager FE extension
)
} catch (error) {
console.error('error', error)
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.showManagerDialog()
}
} else {
dialogService.showManagerDialog()
}
}
},
{
id: 'Comfy.Manager.ShowUpdateAvailablePacks',
icon: 'pi pi-sync',
label: 'Check for Custom Node Updates',
versionAdded: '1.17.0',
function: () => {
dialogService.toggleManagerDialog()
dialogService.showManagerDialog({
initialTab: ManagerTab.UpdateAvailable
})
}
},
{
id: 'Comfy.Manager.ShowMissingPacks',
icon: 'pi pi-exclamation-circle',
label: 'Install Missing Custom Nodes',
versionAdded: '1.17.0',
function: () => {
dialogService.showManagerDialog({
initialTab: ManagerTab.Missing
})
}
},
{
@@ -754,9 +798,88 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
const { node } = res
canvas.select(node)
}
},
{
id: 'Comfy.Manager.CustomNodesManager.ShowLegacyCustomNodesMenu',
icon: 'pi pi-bars',
label: 'Custom Nodes (Legacy)',
versionAdded: '1.16.4',
function: async () => {
try {
await useCommandStore().execute(
'Comfy.Manager.CustomNodesManager.ToggleVisibility'
)
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
}
},
{
id: 'Comfy.Manager.ShowLegacyManagerMenu',
icon: 'mdi mdi-puzzle',
label: 'Manager Menu (Legacy)',
versionAdded: '1.16.4',
function: async () => {
try {
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility')
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
}
},
{
id: 'Comfy.Memory.UnloadModels',
icon: 'mdi mdi-vacuum-outline',
label: 'Unload Models',
versionAdded: '1.16.4',
function: async () => {
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
}),
life: 3000
})
return
}
await api.freeMemory({ freeExecutionCache: false })
}
},
{
id: 'Comfy.Memory.UnloadModelsAndExecutionCache',
icon: 'mdi mdi-vacuum-outline',
label: 'Unload Models and Execution Cache',
versionAdded: '1.16.4',
function: async () => {
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
}),
life: 3000
})
return
}
await api.freeMemory({ freeExecutionCache: true })
}
}
]

View File

@@ -1,101 +1,145 @@
import { useEventListener, whenever } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
import { pickBy } from 'lodash'
import { Ref, computed, ref } from 'vue'
import { api } from '@/scripts/api'
import { ManagerWsQueueStatus } from '@/types/comfyManagerTypes'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { components } from '@/types/generatedManagerTypes'
type QueuedTask<T> = {
task: () => Promise<T>
onComplete?: () => void
}
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
type QueueTaskItem = components['schemas']['QueueTaskItem']
type HistoryTaskItem = components['schemas']['TaskHistoryItem']
type Task = QueueTaskItem | HistoryTaskItem
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
export const useManagerQueue = () => {
const clientQueueItems = ref<QueuedTask<unknown>[]>([])
const clientQueueLength = computed(() => clientQueueItems.value.length)
const onCompletedQueue = ref<((() => void) | undefined)[]>([])
const onCompleteWaitingCount = ref(0)
const uncompletedCount = computed(
() => clientQueueLength.value + onCompleteWaitingCount.value
export const useManagerQueue = (
taskHistory: Ref<ManagerTaskHistory>,
taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>>
) => {
const { showManagerProgressDialog } = useDialogService()
// Task queue state (read-only from server)
const maxHistoryItems = ref(64)
const isLoading = ref(false)
const isProcessing = ref(false)
// Computed values
const currentQueueLength = computed(
() =>
taskQueue.value.running_queue.length +
taskQueue.value.pending_queue.length
)
const serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
const isServerIdle = computed(
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
)
/**
* Update the processing state based on the current queue length.
* If the queue is empty, or all tasks in the queue are associated
* with different clients, then this client is not processing any tasks.
*/
const updateProcessingState = (): void => {
isProcessing.value = currentQueueLength.value > 0
}
const allTasksDone = computed(
() => isServerIdle.value && clientQueueLength.value === 0
)
const nextTaskReady = computed(
() => isServerIdle.value && clientQueueLength.value > 0
)
const allTasksDone = computed(() => currentQueueLength.value === 0)
const historyCount = computed(() => Object.keys(taskHistory.value).length)
const cleanupListener = useEventListener(
api,
MANAGER_WS_MSG_TYPE,
(event: CustomEvent<{ status: ManagerWsQueueStatus }>) => {
if (event?.type === MANAGER_WS_MSG_TYPE && event.detail?.status) {
serverQueueStatus.value = event.detail.status
/**
* Check if a task is associated with this client.
* Task can be from running queue, pending queue, or history.
* @param task - The task to check
* @returns True if the task belongs to this client
*/
const isTaskFromThisClient = (task: Task): boolean =>
task.client_id === app.api.clientId
/**
* Filter queue tasks by client id.
* Ensures that only tasks associated with this client are processed and
* added to client state.
* @param tasks - Array of queue tasks to filter
* @returns Filtered array containing only tasks from this client
*/
const filterQueueByClientId = (tasks: QueueTaskItem[]): QueueTaskItem[] =>
tasks.filter(isTaskFromThisClient)
/**
* Filter history tasks by client id using lodash pickBy for optimal performance.
* Returns a new object containing only tasks associated with this client.
* @param history - The history object to filter
* @returns Filtered history object containing only tasks from this client
*/
const filterHistoryByClientId = (history: ManagerTaskHistory) =>
pickBy(history, isTaskFromThisClient)
/**
* Update task queue and history state with filtered data from server.
* Ensures only tasks from this client are stored in local state.
* @param state - The task state message from the server
*/
const updateTaskState = (state: ManagerTaskQueue) => {
taskQueue.value.running_queue = filterQueueByClientId(state.running_queue)
taskQueue.value.pending_queue = filterQueueByClientId(state.pending_queue)
taskHistory.value = filterHistoryByClientId(state.history)
if (state.installed_packs) {
installedPacks.value = state.installed_packs
}
updateProcessingState()
}
// WebSocket event listener for task done
const cleanupTaskDoneListener = useEventListener(
app.api,
MANAGER_WS_TASK_DONE_NAME,
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME) {
const { state } = event.detail
updateTaskState(state)
}
}
)
const startNextTask = () => {
const nextTask = clientQueueItems.value.shift()
if (!nextTask) return
const { task, onComplete } = nextTask
if (onComplete) {
// Set the task's onComplete to be executed the next time the server is idle
onCompletedQueue.value.push(onComplete)
onCompleteWaitingCount.value++
}
task().catch((e) => {
const message = `Error enqueuing task for ComfyUI Manager: ${e}`
console.error(message)
})
}
const enqueueTask = <T>(task: QueuedTask<T>): void => {
clientQueueItems.value.push(task)
}
const clearQueue = () => {
clientQueueItems.value = []
onCompletedQueue.value = []
onCompleteWaitingCount.value = 0
}
const cleanup = () => {
clearQueue()
cleanupListener()
}
whenever(nextTaskReady, startNextTask)
whenever(isServerIdle, () => {
if (onCompletedQueue.value?.length) {
while (
onCompleteWaitingCount.value > 0 &&
onCompletedQueue.value.length > 0
) {
const onComplete = onCompletedQueue.value.shift()
onComplete?.()
onCompleteWaitingCount.value--
// WebSocket event listener for task started
const cleanupTaskStartedListener = useEventListener(
app.api,
MANAGER_WS_TASK_STARTED_NAME,
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME) {
const { state } = event.detail
updateTaskState(state)
}
}
})
)
whenever(currentQueueLength, () => showManagerProgressDialog())
const stopListening = () => {
cleanupTaskDoneListener()
cleanupTaskStartedListener()
}
return {
allTasksDone,
statusMessage: readonly(serverQueueStatus),
queueLength: clientQueueLength,
uncompletedCount,
// Queue state (read-only from server)
taskHistory,
taskQueue,
maxHistoryItems,
isLoading,
enqueueTask,
clearQueue,
cleanup
// Computed state
allTasksDone,
isProcessing,
queueLength: currentQueueLength,
historyCount,
// Actions
stopListening
}
}

View File

@@ -1,4 +1,5 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { computed, ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
@@ -8,11 +9,12 @@ interface RefreshableItem {
refresh: () => Promise<void> | void
}
const isRefreshableWidget = (widget: unknown): widget is RefreshableItem =>
widget != null &&
typeof widget === 'object' &&
'refresh' in widget &&
typeof widget.refresh === 'function'
type RefreshableWidget = IBaseWidget & RefreshableItem
const isRefreshableWidget = (
widget: IBaseWidget
): widget is RefreshableWidget =>
'refresh' in widget && typeof widget.refresh === 'function'
/**
* Tracks selected nodes and their refreshable widgets
@@ -25,17 +27,10 @@ export const useRefreshableSelection = () => {
selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode)
})
const refreshableWidgets = computed<RefreshableItem[]>(() =>
selectedNodes.value.flatMap((node) => {
if (!node.widgets) return []
const items: RefreshableItem[] = []
for (const widget of node.widgets) {
if (isRefreshableWidget(widget)) {
items.push(widget)
}
}
return items
})
const refreshableWidgets = computed(() =>
selectedNodes.value.flatMap(
(node) => node.widgets?.filter(isRefreshableWidget) ?? []
)
)
const isRefreshable = computed(() => refreshableWidgets.value.length > 0)

View File

@@ -1,24 +1,34 @@
import { useEventListener } from '@vueuse/core'
import { onUnmounted, ref } from 'vue'
import { ref } from 'vue'
import { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { components } from '@/types/generatedManagerTypes'
const LOGS_MESSAGE_TYPE = 'logs'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
interface UseServerLogsOptions {
ui_id: string
immediate?: boolean
messageFilter?: (message: string) => boolean
}
export const useServerLogs = (options: UseServerLogsOptions = {}) => {
export const useServerLogs = (options: UseServerLogsOptions) => {
const {
immediate = false,
messageFilter = (msg: string) => Boolean(msg.trim())
} = options
const logs = ref<string[]>([])
let stop: ReturnType<typeof useEventListener> | null = null
const isTaskStarted = ref(false)
let stopLogs: ReturnType<typeof useEventListener> | null = null
let stopTaskDone: ReturnType<typeof useEventListener> | null = null
let stopTaskStarted: ReturnType<typeof useEventListener> | null = null
const isValidLogEvent = (event: CustomEvent<LogsWsMessage>) =>
event?.type === LOGS_MESSAGE_TYPE && event.detail?.entries?.length > 0
@@ -27,34 +37,81 @@ export const useServerLogs = (options: UseServerLogsOptions = {}) => {
event.detail.entries.map((e) => e.m).filter(messageFilter)
const handleLogMessage = (event: CustomEvent<LogsWsMessage>) => {
// Only capture logs if this task has started
if (!isTaskStarted.value) return
if (isValidLogEvent(event)) {
logs.value.push(...parseLogMessage(event))
const messages = parseLogMessage(event)
if (messages.length > 0) {
logs.value.push(...messages)
}
}
}
const start = async () => {
const handleTaskStarted = (event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME) {
// Check if this is our task starting
const isOurTask = event.detail.ui_id === options.ui_id
if (isOurTask) {
isTaskStarted.value = true
void stopTaskStarted?.()
}
}
}
const handleTaskDone = (event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME) {
const { state } = event.detail
// Check if our task is now in the history (completed)
const isOurTaskDone = state.history[options.ui_id]
if (isOurTaskDone) {
isTaskStarted.value = false
void stopListening()
}
}
}
const startListening = async () => {
await api.subscribeLogs(true)
stop = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
stopLogs = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
stopTaskStarted = useEventListener(
api,
MANAGER_WS_TASK_STARTED_NAME,
handleTaskStarted
)
stopTaskDone = useEventListener(
api,
MANAGER_WS_TASK_DONE_NAME,
handleTaskDone
)
}
const stopListening = async () => {
stop?.()
stop = null
await api.subscribeLogs(false)
stopLogs?.()
stopTaskStarted?.()
stopTaskDone?.()
stopLogs = null
stopTaskStarted = null
stopTaskDone = null
// TODO: move subscribe/unsubscribe logs to useManagerQueue. Subscribe when task starts if not already subscribed.
// Unsubscribe ONLY when there are no tasks running or queued up and the only remaining task finishes.
// await api.subscribeLogs(false)
}
if (immediate) {
void start()
void startListening()
}
onUnmounted(async () => {
const cleanup = async () => {
await stopListening()
logs.value = []
})
isTaskStarted.value = false
}
return {
logs,
startListening: start,
stopListening
startListening,
stopListening,
cleanup
}
}

View File

@@ -3,23 +3,14 @@ import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: ChatHistoryCustomProps
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
@@ -29,7 +20,7 @@ export const useChatHistoryWidget = (
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
ChatHistoryCustomProps
InstanceType<typeof ChatHistoryWidget>['$props']
>({
node,
name: inputSpec.name,

View File

@@ -3,18 +3,9 @@ import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
@@ -27,17 +18,11 @@ export const useTextPreviewWidget = (
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
const widget = new ComponentWidgetImpl<string | object>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {

View File

@@ -1,3 +1,3 @@
{
"supports_preview_metadata": true
"supports_preview_metadata": false
}

View File

@@ -11,9 +11,24 @@ export const CORE_MENU_COMMANDS = [
]
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[
'Comfy.RefreshNodeDefinitions',
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
]
],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[
['Manager'],
[
'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
'Comfy.Manager.ShowMissingPacks',
'Comfy.Manager.ShowUpdateAvailablePacks'
]
],
[
['Help'],
[

View File

@@ -14,6 +14,13 @@ import type { SettingParams } from '@/types/settingTypes'
* when they are no longer needed.
*/
export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.Memory.AllowManualUnload',
name: 'Allow manual unload of models and execution cache via user command',
type: 'hidden',
defaultValue: true,
versionAdded: '1.18.0'
},
{
id: 'Comfy.Validation.Workflows',
name: 'Validate workflows',
@@ -35,10 +42,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Action on link release (No modifier)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
defaultsByInstallVersion: {
'1.24.1': LinkReleaseTriggerAction.SEARCH_BOX
}
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU
},
{
id: 'Comfy.LinkRelease.ActionShift',
@@ -46,10 +50,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Action on link release (Shift)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX,
defaultsByInstallVersion: {
'1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU
}
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX
},
{
id: 'Comfy.NodeSearchBoxImpl.NodePreview',
@@ -890,5 +891,12 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
},
{
id: 'Comfy.Memory.AllowManualUnload',
name: 'Allow manual unload of models and execution cache via user command',
type: 'hidden',
defaultValue: true,
versionAdded: '1.18.0'
}
]

View File

@@ -15,7 +15,6 @@ import {
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
@@ -1225,10 +1224,9 @@ export class GroupNodeHandler {
node.onDrawForeground = function (ctx) {
// @ts-expect-error fixme ts strict error
onDrawForeground?.apply?.(this, arguments)
const progressState = useExecutionStore().nodeProgressStates[this.id]
if (
progressState &&
progressState.state === 'running' &&
// @ts-expect-error fixme ts strict error
+app.runningNodeId === this.id &&
this.runningInternalNodeId !== null
) {
// @ts-expect-error fixme ts strict error
@@ -1342,7 +1340,6 @@ export class GroupNodeHandler {
this.node.onRemoved = function () {
// @ts-expect-error fixme ts strict error
onRemoved?.apply(this, arguments)
// api.removeEventListener('progress_state', progress_state)
api.removeEventListener('executing', executing)
api.removeEventListener('executed', executed)
}

View File

@@ -21,6 +21,8 @@ async function handleModelUpload(files: FileList, node: any) {
(w: any) => w.name === 'model_file'
) as IStringWidget
node.properties['Texture'] = undefined
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
@@ -213,6 +215,8 @@ useExtensionService().registerExtension({
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = ''
node.properties['Texture'] = undefined
}
})

View File

@@ -160,6 +160,12 @@ class Load3DConfiguration {
this.load3d.setEdgeThreshold(edgeThreshold)
const texturePath = this.load3d.loadNodeProperty('Texture', null)
if (texturePath) {
await this.load3d.applyTexture(texturePath)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)

View File

@@ -293,6 +293,23 @@ class Load3d {
}
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.modelManager.currentModel) {
throw new Error('No model to apply texture to')
}
this.eventManager.emitEvent('textureLoadingStart', null)
try {
await this.modelManager.applyTexture(texturePath)
} catch (error) {
console.error('Error applying texture:', error)
throw error
} finally {
this.eventManager.emitEvent('textureLoadingEnd', null)
}
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)

View File

@@ -5,6 +5,7 @@ import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeome
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
import Load3dUtils from './Load3dUtils'
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
@@ -134,6 +135,66 @@ export class ModelManager implements ModelManagerInterface {
})
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.currentModel) {
throw new Error('No model available to apply texture to')
}
if (this.appliedTexture) {
this.appliedTexture.dispose()
}
try {
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(texturePath)
)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl
}
this.appliedTexture = await new Promise<THREE.Texture>(
(resolve, reject) => {
this.textureLoader.load(
imageUrl,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
resolve(texture)
},
undefined,
(error) => reject(error)
)
}
)
if (this.materialMode === 'original') {
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
const material = new THREE.MeshStandardMaterial({
map: this.appliedTexture,
metalness: 0.1,
roughness: 0.8,
side: THREE.DoubleSide
})
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = material
}
})
}
return Promise.resolve()
} catch (error) {
console.error('Error applying texture:', error)
return Promise.reject(error)
}
}
disposeLineartModel(): void {
this.disposeEdgesModel()
this.disposeShadowModel()

View File

@@ -1,7 +1,6 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
@@ -10,8 +9,6 @@ import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
import { NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
@@ -140,27 +137,14 @@ app.registerExtension({
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
audioUIWidget.onRemove = useChainCallback(
audioUIWidget.onRemove,
() => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
}
)
return { widget: audioUIWidget }
}
}
},
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
const node = app.graph.getNodeById(nodeId)
if ('audio' in output) {
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
if (!node) continue
// @ts-expect-error fixme ts strict error
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Toggle the Custom Nodes Manager"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Custom Nodes Manager"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Install Missing"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Check for Updates"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},
"Comfy_Memory_UnloadModels": {
"label": "Unload Models"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Unload Models and Execution Cache"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -134,10 +134,13 @@
"releaseTitle": "{package} {version} Release",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"nodesRunning": "nodes running"
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information."
},
"manager": {
"title": "Custom Nodes Manager",
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
"legacyManagerUI": "Use Legacy UI",
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
"failed": "Failed ({count})",
"noNodesFound": "No nodes found",
"noNodesFoundDescription": "The pack's nodes either could not be parsed, or the pack is a frontend extension only and doesn't have any nodes.",
@@ -914,6 +917,7 @@
"menuLabels": {
"Workflow": "Workflow",
"Edit": "Edit",
"Manager": "Manager",
"Help": "Help",
"Check for Updates": "Check for Updates",
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
@@ -969,6 +973,14 @@
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Custom Nodes (Beta)": "Custom Nodes (Beta)",
"Custom Nodes (Legacy)": "Custom Nodes (Legacy)",
"Manager Menu (Legacy)": "Manager Menu (Legacy)",
"Custom Nodes Manager": "Custom Nodes Manager",
"Install Missing": "Install Missing",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Cargar flujo de trabajo predeterminado"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Administrador de nodos personalizados"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Nodos personalizados (Beta)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Instalar faltantes"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Buscar actualizaciones"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Alternar diálogo de progreso del administrador"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Abrir editor de máscara para el nodo seleccionado"
},
"Comfy_Memory_UnloadModels": {
"label": "Descargar modelos"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Descargar modelos y caché de ejecución"
},
"Comfy_NewBlankWorkflow": {
"label": "Nuevo flujo de trabajo en blanco"
},

View File

@@ -270,6 +270,7 @@
"color": "Color",
"comingSoon": "Próximamente",
"command": "Comando",
"commandProhibited": "El comando {command} está prohibido. Contacta a un administrador para más información.",
"community": "Comunidad",
"completed": "Completado",
"confirm": "Confirmar",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "No hay tareas en la cola.",
"noWorkflowsFound": "No se encontraron flujos de trabajo.",
"nodes": "Nodos",
"nodesRunning": "nodos en ejecución",
"ok": "OK",
"openNewIssue": "Abrir nuevo problema",
"overwrite": "Sobrescribir",
@@ -629,6 +629,9 @@
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
"latestVersion": "Última",
"legacyManagerUI": "Usar UI antigua",
"legacyManagerUIDescription": "Para usar la UI antigua del Manager, inicia ComfyUI con --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "El menú del administrador antiguo no está disponible en esta versión de ComfyUI. Por favor, utiliza el nuevo menú del administrador en su lugar.",
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"nightlyVersion": "Nocturna",
@@ -735,6 +738,7 @@
"Contact Support": "Contactar soporte",
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes Manager": "Administrador de Nodos Personalizados",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
@@ -746,6 +750,7 @@
"Give Feedback": "Dar retroalimentación",
"Group Selected Nodes": "Agrupar nodos seleccionados",
"Help": "Ayuda",
"Install Missing": "Instalar Faltantes",
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
@@ -753,6 +758,7 @@
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
"Manager": "Administrador",
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
@@ -797,6 +803,8 @@
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Unload Models": "Descargar modelos",
"Unload Models and Execution Cache": "Descargar modelos y caché de ejecución",
"Workflow": "Flujo de trabajo",
"Zoom In": "Acercar",
"Zoom Out": "Alejar"

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Charger le flux de travail par défaut"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Gestionnaire de Nœuds Personnalisés"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Nœuds personnalisés (Beta)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Installer manquants"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Vérifier les mises à jour"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Basculer la boîte de dialogue de progression"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Ouvrir l'éditeur de masque pour le nœud sélectionné"
},
"Comfy_Memory_UnloadModels": {
"label": "Décharger les modèles"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Décharger les modèles et le cache d'exécution"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -270,6 +270,7 @@
"color": "Couleur",
"comingSoon": "Bientôt disponible",
"command": "Commande",
"commandProhibited": "La commande {command} est interdite. Contactez un administrateur pour plus d'informations.",
"community": "Communauté",
"completed": "Terminé",
"confirm": "Confirmer",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
"noWorkflowsFound": "Aucun flux de travail trouvé.",
"nodes": "Nœuds",
"nodesRunning": "nœuds en cours dexécution",
"ok": "OK",
"openNewIssue": "Ouvrir un nouveau problème",
"overwrite": "Écraser",
@@ -629,6 +629,9 @@
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
"latestVersion": "Dernière",
"legacyManagerUI": "Utiliser l'interface utilisateur héritée",
"legacyManagerUIDescription": "Pour utiliser l'interface utilisateur de gestion héritée, démarrez ComfyUI avec --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "Le menu du gestionnaire de l'ancienne version n'est pas disponible dans cette version de ComfyUI. Veuillez utiliser le nouveau menu du gestionnaire à la place.",
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"nightlyVersion": "Nocturne",
@@ -722,7 +725,7 @@
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",
"Canvas Toggle Link Visibility": "Basculer la visibilité du lien de la toile",
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
"Check for Updates": "Vérifier les mises à jour",
"Check for Updates": "Vérifier les Mises à Jour",
"Clear Pending Tasks": "Effacer les tâches en attente",
"Clear Workflow": "Effacer le flux de travail",
"Clipspace": "Espace de clip",
@@ -735,6 +738,7 @@
"Contact Support": "Contacter le support",
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -746,6 +750,7 @@
"Give Feedback": "Donnez votre avis",
"Group Selected Nodes": "Grouper les nœuds sélectionnés",
"Help": "Aide",
"Install Missing": "Installer Manquants",
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
@@ -753,6 +758,7 @@
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
"Manager": "Gestionnaire",
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
@@ -797,6 +803,8 @@
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Unload Models": "Décharger les modèles",
"Unload Models and Execution Cache": "Décharger les modèles et le cache d'exécution",
"Workflow": "Flux de travail",
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière"

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "デフォルトのワークフローを読み込む"
},
"Comfy_Manager_CustomNodesManager": {
"label": "カスタムノードマネージャ"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "カスタムノード(ベータ版)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "不足しているパックをインストール"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "更新を確認"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "選択したノードのマスクエディタを開く"
},
"Comfy_Memory_UnloadModels": {
"label": "モデルのアンロード"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "モデルと実行キャッシュのアンロード"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -270,6 +270,7 @@
"color": "色",
"comingSoon": "近日公開",
"command": "コマンド",
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",
"community": "コミュニティ",
"completed": "完了",
"confirm": "確認",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "キューにタスクがありません。",
"noWorkflowsFound": "ワークフローが見つかりません。",
"nodes": "ノード",
"nodesRunning": "ノードが実行中",
"ok": "OK",
"openNewIssue": "新しい問題を開く",
"overwrite": "上書き",
@@ -629,6 +629,9 @@
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
"latestVersion": "最新",
"legacyManagerUI": "レガシーUIを使用する",
"legacyManagerUIDescription": "レガシーManager UIを使用するには、--enable-manager-legacy-uiを付けてComfyUIを起動してください",
"legacyMenuNotAvailable": "このバージョンのComfyUIでは、レガシーマネージャーメニューは利用できません。新しいマネージャーメニューを使用してください。",
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"nightlyVersion": "ナイトリー",
@@ -722,7 +725,7 @@
"Bypass/Unbypass Selected Nodes": "選択したノードのバイパス/バイパス解除",
"Canvas Toggle Link Visibility": "キャンバスのリンク表示を切り替え",
"Canvas Toggle Lock": "キャンバスのロックを切り替え",
"Check for Updates": "更新を確認する",
"Check for Updates": "更新を確認",
"Clear Pending Tasks": "保留中のタスクをクリア",
"Clear Workflow": "ワークフローをクリア",
"Clipspace": "クリップスペース",
@@ -735,6 +738,7 @@
"Contact Support": "サポートに連絡",
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -746,6 +750,7 @@
"Give Feedback": "フィードバックを送る",
"Group Selected Nodes": "選択したノードをグループ化",
"Help": "ヘルプ",
"Install Missing": "不足しているものをインストール",
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
@@ -753,6 +758,7 @@
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
"Move Selected Nodes Up": "選択したノードを上へ移動",
"Manager": "マネージャー",
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
@@ -797,6 +803,8 @@
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Unload Models": "モデルのアンロード",
"Unload Models and Execution Cache": "モデルと実行キャッシュのアンロード",
"Workflow": "ワークフロー",
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "기본 워크플로 로드"
},
"Comfy_Manager_CustomNodesManager": {
"label": "사용자 정의 노드 관리자"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "사용자 정의 노드 (베타)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "누락된 팩 설치"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "업데이트 확인"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "진행 상황 대화 상자 전환"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "선택한 노드 마스크 편집기 열기"
},
"Comfy_Memory_UnloadModels": {
"label": "모델 언로드"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "모델 및 실행 캐시 언로드"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -270,6 +270,7 @@
"color": "색상",
"comingSoon": "곧 출시 예정",
"command": "명령",
"commandProhibited": "명령 {command}은 금지되었습니다. 자세한 정보는 관리자에게 문의하십시오.",
"community": "커뮤니티",
"completed": "완료됨",
"confirm": "확인",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
"nodes": "노드",
"nodesRunning": "노드 실행 중",
"ok": "확인",
"openNewIssue": "새 문제 열기",
"overwrite": "덮어쓰기",
@@ -629,6 +629,9 @@
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"legacyManagerUI": "레거시 UI 사용",
"legacyManagerUIDescription": "레거시 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 레거시 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"nightlyVersion": "최신 테스트 버전(nightly)",
@@ -735,6 +738,7 @@
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -746,6 +750,7 @@
"Give Feedback": "피드백 제공",
"Group Selected Nodes": "선택한 노드 그룹화",
"Help": "도움말",
"Install Missing": "누락된 설치",
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
@@ -753,6 +758,7 @@
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
"Move Selected Nodes Up": "선택한 노드 위로 이동",
"Manager": "매니저",
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
@@ -797,6 +803,8 @@
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Unload Models": "모델 언로드",
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
"Workflow": "워크플로",
"Zoom In": "확대",
"Zoom Out": "축소"

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Загрузить стандартный рабочий процесс"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Менеджер Пользовательских Узлов"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Пользовательские узлы (Бета)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Установить отсутствующие"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Проверить наличие обновлений"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Открыть редактор масок для выбранной ноды"
},
"Comfy_Memory_UnloadModels": {
"label": "Выгрузить модели"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Выгрузить модели и кэш выполнения"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -270,6 +270,7 @@
"color": "Цвет",
"comingSoon": "Скоро будет",
"command": "Команда",
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",
"community": "Сообщество",
"completed": "Завершено",
"confirm": "Подтвердить",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "В очереди нет задач.",
"noWorkflowsFound": "Рабочие процессы не найдены.",
"nodes": "Узлы",
"nodesRunning": "запущено узлов",
"ok": "ОК",
"openNewIssue": "Открыть новую проблему",
"overwrite": "Перезаписать",
@@ -629,6 +629,9 @@
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
"latestVersion": "Последняя",
"legacyManagerUI": "Использовать устаревший UI",
"legacyManagerUIDescription": "Чтобы использовать устаревший UI менеджера, запустите ComfyUI с --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "Устаревшее меню менеджера недоступно в этой версии ComfyUI. Пожалуйста, используйте новое меню менеджера.",
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"nightlyVersion": "Ночная",
@@ -722,7 +725,7 @@
"Bypass/Unbypass Selected Nodes": "Обойти/восстановить выбранные ноды",
"Canvas Toggle Link Visibility": "Переключение видимости ссылки на холст",
"Canvas Toggle Lock": "Переключение блокировки холста",
"Check for Updates": "Проверить наличие обновлений",
"Check for Updates": "Проверить Обновления",
"Clear Pending Tasks": "Очистить ожидающие задачи",
"Clear Workflow": "Очистить рабочий процесс",
"Clipspace": "Клиппространство",
@@ -735,6 +738,7 @@
"Contact Support": "Связаться с поддержкой",
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -746,6 +750,7 @@
"Give Feedback": "Оставить отзыв",
"Group Selected Nodes": "Сгруппировать выбранные ноды",
"Help": "Помощь",
"Install Missing": "Установить Отсутствующие",
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
@@ -753,6 +758,7 @@
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
"Manager": "Менеджер",
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
@@ -797,6 +803,8 @@
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Unload Models": "Выгрузить модели",
"Unload Models and Execution Cache": "Выгрузить модели и кэш выполнения",
"Workflow": "Рабочий процесс",
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"

View File

@@ -152,8 +152,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "載入預設工作流程"
},
"Comfy_Manager_CustomNodesManager": {
"label": "切換自訂節點管理器"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "顯示自訂節點管理器"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "自訂節點(舊版)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "管理選單(舊版)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "安裝缺少的自訂節點"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "檢查自訂節點更新"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切換自訂節點管理器進度條"
@@ -161,6 +173,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "為選取的節點開啟 Mask 編輯器"
},
"Comfy_Memory_UnloadModels": {
"label": "卸載模型"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "卸載模型與執行快取"
},
"Comfy_NewBlankWorkflow": {
"label": "新增空白工作流程"
},

View File

@@ -270,6 +270,7 @@
"color": "顏色",
"comingSoon": "即將推出",
"command": "指令",
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",
"community": "社群",
"completed": "已完成",
"confirm": "確認",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "佇列中沒有任務。",
"noWorkflowsFound": "找不到工作流程。",
"nodes": "節點",
"nodesRunning": "節點執行中",
"ok": "確定",
"openNewIssue": "開啟新問題",
"overwrite": "覆蓋",
@@ -610,12 +610,14 @@
"title": "維護"
},
"manager": {
"applyChanges": "套用變更",
"changingVersion": "正在將版本從 {from} 變更為 {to}",
"createdBy": "建立者",
"dependencies": "相依套件",
"discoverCommunityContent": "探索社群製作的節點包、擴充功能等...",
"downloads": "下載次數",
"errorConnecting": "連線至 Comfy Node Registry 時發生錯誤。",
"extensionsSuccessfullyInstalled": "擴充功能安裝成功,已可使用!",
"failed": "失敗({count}",
"filter": {
"disabled": "已停用",
@@ -627,8 +629,12 @@
"installAllMissingNodes": "安裝所有缺少的節點",
"installSelected": "安裝所選項目",
"installationQueue": "安裝佇列",
"installingDependencies": "正在安裝相依套件……",
"lastUpdated": "最後更新",
"latestVersion": "最新版本",
"legacyManagerUI": "使用舊版介面",
"legacyManagerUIDescription": "若要使用舊版管理介面,請以 --enable-manager-legacy-ui 啟動 ComfyUI",
"legacyMenuNotAvailable": "舊版管理選單不可用,已預設切換至新版管理選單。",
"license": "授權條款",
"loadingVersions": "正在載入版本...",
"nightlyVersion": "每夜建置版",
@@ -640,6 +646,7 @@
"packsSelected": "已選擇套件",
"repository": "儲存庫",
"restartToApplyChanges": "請重新啟動 ComfyUI 以套用變更",
"restartingBackend": "正在重新啟動後端以套用變更……",
"searchPlaceholder": "搜尋",
"selectVersion": "選擇版本",
"sort": {
@@ -664,6 +671,8 @@
"uninstallSelected": "解除安裝所選項目",
"uninstalling": "正在解除安裝",
"update": "更新",
"updateAll": "全部更新",
"updateSelected": "更新所選項目",
"updatingAllPacks": "正在更新所有套件",
"version": "版本"
},
@@ -722,6 +731,7 @@
"Bypass/Unbypass Selected Nodes": "繞過/取消繞過選取節點",
"Canvas Toggle Link Visibility": "切換連結可見性",
"Canvas Toggle Lock": "切換畫布鎖定",
"Check for Custom Node Updates": "檢查自訂節點更新",
"Check for Updates": "檢查更新",
"Clear Pending Tasks": "清除待處理任務",
"Clear Workflow": "清除工作流程",
@@ -735,6 +745,7 @@
"Contact Support": "聯絡支援",
"Convert Selection to Subgraph": "將選取內容轉為子圖",
"Convert selected nodes to group node": "將選取節點轉為群組節點",
"Custom Nodes (Legacy)": "自訂節點(舊版)",
"Delete Selected Items": "刪除選取項目",
"Desktop User Guide": "桌面應用程式使用指南",
"Duplicate Current Workflow": "複製目前工作流程",
@@ -746,9 +757,12 @@
"Give Feedback": "提供意見回饋",
"Group Selected Nodes": "群組選取節點",
"Help": "說明",
"Install Missing Custom Nodes": "安裝缺少的自訂節點",
"Interrupt": "中斷",
"Load Default Workflow": "載入預設工作流程",
"Manage group nodes": "管理群組節點",
"Manager": "管理員",
"Manager Menu (Legacy)": "管理員選單(舊版)",
"Move Selected Nodes Down": "選取節點下移",
"Move Selected Nodes Left": "選取節點左移",
"Move Selected Nodes Right": "選取節點右移",
@@ -782,6 +796,7 @@
"Save": "儲存",
"Save As": "另存新檔",
"Show Settings Dialog": "顯示設定對話框",
"Show the Custom Nodes Manager": "顯示自訂節點管理員",
"Sign Out": "登出",
"Toggle Bottom Panel": "切換下方面板",
"Toggle Focus Mode": "切換專注模式",
@@ -793,10 +808,11 @@
"Toggle Terminal Bottom Panel": "切換終端機底部面板",
"Toggle Theme (Dark/Light)": "切換主題(深色/淺色)",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切換自訂節點管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
"Undo": "復原",
"Ungroup selected group nodes": "取消群組選取的群組節點",
"Unload Models": "卸載模型",
"Unload Models and Execution Cache": "卸載模型與執行快取",
"Workflow": "工作流程",
"Zoom In": "放大",
"Zoom Out": "縮小"

View File

@@ -152,8 +152,14 @@
"Comfy_LoadDefaultWorkflow": {
"label": "加载默认工作流"
},
"Comfy_Manager_CustomNodesManager": {
"label": "自定义节点管理器"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "自定义节点(测试版)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "安装缺失的包"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "检查更新"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切换进度对话框"
@@ -161,6 +167,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "打开选中节点的遮罩编辑器"
},
"Comfy_Memory_UnloadModels": {
"label": "卸载模型"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "卸载模型和执行缓存"
},
"Comfy_NewBlankWorkflow": {
"label": "新建空白工作流"
},

View File

@@ -270,6 +270,7 @@
"color": "颜色",
"comingSoon": "即将推出",
"command": "指令",
"commandProhibited": "命令 {command} 被禁止。请联系管理员获取更多信息。",
"community": "社区",
"completed": "已完成",
"confirm": "确认",
@@ -336,7 +337,6 @@
"noTasksFoundMessage": "队列中没有任务。",
"noWorkflowsFound": "未找到工作流。",
"nodes": "节点",
"nodesRunning": "节点正在运行",
"ok": "确定",
"openNewIssue": "打开新问题",
"overwrite": "覆盖",
@@ -629,6 +629,9 @@
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
"latestVersion": "最新",
"legacyManagerUI": "使用旧版UI",
"legacyManagerUIDescription": "要使用旧版的管理器UI请启动ComfyUI并使用 --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "在此版本的ComfyUI中不提供旧版的管理器菜单。请使用新的管理器菜单。",
"license": "许可证",
"loadingVersions": "正在加载版本...",
"nightlyVersion": "每夜",
@@ -735,6 +738,7 @@
"Contact Support": "联系支持",
"Convert Selection to Subgraph": "将选中内容转换为子图",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -746,6 +750,7 @@
"Give Feedback": "提供反馈",
"Group Selected Nodes": "将选中节点转换为组节点",
"Help": "帮助",
"Install Missing": "安装缺失",
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
@@ -753,6 +758,7 @@
"Move Selected Nodes Left": "左移所选节点",
"Move Selected Nodes Right": "右移所选节点",
"Move Selected Nodes Up": "上移所选节点",
"Manager": "管理器",
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
@@ -786,17 +792,19 @@
"Toggle Bottom Panel": "切换底部面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Model Library Sidebar": "切模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切工作流程側邊欄",
"Toggle Workflows Sidebar": "切工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Unload Models": "卸载模型",
"Unload Models and Execution Cache": "卸载模型和执行缓存",
"Workflow": "工作流",
"Zoom In": "放大画面",
"Zoom Out": "缩小画面"

View File

@@ -48,22 +48,6 @@ const zProgressWsMessage = z.object({
node: zNodeId
})
const zNodeProgressState = z.object({
value: z.number(),
max: z.number(),
state: z.enum(['pending', 'running', 'finished', 'error']),
node_id: zNodeId,
prompt_id: zPromptId,
display_node_id: zNodeId.optional(),
parent_node_id: zNodeId.optional(),
real_node_id: zNodeId.optional()
})
const zProgressStateWsMessage = z.object({
prompt_id: zPromptId,
nodes: z.record(zNodeId, zNodeProgressState)
})
const zExecutingWsMessage = z.object({
node: zNodeId,
display_node: zNodeId,
@@ -150,8 +134,6 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
// End of ws messages
@@ -492,6 +474,7 @@ const zSettings = z.object({
'Comfy.Load3D.LightIntensityMinimum': z.number(),
'Comfy.Load3D.LightAdjustmentIncrement': z.number(),
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
'Comfy.Memory.AllowManualUnload': z.boolean(),
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.string(),

View File

@@ -17,7 +17,6 @@ import type {
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
@@ -38,6 +37,7 @@ import {
type ComfyNodeDef,
validateComfyNodeDef
} from '@/schemas/nodeDefSchema'
import { useToastStore } from '@/stores/toastStore'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
interface QueuePromptRequestBody {
@@ -106,17 +106,7 @@ interface BackendApiCalls {
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
/** Binary preview with metadata (node_id, prompt_id) */
b_preview_with_metadata: {
blob: Blob
nodeId: string
parentNodeId: string
displayNodeId: string
realNodeId: string
promptId: string
}
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}
@@ -468,33 +458,6 @@ export class ComfyApi extends EventTarget {
})
this.dispatchCustomEvent('b_preview', imageBlob)
break
case 4:
// PREVIEW_IMAGE_WITH_METADATA
const decoder4 = new TextDecoder()
const metadataLength = view.getUint32(4)
const metadataBytes = event.data.slice(8, 8 + metadataLength)
const metadata = JSON.parse(decoder4.decode(metadataBytes))
const imageData4 = event.data.slice(8 + metadataLength)
let imageMime4 = metadata.image_type
const imageBlob4 = new Blob([imageData4], {
type: imageMime4
})
// Dispatch enhanced preview event with metadata
this.dispatchCustomEvent('b_preview_with_metadata', {
blob: imageBlob4,
nodeId: metadata.node_id,
displayNodeId: metadata.display_node_id,
parentNodeId: metadata.parent_node_id,
realNodeId: metadata.real_node_id,
promptId: metadata.prompt_id
})
// Also dispatch legacy b_preview for backward compatibility
this.dispatchCustomEvent('b_preview', imageBlob4)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
@@ -524,7 +487,6 @@ export class ComfyApi extends EventTarget {
case 'execution_cached':
case 'execution_success':
case 'progress':
case 'progress_state':
case 'executed':
case 'graphChanged':
case 'promptQueued':
@@ -1026,6 +988,56 @@ export class ComfyApi extends EventTarget {
return (await axios.get(this.internalURL('/folder_paths'))).data
}
/* Frees memory by unloading models and optionally freeing execution cache
* @param {Object} options - The options object
* @param {boolean} options.freeExecutionCache - If true, also frees execution cache
*/
async freeMemory(options: { freeExecutionCache: boolean }) {
try {
let mode = ''
if (options.freeExecutionCache) {
mode = '{"unload_models": true, "free_memory": true}'
} else {
mode = '{"unload_models": true}'
}
const res = await this.fetchApi(`/free`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: mode
})
if (res.status === 200) {
if (options.freeExecutionCache) {
useToastStore().add({
severity: 'success',
summary: 'Models and Execution Cache have been cleared.',
life: 3000
})
} else {
useToastStore().add({
severity: 'success',
summary: 'Models have been unloaded.',
life: 3000
})
}
} else {
useToastStore().add({
severity: 'error',
summary:
'Unloading of models failed. Installed ComfyUI may be an outdated version.',
life: 5000
})
}
} catch (error) {
useToastStore().add({
severity: 'error',
summary: 'An error occurred while trying to unload models.',
life: 5000
})
}
}
/**
* Gets the custom nodes i18n data from the server.
*

View File

@@ -47,7 +47,6 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -61,10 +60,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import {
getNodeByExecutionId,
triggerCallbackOnAllNodes
} from '@/utils/graphTraversalUtil'
import {
executeWidgetsCallback,
fixLinkInputSlots,
@@ -199,8 +194,6 @@ export class ComfyApp {
/**
* @deprecated Use useExecutionStore().executingNodeId instead
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
*/
get runningNodeId(): NodeId | null {
return useExecutionStore().executingNodeId
@@ -642,24 +635,36 @@ export class ComfyApp {
api.addEventListener('executing', () => {
this.graph.setDirtyCanvas(true, false)
// @ts-expect-error fixme ts strict error
this.revokePreviews(this.runningNodeId)
// @ts-expect-error fixme ts strict error
delete this.nodePreviewImages[this.runningNodeId]
})
api.addEventListener('executed', ({ detail }) => {
const nodeOutputStore = useNodeOutputStore()
const executionId = String(detail.display_node || detail.node)
nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, {
merge: detail.merge
})
const node = getNodeByExecutionId(this.graph, executionId)
if (node && node.onExecuted) {
node.onExecuted(detail.output)
const output = this.nodeOutputs[detail.display_node || detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k]
if (v instanceof Array) {
output[k] = v.concat(detail.output[k])
} else {
output[k] = detail.output[k]
}
}
} else {
this.nodeOutputs[detail.display_node || detail.node] = detail.output
}
const node = this.graph.getNodeById(detail.display_node || detail.node)
if (node) {
if (node.onExecuted) node.onExecuted(detail.output)
}
})
api.addEventListener('execution_start', () => {
triggerCallbackOnAllNodes(this.graph, 'onExecutionStart')
this.graph.nodes.forEach((node) => {
if (node.onExecutionStart) node.onExecutionStart()
})
})
api.addEventListener('execution_error', ({ detail }) => {
@@ -684,16 +689,15 @@ export class ComfyApp {
this.canvas.draw(true, true)
})
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
// Enhanced preview with explicit node context
const { blob, displayNodeId } = detail
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
useNodeOutputStore()
// Ensure clean up if `executing` event is missed.
revokePreviewsByExecutionId(displayNodeId)
api.addEventListener('b_preview', ({ detail }) => {
const id = this.runningNodeId
if (id == null) return
const blob = detail
const blobUrl = URL.createObjectURL(blob)
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
setNodePreviewsByExecutionId(displayNodeId, [blobUrl])
// Ensure clean up if `executing` event is missed.
this.revokePreviews(id)
this.nodePreviewImages[id] = [blobUrl]
})
api.init()
@@ -1672,13 +1676,25 @@ export class ComfyApp {
}
}
/**
* Frees memory allocated to image preview blobs for a specific node, by revoking the URLs associated with them.
* @param nodeId ID of the node to revoke all preview images of
*/
revokePreviews(nodeId: NodeId) {
if (!this.nodePreviewImages[nodeId]?.[Symbol.iterator]) return
for (const url of this.nodePreviewImages[nodeId]) {
URL.revokeObjectURL(url)
}
}
/**
* Clean current state
*/
clean() {
this.nodeOutputs = {}
const { revokeAllPreviews } = useNodeOutputStore()
revokeAllPreviews()
for (const id of Object.keys(this.nodePreviewImages)) {
this.revokePreviews(id)
}
this.nodePreviewImages = {}
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null

View File

@@ -44,30 +44,12 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
inputEl?: T
}
/**
* Additional props that can be passed to component widgets.
* These are in addition to the standard props that are always provided:
* - modelValue: The widget's value (handled by v-model)
* - widget: Reference to the widget instance
* - onUpdate:modelValue: The update handler for v-model
*/
export type ComponentWidgetCustomProps = Record<string, unknown>
/**
* Standard props that are handled separately by DomWidget.vue and should be
* omitted when defining custom props for component widgets
*/
export type ComponentWidgetStandardProps =
| 'modelValue'
| 'widget'
| 'onUpdate:modelValue'
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<
V extends object | string,
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
P = Record<string, unknown>
> extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
@@ -176,18 +158,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
override onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
override createCopyForNode(node: LGraphNode): this {
// @ts-expect-error
const cloned: this = new (this.constructor as typeof this)({
node: node,
name: this.name,
type: this.type,
options: this.options
})
cloned.value = this.value
return cloned
}
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
@@ -207,19 +177,6 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.element = obj.element
}
override createCopyForNode(node: LGraphNode): this {
// @ts-expect-error
const cloned: this = new (this.constructor as typeof this)({
node: node,
name: this.name,
type: this.type,
element: this.element, // Include the element!
options: this.options
})
cloned.value = this.value
return cloned
}
/** Extract DOM widget size info */
override computeLayoutSize(node: LGraphNode) {
if (this.type === 'hidden') {
@@ -265,7 +222,7 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
export class ComponentWidgetImpl<
V extends object | string,
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
P = Record<string, unknown>
>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V, P>

View File

@@ -1,17 +1,18 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import {
type InstallPackParams,
type InstalledPacksResponse,
type ManagerPackInfo,
type ManagerQueueStatus,
SelectedVersion,
type UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
import { components } from '@/types/generatedManagerTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
type ManagerQueueStatus = components['schemas']['QueueStatus']
type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
type ManagerTaskHistory = components['schemas']['HistoryResponse']
type QueueTaskItem = components['schemas']['QueueTaskItem']
const GENERIC_SECURITY_ERR_MSG =
'Forbidden: A security error has occurred. Please check the terminal logs'
@@ -19,20 +20,23 @@ const GENERIC_SECURITY_ERR_MSG =
* API routes for ComfyUI Manager
*/
enum ManagerRoute {
START_QUEUE = 'manager/queue/start',
RESET_QUEUE = 'manager/queue/reset',
QUEUE_STATUS = 'manager/queue/status',
INSTALL = 'manager/queue/install',
UPDATE = 'manager/queue/update',
UPDATE_ALL = 'manager/queue/update_all',
UNINSTALL = 'manager/queue/uninstall',
DISABLE = 'manager/queue/disable',
FIX_NODE = 'manager/queue/fix',
LIST_INSTALLED = 'customnode/installed',
GET_NODES = 'customnode/getmappings',
GET_PACKS = 'customnode/getlist',
IMPORT_FAIL_INFO = 'customnode/import_fail_info',
REBOOT = 'manager/reboot'
START_QUEUE = 'v2/manager/queue/start',
RESET_QUEUE = 'v2/manager/queue/reset',
QUEUE_STATUS = 'v2/manager/queue/status',
INSTALL = 'v2/manager/queue/install',
UPDATE = 'v2/manager/queue/update',
UPDATE_ALL = 'v2/manager/queue/update_all',
UNINSTALL = 'v2/manager/queue/uninstall',
DISABLE = 'v2/manager/queue/disable',
FIX_NODE = 'v2/manager/queue/fix',
LIST_INSTALLED = 'v2/customnode/installed',
GET_NODES = 'v2/customnode/getmappings',
GET_PACKS = 'v2/customnode/getlist',
IMPORT_FAIL_INFO = 'v2/customnode/import_fail_info',
REBOOT = 'v2/manager/reboot',
IS_LEGACY_MANAGER_UI = 'v2/manager/is_legacy_manager_ui',
TASK_HISTORY = 'v2/manager/queue/history',
QUEUE_TASK = 'v2/manager/queue/task'
}
const managerApiClient = axios.create({
@@ -49,7 +53,6 @@ const managerApiClient = axios.create({
export const useComfyManagerService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
const didStartQueue = ref(false)
const handleRequestError = (
err: unknown,
@@ -110,28 +113,21 @@ export const useComfyManagerService = () => {
201: 'Created: ComfyUI-Manager job queue is already running'
}
didStartQueue.value = true
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.START_QUEUE, { signal }),
{ errorContext, routeSpecificErrors }
)
}
const getQueueStatus = async (signal?: AbortSignal) => {
const getQueueStatus = async (client_id?: string, signal?: AbortSignal) => {
const errorContext = 'Getting ComfyUI-Manager queue status'
return executeRequest<ManagerQueueStatus>(
() => managerApiClient.get(ManagerRoute.QUEUE_STATUS, { signal }),
{ errorContext }
)
}
const resetQueue = async (signal?: AbortSignal) => {
const errorContext = 'Resetting ComfyUI-Manager queue'
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.RESET_QUEUE, { signal }),
() =>
managerApiClient.get(ManagerRoute.QUEUE_STATUS, {
params: client_id ? { client_id } : undefined,
signal
}),
{ errorContext }
)
}
@@ -154,73 +150,66 @@ export const useComfyManagerService = () => {
)
}
const installPack = async (
params: InstallPackParams,
const queueTask = async (
kind: QueueTaskItem['kind'],
params: QueueTaskItem['params'],
ui_id?: string,
signal?: AbortSignal
) => {
const errorContext = `Installing pack ${params.id}`
const task: QueueTaskItem = {
kind,
params,
ui_id: ui_id || uuidv4(),
client_id: api.clientId ?? api.initialClientId ?? 'unknown'
}
const errorContext = `Queueing ${task.kind} task`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG,
404:
params.selected_version === SelectedVersion.NIGHTLY
? `Not Found: Node pack ${params.id} does not provide nightly version`
: GENERIC_SECURITY_ERR_MSG
404: `Not Found: Task could not be queued`
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.INSTALL, params, { signal }),
() => managerApiClient.post(ManagerRoute.QUEUE_TASK, task, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const installPack = async (
params: InstallPackParams,
ui_id?: string,
signal?: AbortSignal
) => {
return queueTask('install', params, ui_id, signal)
}
const uninstallPack = async (
params: ManagerPackInfo,
params: components['schemas']['UninstallPackParams'],
ui_id?: string,
signal?: AbortSignal
) => {
const errorContext = `Uninstalling pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.UNINSTALL, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
return queueTask('uninstall', params, ui_id, signal)
}
const disablePack = async (
params: ManagerPackInfo,
params: components['schemas']['DisablePackParams'],
ui_id?: string,
signal?: AbortSignal
): Promise<null> => {
const errorContext = `Disabling pack ${params.id}`
const routeSpecificErrors = {
404: `Pack ${params.id} not found or not installed`,
409: `Pack ${params.id} is already disabled`
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.DISABLE, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
return queueTask('disable', params, ui_id, signal)
}
const updatePack = async (
params: ManagerPackInfo,
params: components['schemas']['UpdatePackParams'],
ui_id?: string,
signal?: AbortSignal
): Promise<null> => {
const errorContext = `Updating pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.UPDATE, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
return queueTask('update', params, ui_id, signal)
}
const updateAllPacks = async (
params?: UpdateAllPacksParams,
params: UpdateAllPacksParams = {},
ui_id?: string,
signal?: AbortSignal
) => {
const errorContext = 'Updating all packs'
@@ -229,8 +218,18 @@ export const useComfyManagerService = () => {
401: 'Unauthorized: ComfyUI-Manager job queue is busy'
}
const queryParams = {
mode: params.mode,
client_id: api.clientId ?? api.initialClientId ?? 'unknown',
ui_id: ui_id || uuidv4()
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.UPDATE_ALL, { params, signal }),
() =>
managerApiClient.get(ManagerRoute.UPDATE_ALL, {
params: queryParams,
signal
}),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
@@ -247,6 +246,36 @@ export const useComfyManagerService = () => {
)
}
const isLegacyManagerUI = async (signal?: AbortSignal) => {
const errorContext = 'Checking if user set Manager to use the legacy UI'
return executeRequest<{ is_legacy_manager_ui: boolean }>(
() => managerApiClient.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
{ errorContext }
)
}
const getTaskHistory = async (
options: {
ui_id?: string
max_items?: number
client_id?: string
offset?: number
} = {},
signal?: AbortSignal
) => {
const errorContext = 'Getting ComfyUI-Manager task history'
return executeRequest<ManagerTaskHistory>(
() =>
managerApiClient.get(ManagerRoute.TASK_HISTORY, {
params: options,
signal
}),
{ errorContext }
)
}
return {
// State
isLoading,
@@ -254,8 +283,8 @@ export const useComfyManagerService = () => {
// Queue operations
startQueue,
resetQueue,
getQueueStatus,
getTaskHistory,
// Pack management
listInstalledPacks,
@@ -268,6 +297,7 @@ export const useComfyManagerService = () => {
updateAllPacks,
// System operations
rebootComfyUI
rebootComfyUI,
isLegacyManagerUI
}
}

View File

@@ -32,10 +32,7 @@ import type {
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { ComfyApp, app } from '@/scripts/app'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { $el } from '@/scripts/ui'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -90,37 +87,6 @@ export const useLitegraphService = () => {
constructor() {
super(app.graph, subgraph, instanceData)
// Set up event listener for promoted widget registration
subgraph.events.addEventListener('widget-promoted', (event) => {
const { widget } = event.detail
// Only handle DOM widgets
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
const domWidgetStore = useDomWidgetStore()
if (!domWidgetStore.widgetStates.has(widget.id)) {
domWidgetStore.registerWidget(widget)
// Set initial visibility based on whether the widget's node is in the current graph
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (widgetState) {
const currentGraph = canvasStore.getCanvas().graph
widgetState.visible =
currentGraph?.nodes.includes(widget.node) ?? false
}
}
})
// Set up event listener for promoted widget removal
subgraph.events.addEventListener('widget-demoted', (event) => {
const { widget } = event.detail
// Only handle DOM widgets
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
const domWidgetStore = useDomWidgetStore()
if (domWidgetStore.widgetStates.has(widget.id)) {
domWidgetStore.unregisterWidget(widget.id)
}
})
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
@@ -141,11 +107,7 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
if (this.id == app.runningNodeId) {
return { color: '#0f0' }
}
}
@@ -400,11 +362,7 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
if (this.id == app.runningNodeId) {
return { color: '#0f0' }
}
}

View File

@@ -1,21 +1,28 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { useServerLogs } from '@/composables/useServerLogs'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import {
InstallPackParams,
InstalledPacksResponse,
ManagerPackInfo,
ManagerPackInstalled,
TaskLog,
UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
import { TaskLog } from '@/types/comfyManagerTypes'
import { components } from '@/types/generatedManagerTypes'
type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
type ManagerPackInfo = components['schemas']['ManagerPackInfo']
type ManagerPackInstalled = components['schemas']['ManagerPackInstalled']
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
/**
* Store for state of installed node packs
@@ -31,14 +38,62 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
const installedPacksIds = ref<Set<string>>(new Set())
const isStale = ref(true)
const taskLogs = ref<TaskLog[]>([])
const succeededTasksLogs = ref<TaskLog[]>([])
const failedTasksLogs = ref<TaskLog[]>([])
const { statusMessage, allTasksDone, enqueueTask, uncompletedCount } =
useManagerQueue()
const taskHistory = ref<ManagerTaskHistory>({})
const succeededTasksIds = ref<string[]>([])
const failedTasksIds = ref<string[]>([])
const taskQueue = ref<ManagerTaskQueue>({
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
})
const managerQueue = useManagerQueue(taskHistory, taskQueue, installedPacks)
const setStale = () => {
isStale.value = true
}
const partitionTaskLogs = () => {
const successTaskLogs: TaskLog[] = []
const failTaskLogs: TaskLog[] = []
for (const log of taskLogs.value) {
if (failedTasksIds.value.includes(log.taskId)) {
failTaskLogs.push(log)
} else {
successTaskLogs.push(log)
}
}
succeededTasksLogs.value = successTaskLogs
failedTasksLogs.value = failTaskLogs
}
const partitionTasks = () => {
const successTasksIds = []
const failTasksIds = []
for (const task of Object.values(taskHistory.value)) {
if (task.status?.status_str === 'success') {
successTasksIds.push(task.ui_id)
} else {
failTasksIds.push(task.ui_id)
}
}
succeededTasksIds.value = successTasksIds
failedTasksIds.value = failTasksIds
}
whenever(
taskHistory,
() => {
partitionTasks()
partitionTaskLogs()
},
{ deep: true }
)
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: string | undefined): boolean =>
@@ -97,11 +152,13 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
const updateInstalledIds = (packs: ManagerPackInstalled[]) => {
installedPacksIds.value = packsToIdSet(packs)
const newIds = packsToIdSet(packs)
installedPacksIds.value = newIds
}
const onPacksChanged = () => {
const packs = Object.values(installedPacks.value)
updateDisabledIds(packs)
updateInstalledIds(packs)
}
@@ -115,23 +172,46 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
whenever(isStale, refreshInstalledList, { immediate: true })
whenever(uncompletedCount, () => showManagerProgressDialog())
const withLogs = (task: () => Promise<null>, taskName: string) => {
const { startListening, stopListening, logs } = useServerLogs()
const enqueueTaskWithLogs = async (
task: (taskId: string) => Promise<null>,
taskName: string
) => {
const taskId = uuidv4()
const { logs } = useServerLogs({
ui_id: taskId,
immediate: true
})
const loggedTask = async () => {
taskLogs.value.push({ taskName, logs: logs.value })
await startListening()
return task()
try {
// Show progress dialog immediately when task is queued
showManagerProgressDialog()
managerQueue.isProcessing.value = true
// Prepare logging hook
taskLogs.value.push({ taskName, taskId, logs: logs.value })
// Queue the task to the server
await task(taskId)
} catch (error) {
// Reset processing state on error
managerQueue.isProcessing.value = false
// The server has authority over task history in general, but in rare
// case of client-side error, we add that to failed tasks from the client side
taskHistory.value[taskId] = {
ui_id: taskId,
client_id: api.clientId || 'unknown',
kind: 'error',
result: 'failed',
status: {
status_str: 'error',
completed: false,
messages: [error instanceof Error ? error.message : String(error)]
},
timestamp: new Date().toISOString()
}
}
const onComplete = async () => {
await stopListening()
setStale()
}
return { task: loggedTask, onComplete }
}
const installPack = useCachedRequest<InstallPackParams, void>(
@@ -152,39 +232,62 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
}
const task = () => managerService.installPack(params, signal)
enqueueTask(withLogs(task, `${actionDescription} ${params.id}`))
const task = (taskId: string) =>
managerService.installPack(params, taskId, signal)
await enqueueTaskWithLogs(task, `${actionDescription} ${params.id}`)
},
{ maxSize: 1 }
)
const uninstallPack = (params: ManagerPackInfo, signal?: AbortSignal) => {
const uninstallPack = async (
params: ManagerPackInfo,
signal?: AbortSignal
) => {
installPack.clear()
installPack.cancel()
const task = () => managerService.uninstallPack(params, signal)
enqueueTask(withLogs(task, t('manager.uninstalling', { id: params.id })))
const uninstallParams: components['schemas']['UninstallPackParams'] = {
node_name: params.id,
is_unknown: false
}
const task = (taskId: string) =>
managerService.uninstallPack(uninstallParams, taskId, signal)
await enqueueTaskWithLogs(
task,
t('manager.uninstalling', { id: params.id })
)
}
const updatePack = useCachedRequest<ManagerPackInfo, void>(
async (params: ManagerPackInfo, signal?: AbortSignal) => {
updateAllPacks.cancel()
const task = () => managerService.updatePack(params, signal)
enqueueTask(withLogs(task, t('g.updating', { id: params.id })))
const updateParams: components['schemas']['UpdatePackParams'] = {
node_name: params.id,
node_ver: params.version
}
const task = (taskId: string) =>
managerService.updatePack(updateParams, taskId, signal)
await enqueueTaskWithLogs(task, t('g.updating', { id: params.id }))
},
{ maxSize: 1 }
)
const updateAllPacks = useCachedRequest<UpdateAllPacksParams, void>(
async (params: UpdateAllPacksParams, signal?: AbortSignal) => {
const task = () => managerService.updateAllPacks(params, signal)
enqueueTask(withLogs(task, t('manager.updatingAllPacks')))
const task = (taskId: string) =>
managerService.updateAllPacks(params, taskId, signal)
await enqueueTaskWithLogs(task, t('manager.updatingAllPacks'))
},
{ maxSize: 1 }
)
const disablePack = (params: ManagerPackInfo, signal?: AbortSignal) => {
const task = () => managerService.disablePack(params, signal)
enqueueTask(withLogs(task, t('g.disabling', { id: params.id })))
const disablePack = async (params: ManagerPackInfo, signal?: AbortSignal) => {
const disableParams: components['schemas']['DisablePackParams'] = {
node_name: params.id,
is_unknown: false
}
const task = (taskId: string) =>
managerService.disablePack(disableParams, taskId, signal)
await enqueueTaskWithLogs(task, t('g.disabling', { id: params.id }))
}
const getInstalledPackVersion = (packId: string) => {
@@ -196,15 +299,31 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
taskLogs.value = []
}
const resetTaskState = () => {
// Clear all task-related reactive state for fresh start after restart
taskLogs.value = []
taskHistory.value = {}
succeededTasksIds.value = []
failedTasksIds.value = []
succeededTasksLogs.value = []
failedTasksLogs.value = []
// Reset task queue to initial state
taskQueue.value = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
}
return {
// Manager state
isLoading: managerService.isLoading,
error: managerService.error,
statusMessage,
allTasksDone,
uncompletedCount,
taskLogs,
clearLogs,
resetTaskState,
setStale,
// Installed packs state
@@ -215,6 +334,15 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
getInstalledPackVersion,
refreshInstalledList,
// Task queue state and actions
taskHistory,
isProcessingTasks: managerQueue.isProcessing,
succeededTasksIds,
failedTasksIds,
succeededTasksLogs,
failedTasksLogs,
managerQueue,
// Pack actions
installPack,
uninstallPack,
@@ -234,6 +362,15 @@ export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const setActiveTabIndex = (index: number) => {
activeTabIndex.value = index
}
const getActiveTabIndex = () => {
return activeTabIndex.value
}
const toggle = () => {
isExpanded.value = !isExpanded.value
@@ -250,7 +387,9 @@ export const useManagerProgressDialogStore = defineStore(
isExpanded,
toggle,
collapse,
expand
expand,
setActiveTabIndex,
getActiveTabIndex
}
}
)

View File

@@ -12,8 +12,6 @@ import type {
ExecutionErrorWsMessage,
ExecutionStartWsMessage,
NodeError,
NodeProgressState,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage
} from '@/schemas/apiSchema'
@@ -23,10 +21,6 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { useCanvasStore } from './graphStore'
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
@@ -52,97 +46,7 @@ export const useExecutionStore = defineStore('execution', () => {
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
const executionIdToNodeLocatorId = (
nodeId: string | number
): NodeLocatorId => {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
): NodeProgressState => {
if (currentState === undefined) {
return newState
}
const mergedState = { ...currentState }
if (mergedState.state === 'error') {
return mergedState
} else if (newState.state === 'running') {
const newPerc = newState.max > 0 ? newState.value / newState.max : 0.0
const oldPerc =
mergedState.max > 0 ? mergedState.value / mergedState.max : 0.0
if (
mergedState.state !== 'running' ||
oldPerc === 0.0 ||
newPerc < oldPerc
) {
mergedState.value = newState.value
mergedState.max = newState.max
}
mergedState.state = 'running'
}
return mergedState
}
const nodeLocationProgressStates = computed<
Record<NodeLocatorId, NodeProgressState>
>(() => {
const result: Record<NodeLocatorId, NodeProgressState> = {}
const states = nodeProgressStates.value // Apparently doing this inside `Object.entries` causes issues
for (const state of Object.values(states)) {
const parts = String(state.display_node_id).split(':')
for (let i = 0; i < parts.length; i++) {
const executionId = parts.slice(0, i + 1).join(':')
const locatorId = executionIdToNodeLocatorId(executionId)
if (!locatorId) continue
result[locatorId] = mergeExecutionProgressStates(
result[locatorId],
state
)
}
}
return result
})
// Easily access all currently executing node IDs
const executingNodeIds = computed<NodeId[]>(() => {
return Object.entries(nodeProgressStates)
.filter(([_, state]) => state.state === 'running')
.map(([nodeId, _]) => nodeId)
})
// @deprecated For backward compatibility - stores the primary executing node ID
const executingNodeId = computed<NodeId | null>(() => {
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
})
// For backward compatibility - returns the primary executing node
const executingNodeId = ref<NodeId | null>(null)
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
@@ -189,7 +93,30 @@ export const useExecutionStore = defineStore('execution', () => {
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
// This is the progress of the currently executing node (for backward compatibility)
const executionIdToCurrentId = (id: string) => {
const subgraph = workflowStore.activeSubgraph
// Short-circuit: ID belongs to the parent workflow / no active subgraph
if (!id.includes(':')) {
return !subgraph ? id : undefined
} else if (!subgraph) {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(
subgraph.rootGraph,
subgraphNodeIds
)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
// This is the progress of the currently executing node, if any
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
_executingNodeProgress.value
@@ -223,29 +150,24 @@ export const useExecutionStore = defineStore('execution', () => {
function bindExecutionEvents() {
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('execution_interrupted', handleExecutionInterrupted)
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
api.addEventListener('progress', handleProgress)
api.addEventListener('progress_state', handleProgressState)
api.addEventListener('status', handleStatus)
api.addEventListener('execution_error', handleExecutionError)
api.addEventListener('progress_text', handleProgressText)
api.addEventListener('display_component', handleDisplayComponent)
}
api.addEventListener('progress_text', handleProgressText)
api.addEventListener('display_component', handleDisplayComponent)
function unbindExecutionEvents() {
api.removeEventListener('execution_start', handleExecutionStart)
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('execution_interrupted', handleExecutionInterrupted)
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
api.removeEventListener('progress', handleProgress)
api.removeEventListener('progress_state', handleProgressState)
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
api.removeEventListener('display_component', handleDisplayComponent)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -261,10 +183,6 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
function handleExecutionInterrupted() {
nodeProgressStates.value = {}
}
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
if (!activePrompt.value) return
activePrompt.value.nodes[e.detail.node] = true
@@ -276,42 +194,19 @@ export const useExecutionStore = defineStore('execution', () => {
if (!activePrompt.value) return
// Update the executing nodes list
if (typeof e.detail !== 'string') {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
if (executingNodeId.value && activePrompt.value) {
// Seems sometimes nodes that are cached fire executing but not executed
activePrompt.value.nodes[executingNodeId.value] = true
}
}
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes } = e.detail
// Revoke previews for nodes that are starting to execute
for (const nodeId in nodes) {
const nodeState = nodes[nodeId]
if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) {
// This node just started executing, revoke its previews
// Note that we're doing the *actual* node id instead of the display node id
// here intentionally. That way, we don't clear the preview every time a new node
// within an expanded graph starts executing.
const { revokePreviewsByExecutionId } = useNodeOutputStore()
revokePreviewsByExecutionId(nodeId)
}
}
// Update the progress states for all nodes
nodeProgressStates.value = nodes
// If we have progress for the currently executing node, update it for backwards compatibility
if (executingNodeId.value && nodes[executingNodeId.value]) {
const nodeState = nodes[executingNodeId.value]
_executingNodeProgress.value = {
value: nodeState.value,
max: nodeState.max,
prompt_id: nodeState.prompt_id,
node: nodeState.display_node_id || nodeState.node_id
if (typeof e.detail === 'string') {
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
} else {
executingNodeId.value = e.detail
if (executingNodeId.value === null) {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
}
}
}
@@ -344,7 +239,7 @@ export const useExecutionStore = defineStore('execution', () => {
const { nodeId, text } = e.detail
if (!text || !nodeId) return
// Handle execution node IDs for subgraphs
// Handle hierarchical node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
@@ -355,7 +250,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
const { node_id: nodeId, component, props = {} } = e.detail
// Handle execution node IDs for subgraphs
// Handle hierarchical node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
@@ -395,18 +290,6 @@ export const useExecutionStore = defineStore('execution', () => {
)
}
/**
* Convert a NodeLocatorId to an execution context ID
* @param locatorId The NodeLocatorId
* @returns The execution ID or null if conversion fails
*/
const nodeLocatorIdToExecutionId = (
locatorId: NodeLocatorId | string
): string | null => {
const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId)
return executionId
}
return {
isIdle,
clientId,
@@ -427,13 +310,9 @@ export const useExecutionStore = defineStore('execution', () => {
*/
lastExecutionError,
/**
* The id of the node that is currently being executed (backward compatibility)
* The id of the node that is currently being executed
*/
executingNodeId,
/**
* The list of all nodes that are currently executing
*/
executingNodeIds,
/**
* The prompt that is currently being executed
*/
@@ -451,25 +330,17 @@ export const useExecutionStore = defineStore('execution', () => {
*/
executionProgress,
/**
* The node that is currently being executed (backward compatibility)
* The node that is currently being executed
*/
executingNode,
/**
* The progress of the executing node (backward compatibility)
* The progress of the executing node (if the node reports progress)
*/
executingNodeProgress,
/**
* All node progress states from progress_state events
*/
nodeProgressStates,
nodeLocationProgressStates,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt,
// Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress,
// NodeLocatorId conversion helpers
executionIdToNodeLocatorId,
nodeLocatorIdToExecutionId
_executingNodeProgress
}
})

View File

@@ -8,9 +8,6 @@ import {
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isVideoNode } from '@/utils/litegraphUtil'
@@ -25,22 +22,17 @@ const createOutputs = (
}
}
interface SetOutputOptions {
merge?: boolean
}
export const useNodeOutputStore = defineStore('nodeOutput', () => {
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore()
const getNodeId = (node: LGraphNode): string => node.id.toString()
function getNodeOutputs(
node: LGraphNode
): ExecutedWsMessage['output'] | undefined {
return app.nodeOutputs[nodeIdToNodeLocatorId(node.id)]
return app.nodeOutputs[getNodeId(node)]
}
function getNodePreviews(node: LGraphNode): string[] | undefined {
return app.nodePreviewImages[nodeIdToNodeLocatorId(node.id)]
return app.nodePreviewImages[getNodeId(node)]
}
/**
@@ -94,35 +86,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
})
}
/**
* Internal function to set outputs by NodeLocatorId.
* Handles the merge logic when needed.
*/
function setOutputsByLocatorId(
nodeLocatorId: NodeLocatorId,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
if (options.merge) {
const existingOutput = app.nodeOutputs[nodeLocatorId]
if (existingOutput && outputs) {
for (const k in outputs) {
const existingValue = existingOutput[k]
const newValue = (outputs as Record<NodeLocatorId, any>)[k]
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
existingOutput[k] = existingValue.concat(newValue)
} else {
existingOutput[k] = newValue
}
}
return
}
}
app.nodeOutputs[nodeLocatorId] = outputs
}
function setNodeOutputs(
node: LGraphNode,
filenames: string | string[] | ResultItem,
@@ -133,149 +96,24 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
if (!filenames || !node) return
const nodeId = getNodeId(node)
if (typeof filenames === 'string') {
setNodeOutputsByNodeId(
node.id,
createOutputs([filenames], folder, isAnimated)
)
app.nodeOutputs[nodeId] = createOutputs([filenames], folder, isAnimated)
} else if (!Array.isArray(filenames)) {
setNodeOutputsByNodeId(node.id, filenames)
app.nodeOutputs[nodeId] = filenames
} else {
const resultItems = createOutputs(filenames, folder, isAnimated)
if (!resultItems?.images?.length) return
setNodeOutputsByNodeId(node.id, resultItems)
app.nodeOutputs[nodeId] = resultItems
}
}
/**
* Set node outputs by execution ID (hierarchical ID from backend).
* Converts the execution ID to a NodeLocatorId before storing.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @param outputs - The outputs to store
* @param options - Options for setting outputs
* @param options.merge - If true, merge with existing outputs (arrays are concatenated)
*/
function setNodeOutputsByExecutionId(
executionId: string,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
setOutputsByLocatorId(nodeLocatorId, outputs, options)
}
/**
* Set node outputs by node ID.
* Uses the current graph context to create the appropriate NodeLocatorId.
*
* @param nodeId - The node ID
* @param outputs - The outputs to store
* @param options - Options for setting outputs
* @param options.merge - If true, merge with existing outputs (arrays are concatenated)
*/
function setNodeOutputsByNodeId(
nodeId: string | number,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
if (!nodeLocatorId) return
setOutputsByLocatorId(nodeLocatorId, outputs, options)
}
/**
* Set node preview images by execution ID (hierarchical ID from backend).
* Converts the execution ID to a NodeLocatorId before storing.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @param previewImages - Array of preview image URLs to store
*/
function setNodePreviewsByExecutionId(
executionId: string,
previewImages: string[]
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
app.nodePreviewImages[nodeLocatorId] = previewImages
}
/**
* Set node preview images by node ID.
* Uses the current graph context to create the appropriate NodeLocatorId.
*
* @param nodeId - The node ID
* @param previewImages - Array of preview image URLs to store
*/
function setNodePreviewsByNodeId(
nodeId: string | number,
previewImages: string[]
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
app.nodePreviewImages[nodeLocatorId] = previewImages
}
/**
* Revoke preview images by execution ID.
* Frees memory allocated to image preview blobs by revoking the URLs.
*
* @param executionId - The execution ID
*/
function revokePreviewsByExecutionId(executionId: string) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
revokePreviewsByLocatorId(nodeLocatorId)
}
/**
* Revoke preview images by node locator ID.
* Frees memory allocated to image preview blobs by revoking the URLs.
*
* @param nodeLocatorId - The node locator ID
*/
function revokePreviewsByLocatorId(nodeLocatorId: NodeLocatorId) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (!previews?.[Symbol.iterator]) return
for (const url of previews) {
URL.revokeObjectURL(url)
}
delete app.nodePreviewImages[nodeLocatorId]
}
/**
* Revoke all preview images.
* Frees memory allocated to all image preview blobs.
*/
function revokeAllPreviews() {
for (const nodeLocatorId of Object.keys(app.nodePreviewImages)) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (!previews?.[Symbol.iterator]) continue
for (const url of previews) {
URL.revokeObjectURL(url)
}
}
app.nodePreviewImages = {}
}
return {
getNodeOutputs,
getNodeImageUrls,
getNodePreviews,
setNodeOutputs,
setNodeOutputsByExecutionId,
setNodeOutputsByNodeId,
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
revokePreviewsByExecutionId,
revokeAllPreviews,
getPreviewParam
}
})

View File

@@ -251,41 +251,11 @@ export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
} as ComfyNodeDefV1)
}
/**
* Defines a filter for node definitions in the node library.
* Filters are applied in a single pass to determine node visibility.
*/
export interface NodeDefFilter {
/**
* Unique identifier for the filter.
* Convention: Use dot notation like 'core.deprecated' or 'extension.myfilter'
*/
id: string
/**
* Display name for the filter (used in UI/debugging).
*/
name: string
/**
* Optional description explaining what the filter does.
*/
description?: string
/**
* The filter function that returns true if the node should be visible.
* @param nodeDef - The node definition to evaluate
* @returns true if the node should be visible, false to hide it
*/
predicate: (nodeDef: ComfyNodeDefImpl) => boolean
}
export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeDefsByName = ref<Record<string, ComfyNodeDefImpl>>({})
const nodeDefsByDisplayName = ref<Record<string, ComfyNodeDefImpl>>({})
const showDeprecated = ref(false)
const showExperimental = ref(false)
const nodeDefFilters = ref<NodeDefFilter[]>([])
const nodeDefs = computed(() => Object.values(nodeDefsByName.value))
const nodeDataTypes = computed(() => {
@@ -300,11 +270,13 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
return types
})
const visibleNodeDefs = computed(() => {
return nodeDefs.value.filter((nodeDef) =>
nodeDefFilters.value.every((filter) => filter.predicate(nodeDef))
const visibleNodeDefs = computed(() =>
nodeDefs.value.filter(
(nodeDef: ComfyNodeDefImpl) =>
(showDeprecated.value || !nodeDef.deprecated) &&
(showExperimental.value || !nodeDef.experimental)
)
})
)
const nodeSearchService = computed(
() => new NodeSearchService(visibleNodeDefs.value)
)
@@ -338,68 +310,11 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
}
/**
* Registers a node definition filter.
* @param filter - The filter to register
*/
function registerNodeDefFilter(filter: NodeDefFilter) {
nodeDefFilters.value = [...nodeDefFilters.value, filter]
}
/**
* Unregisters a node definition filter by ID.
* @param id - The ID of the filter to remove
*/
function unregisterNodeDefFilter(id: string) {
nodeDefFilters.value = nodeDefFilters.value.filter((f) => f.id !== id)
}
/**
* Register the core node definition filters.
*/
function registerCoreNodeDefFilters() {
// Deprecated nodes filter
registerNodeDefFilter({
id: 'core.deprecated',
name: 'Hide Deprecated Nodes',
description: 'Hides nodes marked as deprecated unless explicitly enabled',
predicate: (nodeDef) => showDeprecated.value || !nodeDef.deprecated
})
// Experimental nodes filter
registerNodeDefFilter({
id: 'core.experimental',
name: 'Hide Experimental Nodes',
description:
'Hides nodes marked as experimental unless explicitly enabled',
predicate: (nodeDef) => showExperimental.value || !nodeDef.experimental
})
// Subgraph nodes filter
// @todo Remove this filter when subgraph v2 is released
registerNodeDefFilter({
id: 'core.subgraph',
name: 'Hide Subgraph Nodes',
description:
'Temporarily hides subgraph nodes from node library and search',
predicate: (nodeDef) => {
// Hide subgraph nodes (identified by category='subgraph' and python_module='nodes')
return !(
nodeDef.category === 'subgraph' && nodeDef.python_module === 'nodes'
)
}
})
}
// Register core filters on store initialization
registerCoreNodeDefFilters()
return {
nodeDefsByName,
nodeDefsByDisplayName,
showDeprecated,
showExperimental,
nodeDefFilters,
nodeDefs,
nodeDataTypes,
@@ -409,9 +324,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
fromLGraphNode,
registerNodeDefFilter,
unregisterNodeDefFilter
fromLGraphNode
}
})

View File

@@ -14,8 +14,6 @@ import type {
import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
// Task type used in the API.
export type APITaskType = 'queue' | 'history'
@@ -379,18 +377,7 @@ export class TaskItemImpl {
}
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
app.nodeOutputs = toRaw(this.outputs)
}
}

View File

@@ -4,18 +4,10 @@ import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId,
parseNodeExecutionId,
parseNodeLocatorId
} from '@/types/nodeIdentification'
import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
@@ -171,15 +163,6 @@ export interface WorkflowStore {
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
export const useWorkflowStore = defineStore('workflow', () => {
@@ -490,7 +473,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
return
}
// Parse the execution ID (e.g., "123:456:789")
// Parse the hierarchical ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// Start from the root graph
@@ -505,136 +488,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
watch(activeWorkflow, updateActiveGraph)
/**
* Convert a node ID to a NodeLocatorId
* @param nodeId The local node ID
* @param subgraph The subgraph containing the node (defaults to active subgraph)
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
*/
const nodeIdToNodeLocatorId = (
nodeId: NodeId,
subgraph?: Subgraph
): NodeLocatorId => {
const targetSubgraph = subgraph ?? activeSubgraph.value
if (!targetSubgraph) {
// Node is in the root graph, return the node ID as-is
return String(nodeId)
}
return createNodeLocatorId(targetSubgraph.id, nodeId)
}
/**
* Convert an execution ID to a NodeLocatorId
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
}
const parts = parseNodeExecutionId(nodeExecutionId)
if (!parts || parts.length === 0) return null
const nodeId = parts[parts.length - 1]
const subgraphNodeIds = parts.slice(0, -1)
if (subgraphNodeIds.length === 0) {
// Node is in root graph, return the node ID as-is
return String(nodeId)
}
try {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
subgraphNodeIds.map((id) => String(id))
)
const immediateSubgraph = subgraphs[subgraphs.length - 1]
return createNodeLocatorId(immediateSubgraph.id, nodeId)
} catch {
return null
}
}
/**
* Extract the node ID from a NodeLocatorId
* @param locatorId The NodeLocatorId
* @returns The local node ID or null if invalid
*/
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
}
/**
* Convert a NodeLocatorId to an execution ID for a specific context
* @param locatorId The NodeLocatorId
* @param targetSubgraph The subgraph context (defaults to active subgraph)
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)
if (!parsed) return null
const { subgraphUuid, localNodeId } = parsed
// If no subgraph UUID, this is a root graph node
if (!subgraphUuid) {
return String(localNodeId)
}
// Find the path from root to the subgraph with this UUID
const findSubgraphPath = (
graph: LGraph | Subgraph,
targetUuid: string,
path: NodeId[] = []
): NodeId[] | null => {
if (isSubgraph(graph) && graph.id === targetUuid) {
return path
}
for (const node of graph._nodes) {
if (node.isSubgraphNode() && node.subgraph) {
const result = findSubgraphPath(node.subgraph, targetUuid, [
...path,
node.id
])
if (result) return result
}
}
return null
}
const path = findSubgraphPath(comfyApp.graph, subgraphUuid)
if (!path) return null
// If we have a target subgraph, check if the path goes through it
if (
targetSubgraph &&
!path.some((_, idx) => {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
path.slice(0, idx + 1).map((id) => String(id))
)
return subgraphs[subgraphs.length - 1] === targetSubgraph
})
) {
return null
}
return createNodeExecutionId([...path, localNodeId])
}
return {
activeWorkflow,
isActive,
@@ -661,11 +514,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive,
activeSubgraph,
updateActiveGraph,
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
executionIdToCurrentId
}
}) satisfies () => WorkflowStore

View File

@@ -1,25 +1,13 @@
import type { InjectionKey, Ref } from 'vue'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { SearchMode } from '@/types/searchServiceTypes'
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
export type RegistryPack = components['schemas']['Node']
export type MergedNodePack = RegistryPack & AlgoliaNodePack
export const isMergedNodePack = (
nodePack: RegistryPack | AlgoliaNodePack
): nodePack is MergedNodePack => 'comfy_nodes' in nodePack
export type PackField = keyof RegistryPack | null
export const IsInstallingKey: InjectionKey<Ref<boolean>> =
Symbol('isInstalling')
export enum ManagerWsQueueStatus {
DONE = 'done',
DONE = 'all-done',
IN_PROGRESS = 'in_progress'
}
@@ -52,6 +40,7 @@ export interface SearchOption<T> {
export type TaskLog = {
taskName: string
taskId: string
logs: string[]
}
@@ -60,185 +49,25 @@ export interface UseNodePacksOptions {
maxConcurrent?: number
}
enum ManagerPackState {
/** Pack is installed and enabled */
INSTALLED = 'installed',
/** Pack is installed but disabled */
DISABLED = 'disabled',
/** Pack is not installed */
NOT_INSTALLED = 'not_installed',
/** Pack failed to import */
IMPORT_FAILED = 'import_failed',
/** Pack has an update available */
NEEDS_UPDATE = 'needs_update'
}
// Node pack types from different sources
export type RegistryPack = components['schemas']['Node']
enum ManagerPackInstallType {
/** Installed via git clone */
GIT = 'git-clone',
/** Installed via file copy */
COPY = 'copy',
/** Installed from the Comfy Registry */
REGISTRY = 'cnr'
}
export enum SelectedVersion {
/** Latest version of the pack from the registry */
LATEST = 'latest',
/** Latest commit of the pack from its GitHub repository */
NIGHTLY = 'nightly'
}
export enum ManagerChannel {
/** All packs except those with instability or security issues */
DEFAULT = 'default',
/** Packs that were recently updated */
RECENT = 'recent',
/** Packs that were superseded by distinct replacements of some type */
LEGACY = 'legacy',
/** Packs that were forked as a result of the original pack going unmaintained */
FORKED = 'forked',
/** Packs with instability or security issues suitable only for developers */
DEV = 'dev',
/** Packs suitable for beginners */
TUTORIAL = 'tutorial'
}
export enum ManagerDatabaseSource {
/** Get pack info from the Comfy Registry */
REMOTE = 'remote',
/** If set to `local`, the channel is ignored */
LOCAL = 'local',
/** Get pack info from the cached response from the Comfy Registry (1 day TTL) */
CACHE = 'cache'
}
export interface ManagerQueueStatus {
/** `done_count` + `in_progress_count` + number of items queued */
total_count: number
/** Task worker thread is alive, a queued operation is running */
is_processing: boolean
/** Number of items in the queue that have been completed */
done_count: number
/** Number of items in the queue that are currently running */
in_progress_count: number
}
export interface ManagerPackInfo {
/** Either github-author/github-repo or name of pack from the registry (not id) */
id: WorkflowNodeProperties['aux_id'] | WorkflowNodeProperties['cnr_id']
/** Semantic version or Git commit hash */
version: WorkflowNodeProperties['ver']
}
export interface ManagerPackInstalled {
/**
* The version of the pack that is installed.
* Git commit hash or semantic version.
*/
ver: WorkflowNodeProperties['ver']
/**
* The name of the pack if the pack is installed from the registry.
* Corresponds to `Node#name` in comfy-api.
*/
cnr_id: WorkflowNodeProperties['cnr_id']
/**
* The name of the pack if the pack is installed from github.
* In the format author/repo-name. If the pack is installed from the registry, this is `null`.
*/
aux_id: WorkflowNodeProperties['aux_id'] | null
enabled: boolean
}
// MergedNodePack is the intersection of AlgoliaNodePack and RegistryPack
// created by lodash merge operation: merge({}, algoliaNodePack, registryPack)
export type MergedNodePack = AlgoliaNodePack & RegistryPack
/**
* Returned by `/customnode/installed`
* Type guard to check if a node pack is from Algolia (has comfy_nodes)
*/
export type InstalledPacksResponse = Record<
NonNullable<RegistryPack['name']>,
ManagerPackInstalled
>
/**
* Returned by `/customnode/getlist`
*/
export interface ManagerPack extends ManagerPackInfo {
/** Pack author name or 'Unclaimed' if the pack was added automatically via GitHub crawl. */
author: components['schemas']['Node']['author']
/** Files included in the pack */
files: string[]
/** The type of installation that was used to install the pack */
reference: string
/** The display name of the pack */
title: string
/** The latest version of the pack */
cnr_latest: SelectedVersion
/** The github link to the repository of the pack */
repository: string
/** The state of the pack */
state: ManagerPackState
/** The state of the pack update */
'update-state': 'false' | 'true' | null
/** The number of stars the pack has on GitHub. Distinct from registry stars */
stars: number
/**
* The last time the pack was updated. In ISO 8601 format.
* @example '2024-05-22 20:00:00'
*/
last_update: string
health: string
description: string
trust: boolean
install_type: ManagerPackInstallType
}
/**
* Returned by `/customnode/getmappings`.
*/
export type ManagerMappings = Record<
NonNullable<components['schemas']['Node']['name']>,
[
/** List of ComfyNode names included in the pack */
Array<components['schemas']['ComfyNode']['comfy_node_name']>,
{
/** The display name of the pack */
title_aux: string
}
]
>
/**
* Payload for `/manager/queue/install`
*/
export interface InstallPackParams extends ManagerPackInfo {
/**
* Semantic version, Git commit hash, `latest`, or `nightly`.
*/
selected_version: WorkflowNodeProperties['ver'] | SelectedVersion
/**
* The GitHub link to the repository of the pack to install.
* Required if `selected_version` is `nightly`.
*/
repository: string
/**
* List of PyPi dependency names associated with the pack.
* Used in coordination with pip package whitelist and version lock features.
*/
pip?: string[]
mode: ManagerDatabaseSource
channel: ManagerChannel
skip_post_install?: boolean
}
/**
* Params for `/manager/queue/update_all`
*/
export interface UpdateAllPacksParams {
mode?: ManagerDatabaseSource
export function isMergedNodePack(
pack: MergedNodePack | RegistryPack
): pack is MergedNodePack {
return 'comfy_nodes' in pack && Array.isArray(pack.comfy_nodes)
}
export interface ManagerState {
selectedTabId: ManagerTab
searchQuery: string
searchMode: SearchMode
searchMode: 'nodes' | 'packs'
sortField: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,16 +31,6 @@ export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
export type { InputSpec } from '@/schemas/nodeDefSchema'
export type {
NodeLocatorId,
NodeExecutionId,
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from './nodeIdentification'
export type {
EmbeddingsResponse,
ExtensionsResponse,

View File

@@ -1,123 +0,0 @@
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
/**
* A globally unique identifier for nodes that maintains consistency across
* multiple instances of the same subgraph.
*
* Format:
* - For subgraph nodes: `<immediate-contained-subgraph-uuid>:<local-node-id>`
* - For root graph nodes: `<local-node-id>`
*
* Examples:
* - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph)
* - "456" (node in root graph)
*
* Unlike execution IDs which change based on the instance path,
* NodeLocatorId remains the same for all instances of a particular node.
*/
export type NodeLocatorId = string
/**
* An execution identifier representing a node's position in nested subgraphs.
* Also known as ExecutionId in some contexts.
*
* Format: Colon-separated path of node IDs
* Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123)
*/
export type NodeExecutionId = string
/**
* Type guard to check if a value is a NodeLocatorId
*/
export function isNodeLocatorId(value: unknown): value is NodeLocatorId {
if (typeof value !== 'string') return false
// Check if it's a simple node ID (root graph node)
const parts = value.split(':')
if (parts.length === 1) {
// Simple node ID - must be non-empty
return value.length > 0
}
// Check for UUID:nodeId format
if (parts.length !== 2) return false
// Check that node ID part is not empty
if (!parts[1]) return false
// Basic UUID format check (8-4-4-4-12 hex characters)
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
return uuidPattern.test(parts[0])
}
/**
* Type guard to check if a value is a NodeExecutionId
*/
export function isNodeExecutionId(value: unknown): value is NodeExecutionId {
if (typeof value !== 'string') return false
// Must contain at least one colon to be an execution ID
return value.includes(':')
}
/**
* Parse a NodeLocatorId into its components
* @param id The NodeLocatorId to parse
* @returns The subgraph UUID and local node ID, or null if invalid
*/
export function parseNodeLocatorId(
id: string
): { subgraphUuid: string | null; localNodeId: NodeId } | null {
if (!isNodeLocatorId(id)) return null
const parts = id.split(':')
if (parts.length === 1) {
// Simple node ID (root graph)
return {
subgraphUuid: null,
localNodeId: isNaN(Number(id)) ? id : Number(id)
}
}
const [subgraphUuid, localNodeId] = parts
return {
subgraphUuid,
localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId)
}
}
/**
* Create a NodeLocatorId from components
* @param subgraphUuid The UUID of the immediate containing subgraph
* @param localNodeId The local node ID within that subgraph
* @returns A properly formatted NodeLocatorId
*/
export function createNodeLocatorId(
subgraphUuid: string,
localNodeId: NodeId
): NodeLocatorId {
return `${subgraphUuid}:${localNodeId}`
}
/**
* Parse a NodeExecutionId into its component node IDs
* @param id The NodeExecutionId to parse
* @returns Array of node IDs from root to target, or null if not an execution ID
*/
export function parseNodeExecutionId(id: string): NodeId[] | null {
if (!isNodeExecutionId(id)) return null
return id
.split(':')
.map((part) => (isNaN(Number(part)) ? part : Number(part)))
}
/**
* Create a NodeExecutionId from an array of node IDs
* @param nodeIds Array of node IDs from root to target
* @returns A properly formatted NodeExecutionId
*/
export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
return nodeIds.join(':')
}

View File

@@ -1,243 +0,0 @@
import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
/**
* Parses an execution ID into its component parts.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns Array of node IDs in the path, or null if invalid
*/
export function parseExecutionId(executionId: string): string[] | null {
if (!executionId || typeof executionId !== 'string') return null
return executionId.split(':').filter((part) => part.length > 0)
}
/**
* Extracts the local node ID from an execution ID.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns The local node ID or null if invalid
*/
export function getLocalNodeIdFromExecutionId(
executionId: string
): string | null {
const parts = parseExecutionId(executionId)
return parts ? parts[parts.length - 1] : null
}
/**
* Extracts the subgraph path from an execution ID.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns Array of subgraph node IDs (excluding the final node ID), or empty array
*/
export function getSubgraphPathFromExecutionId(executionId: string): string[] {
const parts = parseExecutionId(executionId)
return parts ? parts.slice(0, -1) : []
}
/**
* Visits each node in a graph (non-recursive, single level).
*
* @param graph - The graph to visit nodes from
* @param visitor - Function called for each node
*/
export function visitGraphNodes(
graph: LGraph | Subgraph,
visitor: (node: LGraphNode) => void
): void {
for (const node of graph.nodes) {
visitor(node)
}
}
/**
* Traverses a path of subgraphs to reach a target graph.
*
* @param startGraph - The graph to start from
* @param path - Array of subgraph node IDs to traverse
* @returns The target graph or null if path is invalid
*/
export function traverseSubgraphPath(
startGraph: LGraph | Subgraph,
path: string[]
): LGraph | Subgraph | null {
let currentGraph: LGraph | Subgraph = startGraph
for (const nodeId of path) {
const node = currentGraph.getNodeById(nodeId)
if (!node?.isSubgraphNode?.() || !node.subgraph) return null
currentGraph = node.subgraph
}
return currentGraph
}
/**
* Traverses all nodes in a graph hierarchy (including subgraphs) and invokes
* a callback on each node that has the specified property.
*
* @param graph - The root graph to start traversal from
* @param callbackProperty - The name of the callback property to invoke on each node
*/
export function triggerCallbackOnAllNodes(
graph: LGraph | Subgraph,
callbackProperty: keyof LGraphNode
): void {
visitGraphNodes(graph, (node) => {
// Recursively process subgraphs first
if (node.isSubgraphNode?.() && node.subgraph) {
triggerCallbackOnAllNodes(node.subgraph, callbackProperty)
}
// Invoke callback if it exists on the node
const callback = node[callbackProperty]
if (typeof callback === 'function') {
callback.call(node)
}
})
}
/**
* Collects all nodes in a graph hierarchy (including subgraphs) into a flat array.
*
* @param graph - The root graph to collect nodes from
* @param filter - Optional filter function to include only specific nodes
* @returns Array of all nodes in the graph hierarchy
*/
export function collectAllNodes(
graph: LGraph | Subgraph,
filter?: (node: LGraphNode) => boolean
): LGraphNode[] {
const nodes: LGraphNode[] = []
visitGraphNodes(graph, (node) => {
// Recursively collect from subgraphs
if (node.isSubgraphNode?.() && node.subgraph) {
nodes.push(...collectAllNodes(node.subgraph, filter))
}
// Add node if it passes the filter (or no filter provided)
if (!filter || filter(node)) {
nodes.push(node)
}
})
return nodes
}
/**
* Finds a node by ID anywhere in the graph hierarchy.
*
* @param graph - The root graph to search
* @param nodeId - The ID of the node to find
* @returns The node if found, null otherwise
*/
export function findNodeInHierarchy(
graph: LGraph | Subgraph,
nodeId: string | number
): LGraphNode | null {
// Check current graph
const node = graph.getNodeById(nodeId)
if (node) return node
// Search in subgraphs
for (const node of graph.nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
const found = findNodeInHierarchy(node.subgraph, nodeId)
if (found) return found
}
}
return null
}
/**
* Find a subgraph by its UUID anywhere in the graph hierarchy.
*
* @param graph - The root graph to search
* @param targetUuid - The UUID of the subgraph to find
* @returns The subgraph if found, null otherwise
*/
export function findSubgraphByUuid(
graph: LGraph | Subgraph,
targetUuid: string
): Subgraph | null {
// Check all nodes in the current graph
for (const node of graph._nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
if (node.subgraph.id === targetUuid) {
return node.subgraph
}
// Recursively search in nested subgraphs
const found = findSubgraphByUuid(node.subgraph, targetUuid)
if (found) return found
}
}
return null
}
/**
* Get a node by its execution ID from anywhere in the graph hierarchy.
* Execution IDs use hierarchical format like "123:456:789" for nested nodes.
*
* @param rootGraph - The root graph to search from
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns The node if found, null otherwise
*/
export function getNodeByExecutionId(
rootGraph: LGraph,
executionId: string
): LGraphNode | null {
if (!rootGraph) return null
const localNodeId = getLocalNodeIdFromExecutionId(executionId)
if (!localNodeId) return null
const subgraphPath = getSubgraphPathFromExecutionId(executionId)
// If no subgraph path, it's in the root graph
if (subgraphPath.length === 0) {
return rootGraph.getNodeById(localNodeId) || null
}
// Traverse to the target subgraph
const targetGraph = traverseSubgraphPath(rootGraph, subgraphPath)
if (!targetGraph) return null
// Get the node from the target graph
return targetGraph.getNodeById(localNodeId) || null
}
/**
* Get a node by its locator ID from anywhere in the graph hierarchy.
* Locator IDs use UUID format like "uuid:nodeId" for subgraph nodes.
*
* @param rootGraph - The root graph to search from
* @param locatorId - The locator ID (e.g., "uuid:123" or "123")
* @returns The node if found, null otherwise
*/
export function getNodeByLocatorId(
rootGraph: LGraph,
locatorId: NodeLocatorId | string
): LGraphNode | null {
if (!rootGraph) return null
const parsedIds = parseNodeLocatorId(locatorId)
if (!parsedIds) return null
const { subgraphUuid, localNodeId } = parsedIds
// If no subgraph UUID, it's in the root graph
if (!subgraphUuid) {
return rootGraph.getNodeById(localNodeId) || null
}
// Find the subgraph with the matching UUID
const targetSubgraph = findSubgraphByUuid(rootGraph, subgraphUuid)
if (!targetSubgraph) return null
return targetSubgraph.getNodeById(localNodeId) || null
}

View File

@@ -15,7 +15,6 @@ import {
isFloatInputSpec,
isIntInputSpec
} from '@/schemas/nodeDefSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { lcm } from './mathUtil'
@@ -139,11 +138,3 @@ export const mergeInputSpec = (
return mergeCommonInputSpec(spec1, spec2)
}
/**
* Checks if a node definition represents a subgraph node.
* Subgraph nodes are created with category='subgraph' and python_module='nodes'.
*/
export const isSubgraphNode = (nodeDef: ComfyNodeDefImpl): boolean => {
return nodeDef.category === 'subgraph' && nodeDef.python_module === 'nodes'
}

View File

@@ -3,20 +3,12 @@ import { nextTick, reactive } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
// Mock i18n module
vi.mock('@/i18n', () => ({
t: (key: string, fallback: string) =>
key === 'g.nodesRunning' ? 'nodes running' : fallback
}))
// Mock the execution store
const executionStore = reactive({
isIdle: true,
executionProgress: 0,
executingNode: null as any,
executingNodeProgress: 0,
nodeProgressStates: {} as any,
activePrompt: null as any
executingNodeProgress: 0
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -45,8 +37,6 @@ describe('useBrowserTabTitle', () => {
executionStore.executionProgress = 0
executionStore.executingNode = null as any
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activePrompt = null
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
@@ -107,41 +97,13 @@ describe('useBrowserTabTitle', () => {
expect(document.title).toBe('[30%]ComfyUI')
})
it('shows node execution title when executing a node using nodeProgressStates', async () => {
it('shows node execution title when executing a node', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activePrompt = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
executionStore.executingNodeProgress = 0.5
executionStore.executingNode = { type: 'Foo' }
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
})
it('shows multiple nodes running when multiple nodes are executing', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.nodeProgressStates = {
'1': {
state: 'running',
value: 5,
max: 10,
node: '1',
prompt_id: 'test'
},
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')
})
})

View File

@@ -1022,402 +1022,5 @@ describe('useNodePricing', () => {
getRelevantWidgetNames('RecraftGenerateColorFromImageNode')
).toEqual(['n'])
})
it('should include relevant widget names for new nodes', () => {
const { getRelevantWidgetNames } = useNodePricing()
expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen3a')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen4')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('RunwayFirstLastFrameNode')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model',
'model_version',
'texture_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model',
'model_version',
'texture_quality'
])
})
})
describe('New API nodes pricing', () => {
describe('RunwayML nodes', () => {
it('should return static price for RunwayTextToImageNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayTextToImageNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 10 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.50/Run') // 0.05 * 10
})
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/second')
})
})
describe('Rodin nodes', () => {
it('should return base price for Rodin3D_Regular', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Regular')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
it('should return addon price for Rodin3D_Detail', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Detail')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.2/Run')
})
it('should return addon price for Rodin3D_Smooth', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Smooth')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.2/Run')
})
})
describe('Tripo nodes', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5-20250123' },
{ name: 'texture_quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
})
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5-20250123' },
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
})
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0-20240919' },
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v1.4-legacy' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
})
it('should return static price for TripoRefineNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRefineNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
})
it('should return fallback for TripoTextToModelNode without model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-0.3/Run (varies with model & texture quality)')
})
it('should return texture-based pricing for TripoTextureNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const standardNode = createMockNode('TripoTextureNode', [
{ name: 'texture_quality', value: 'standard' }
])
const detailedNode = createMockNode('TripoTextureNode', [
{ name: 'texture_quality', value: 'detailed' }
])
expect(getNodeDisplayPrice(standardNode)).toBe('$0.1/Run')
expect(getNodeDisplayPrice(detailedNode)).toBe('$0.2/Run')
})
it('should handle various Tripo model version formats', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test different model version formats
const testCases = [
{ model: 'v2.0-20240919', expected: '$0.2/Run' },
{ model: 'v2.5-20250123', expected: '$0.2/Run' },
{ model: 'v1.4', expected: '$0.2/Run' },
{ model: 'unknown-model', expected: '$0.2/Run' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: model },
{ name: 'texture_quality', value: 'standard' }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
})
describe('Gemini and OpenAI Chat nodes', () => {
it('should return specific pricing for supported Gemini models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{
model: 'gemini-2.5-pro-preview-05-06',
expected: '$0.0035/$0.0008 per 1K tokens'
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: '$0.0015/$0.0004 per 1K tokens'
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('GeminiNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return per-second pricing for Gemini Veo models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [
{ name: 'model', value: 'veo-2.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.5/second')
})
it('should return fallback for GeminiNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
it('should return token-based pricing for OpenAIChatNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: 'unknown-model' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
it('should return correct pricing for all exposed OpenAI models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ model: 'o4-mini', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'o1-pro', expected: '$0.15/$0.60 per 1K tokens' },
{ model: 'o1', expected: '$0.015/$0.06 per 1K tokens' },
{ model: 'o3-mini', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'o3', expected: '$0.01/$0.04 per 1K tokens' },
{ model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' },
{ model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' },
{ model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' },
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should handle model ordering correctly (specific before general)', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test that more specific patterns are matched before general ones
const testCases = [
{
model: 'gpt-4.1-nano-test',
expected: '$0.0001/$0.0004 per 1K tokens'
},
{
model: 'gpt-4.1-mini-test',
expected: '$0.0004/$0.0016 per 1K tokens'
},
{ model: 'gpt-4.1-test', expected: '$0.002/$0.008 per 1K tokens' },
{ model: 'o1-pro-test', expected: '$0.15/$0.60 per 1K tokens' },
{ model: 'o1-test', expected: '$0.015/$0.06 per 1K tokens' },
{ model: 'o3-mini-test', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'unknown-model', expected: 'Token-based' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return fallback for OpenAIChatNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIChatNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
})
describe('Additional RunwayML edge cases', () => {
it('should handle edge cases for RunwayML duration-based pricing', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test edge cases
const testCases = [
{ duration: 0, expected: '$0.25/Run' }, // Falls back to 5 seconds (0 || 5)
{ duration: 1, expected: '$0.05/Run' },
{ duration: 30, expected: '$1.50/Run' }
]
testCases.forEach(({ duration, expected }) => {
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: duration }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should handle invalid duration values gracefully', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 'invalid-string' }
])
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
})
it('should handle missing duration widget gracefully', () => {
const { getNodeDisplayPrice } = useNodePricing()
const nodes = [
'RunwayImageToVideoNodeGen3a',
'RunwayImageToVideoNodeGen4',
'RunwayFirstLastFrameNode'
]
nodes.forEach((nodeType) => {
const node = createMockNode(nodeType, [])
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
})
})
})
describe('Complete Rodin node coverage', () => {
it('should return correct pricing for all Rodin variants', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ nodeType: 'Rodin3D_Regular', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Sketch', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Detail', expected: '$1.2/Run' },
{ nodeType: 'Rodin3D_Smooth', expected: '$1.2/Run' }
]
testCases.forEach(({ nodeType, expected }) => {
const node = createMockNode(nodeType)
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
})
describe('Comprehensive Tripo edge case testing', () => {
it('should handle TripoImageToModelNode with various model versions', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ model: 'v1.4-legacy', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.0-20240919', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.0-20240919', texture: 'detailed', expected: '$0.4/Run' },
{ model: 'v2.5-20250123', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.5-20250123', texture: 'detailed', expected: '$0.4/Run' }
]
testCases.forEach(({ model, texture, expected }) => {
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: model },
{ name: 'texture_quality', value: texture }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return correct fallback for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3-0.4/Run (varies with model & texture quality)')
})
it('should handle missing texture quality widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0-20240919' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run') // Default to standard texture pricing
})
it('should handle missing model version widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-0.3/Run (varies with model & texture quality)')
})
})
})
})

View File

@@ -24,22 +24,22 @@ describe('useServerLogs', () => {
})
it('should initialize with empty logs array', () => {
const { logs } = useServerLogs()
const { logs } = useServerLogs({ ui_id: 'test-ui-id' })
expect(logs.value).toEqual([])
})
it('should not subscribe to logs by default', () => {
useServerLogs()
useServerLogs({ ui_id: 'test-ui-id' })
expect(api.subscribeLogs).not.toHaveBeenCalled()
})
it('should subscribe to logs when immediate is true', () => {
useServerLogs({ immediate: true })
useServerLogs({ ui_id: 'test-ui-id', immediate: true })
expect(api.subscribeLogs).toHaveBeenCalledWith(true)
})
it('should start listening when startListening is called', async () => {
const { startListening } = useServerLogs()
const { startListening } = useServerLogs({ ui_id: 'test-ui-id' })
await startListening()
@@ -47,16 +47,21 @@ describe('useServerLogs', () => {
})
it('should stop listening when stopListening is called', async () => {
const { startListening, stopListening } = useServerLogs()
const { startListening, stopListening } = useServerLogs({
ui_id: 'test-ui-id'
})
await startListening()
await stopListening()
expect(api.subscribeLogs).toHaveBeenCalledWith(false)
// TODO: Update this test when subscribeLogs(false) is re-enabled
// Currently commented out in useServerLogs to prevent logs from stopping
// after 1st of multiple queue tasks
expect(api.subscribeLogs).toHaveBeenCalledWith(true)
})
it('should register event listener when starting', async () => {
const { startListening } = useServerLogs()
const { startListening } = useServerLogs({ ui_id: 'test-ui-id' })
await startListening()
@@ -68,16 +73,30 @@ describe('useServerLogs', () => {
})
it('should handle log messages correctly', async () => {
const { logs, startListening } = useServerLogs()
const { logs, startListening } = useServerLogs({ ui_id: 'test-ui-id' })
await startListening()
// Get the callback that was registered with useEventListener
const eventCallback = vi.mocked(useEventListener).mock.calls[0][2] as (
// Get the callbacks that were registered with useEventListener
const mockCalls = vi.mocked(useEventListener).mock.calls
const logsCallback = mockCalls.find((call) => call[1] === 'logs')?.[2] as (
event: CustomEvent<LogsWsMessage>
) => void
const taskStartedCallback = mockCalls.find(
(call) => call[1] === 'cm-task-started'
)?.[2] as (event: CustomEvent<any>) => void
// Simulate receiving a log event
// First, simulate task started event
const taskStartedEvent = new CustomEvent('cm-task-started', {
detail: {
type: 'cm-task-started',
ui_id: 'test-ui-id'
}
})
taskStartedCallback(taskStartedEvent)
await nextTick()
// Now simulate receiving a log event
const mockEvent = new CustomEvent('logs', {
detail: {
type: 'logs',
@@ -85,7 +104,7 @@ describe('useServerLogs', () => {
} as unknown as LogsWsMessage
}) as CustomEvent<LogsWsMessage>
eventCallback(mockEvent)
logsCallback(mockEvent)
await nextTick()
expect(logs.value).toEqual(['Log message 1', 'Log message 2'])
@@ -93,15 +112,32 @@ describe('useServerLogs', () => {
it('should use the message filter if provided', async () => {
const { logs, startListening } = useServerLogs({
ui_id: 'test-ui-id',
messageFilter: (msg) => msg !== 'remove me'
})
await startListening()
const eventCallback = vi.mocked(useEventListener).mock.calls[0][2] as (
// Get the callbacks that were registered with useEventListener
const mockCalls = vi.mocked(useEventListener).mock.calls
const logsCallback = mockCalls.find((call) => call[1] === 'logs')?.[2] as (
event: CustomEvent<LogsWsMessage>
) => void
const taskStartedCallback = mockCalls.find(
(call) => call[1] === 'cm-task-started'
)?.[2] as (event: CustomEvent<any>) => void
// First, simulate task started event
const taskStartedEvent = new CustomEvent('cm-task-started', {
detail: {
type: 'cm-task-started',
ui_id: 'test-ui-id'
}
})
taskStartedCallback(taskStartedEvent)
await nextTick()
// Now simulate receiving a log event
const mockEvent = new CustomEvent('logs', {
detail: {
type: 'logs',
@@ -113,7 +149,7 @@ describe('useServerLogs', () => {
} as unknown as LogsWsMessage
}) as CustomEvent<LogsWsMessage>
eventCallback(mockEvent)
logsCallback(mockEvent)
await nextTick()
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])

View File

@@ -0,0 +1,360 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
// Import mocked utils
import { compareVersions, isSemVer } from '@/utils/formatUtil'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
}
})
// Mock the dependencies
vi.mock('@/composables/nodePack/useInstalledPacks', () => ({
useInstalledPacks: vi.fn()
}))
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/utils/formatUtil', () => ({
compareVersions: vi.fn(),
isSemVer: vi.fn()
}))
const mockUseInstalledPacks = vi.mocked(useInstalledPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockCompareVersions = vi.mocked(compareVersions)
const mockIsSemVer = vi.mocked(isSemVer)
describe('useUpdateAvailableNodes', () => {
const mockInstalledPacks = [
{
id: 'pack-1',
name: 'Outdated Pack',
latest_version: { version: '2.0.0' }
},
{
id: 'pack-2',
name: 'Up to Date Pack',
latest_version: { version: '1.0.0' }
},
{
id: 'pack-3',
name: 'Nightly Pack',
latest_version: { version: '1.5.0' }
},
{
id: 'pack-4',
name: 'No Latest Version',
latest_version: null
}
]
const mockStartFetchInstalled = vi.fn()
const mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup
mockIsPackInstalled.mockReturnValue(true)
mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) {
case 'pack-1':
return '1.0.0' // outdated
case 'pack-2':
return '1.0.0' // up to date
case 'pack-3':
return 'nightly-abc123' // nightly
case 'pack-4':
return '1.0.0' // no latest version
default:
return '1.0.0'
}
})
mockIsSemVer.mockImplementation(
(version: string): version is `${number}.${number}.${number}` => {
return !version.includes('nightly')
}
)
mockCompareVersions.mockImplementation(
(latest: string | undefined, installed: string | undefined) => {
if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
return 0
}
)
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion
} as any)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
})
describe('core filtering logic', () => {
it('identifies outdated packs correctly', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Should only include pack-1 (outdated)
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
})
it('excludes up-to-date packs', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes nightly packs from updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes packs with no latest version', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[3]]), // pack-4: no latest version
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes uninstalled packs', () => {
mockIsPackInstalled.mockReturnValue(false)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('returns empty array when no installed packs exist', () => {
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toEqual([])
})
})
describe('hasUpdateAvailable computed', () => {
it('returns true when updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
it('returns false when no updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
})
describe('automatic data fetching', () => {
it('fetches installed packs automatically when none exist', () => {
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).toHaveBeenCalledOnce()
})
it('does not fetch when packs already exist', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
it('does not fetch when already loading', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
})
describe('state management', () => {
it('exposes loading state from useInstalledPacks', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { isLoading } = useUpdateAvailableNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useInstalledPacks', () => {
const testError = 'Failed to fetch installed packs'
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { error } = useUpdateAvailableNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when installed packs change', async () => {
const installedPacksRef = ref([])
mockUseInstalledPacks.mockReturnValue({
installedPacks: installedPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks, hasUpdateAvailable } =
useUpdateAvailableNodes()
// Initially empty
expect(updateAvailableNodePacks.value).toEqual([])
expect(hasUpdateAvailable.value).toBe(false)
// Update installed packs
installedPacksRef.value = [mockInstalledPacks[0]] as any // pack-1: outdated
await nextTick()
// Should update available updates
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(hasUpdateAvailable.value).toBe(true)
})
})
describe('version comparison logic', () => {
it('calls compareVersions with correct parameters', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockCompareVersions).toHaveBeenCalledWith('2.0.0', '1.0.0')
})
it('calls isSemVer to check nightly versions', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsSemVer).toHaveBeenCalledWith('nightly-abc123')
})
it('calls isPackInstalled for each pack', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-1')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-2')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-3')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
})
})
})

View File

@@ -28,7 +28,7 @@ describe('useManagerQueue', () => {
const getEventListenerCallback = () =>
vi.mocked(api.addEventListener).mock.calls[0][1]
const simulateServerStatus = async (status: 'done' | 'in_progress') => {
const simulateServerStatus = async (status: 'all-done' | 'in_progress') => {
const event = new CustomEvent('cm-queue-status', {
detail: { status }
})
@@ -49,7 +49,7 @@ describe('useManagerQueue', () => {
const queue = useManagerQueue()
expect(queue.queueLength.value).toBe(0)
expect(queue.statusMessage.value).toBe('done')
expect(queue.statusMessage.value).toBe('all-done')
expect(queue.allTasksDone.value).toBe(true)
})
})
@@ -104,7 +104,7 @@ describe('useManagerQueue', () => {
await nextTick()
// Should maintain the default status
expect(queue.statusMessage.value).toBe('done')
expect(queue.statusMessage.value).toBe('all-done')
})
it('should handle missing status property gracefully', async () => {
@@ -119,7 +119,7 @@ describe('useManagerQueue', () => {
await nextTick()
// Should maintain the default status
expect(queue.statusMessage.value).toBe('done')
expect(queue.statusMessage.value).toBe('all-done')
})
})
@@ -127,7 +127,7 @@ describe('useManagerQueue', () => {
it('should start the next task when server is idle and queue has items', async () => {
const { queue, mockTask } = createQueueWithMockTask()
await simulateServerStatus('done')
await simulateServerStatus('all-done')
// Task should have been started
expect(mockTask.task).toHaveBeenCalled()
@@ -138,7 +138,7 @@ describe('useManagerQueue', () => {
const { mockTask } = createQueueWithMockTask()
// Start the task
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask.task).toHaveBeenCalled()
// Simulate task completion
@@ -148,7 +148,7 @@ describe('useManagerQueue', () => {
await simulateServerStatus('in_progress')
expect(mockTask.onComplete).not.toHaveBeenCalled()
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask.onComplete).toHaveBeenCalled()
})
@@ -159,7 +159,7 @@ describe('useManagerQueue', () => {
queue.enqueueTask(mockTask)
// Start the task
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask.task).toHaveBeenCalled()
// Simulate task completion
@@ -167,7 +167,7 @@ describe('useManagerQueue', () => {
// Simulate server cycle
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
// Should not throw errors even without onComplete
expect(queue.allTasksDone.value).toBe(true)
@@ -184,14 +184,14 @@ describe('useManagerQueue', () => {
expect(queue.queueLength.value).toBe(2)
// Process first task
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask1.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(1)
// Complete first task
await mockTask1.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask1.onComplete).toHaveBeenCalled()
// Process second task
@@ -201,7 +201,7 @@ describe('useManagerQueue', () => {
// Complete second task
await mockTask2.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask2.onComplete).toHaveBeenCalled()
// Queue should be empty and all tasks done
@@ -219,7 +219,7 @@ describe('useManagerQueue', () => {
queue.enqueueTask(mockTask)
// Start the task
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask.task).toHaveBeenCalled()
// Let the promise rejection happen
@@ -231,7 +231,7 @@ describe('useManagerQueue', () => {
// Simulate server cycle
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
// onComplete should still be called for failed tasks
expect(mockTask.onComplete).toHaveBeenCalled()
@@ -252,7 +252,7 @@ describe('useManagerQueue', () => {
])
// Task 1
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask1.task).toHaveBeenCalled()
// Verify state of onComplete callbacks
@@ -266,7 +266,7 @@ describe('useManagerQueue', () => {
// Task 2
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask2.task).toHaveBeenCalled()
// Verify state of onComplete callbacks
@@ -279,7 +279,7 @@ describe('useManagerQueue', () => {
// Task 3
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
// Verify state of onComplete callbacks
expect(mockTask3.task).toHaveBeenCalled()
@@ -297,7 +297,7 @@ describe('useManagerQueue', () => {
// Add first task and start processing
queue.enqueueTask(mockTask1)
await simulateServerStatus('done')
await simulateServerStatus('all-done')
expect(mockTask1.task).toHaveBeenCalled()
// Add second task while first is processing
@@ -307,7 +307,7 @@ describe('useManagerQueue', () => {
// Complete first task
await mockTask1.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
// Second task should now be processed
expect(mockTask2.task).toHaveBeenCalled()
@@ -318,9 +318,9 @@ describe('useManagerQueue', () => {
// Cycle server status without any tasks
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('all-done')
// Should not cause any errors
expect(queue.allTasksDone.value).toBe(true)

View File

@@ -1,151 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
// Mock DOM widget for testing
const createMockDOMWidget = (id: string) => {
const element = document.createElement('input')
return {
id,
element,
node: {
id: 'node-1',
title: 'Test Node',
pos: [0, 0],
size: [200, 100]
} as any,
name: 'test_widget',
type: 'text',
value: 'test',
options: {},
y: 0,
margin: 10,
isVisible: () => true,
containerNode: undefined as any
}
}
describe('domWidgetStore', () => {
let store: ReturnType<typeof useDomWidgetStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useDomWidgetStore()
})
describe('widget registration', () => {
it('should register a widget with default state', () => {
const widget = createMockDOMWidget('widget-1')
store.registerWidget(widget)
expect(store.widgetStates.has('widget-1')).toBe(true)
const state = store.widgetStates.get('widget-1')
expect(state).toBeDefined()
expect(state!.widget).toBe(widget)
expect(state!.visible).toBe(true)
expect(state!.active).toBe(true)
expect(state!.readonly).toBe(false)
expect(state!.zIndex).toBe(0)
expect(state!.pos).toEqual([0, 0])
expect(state!.size).toEqual([0, 0])
})
it('should not register the same widget twice', () => {
const widget = createMockDOMWidget('widget-1')
store.registerWidget(widget)
store.registerWidget(widget)
// Should still only have one entry
const states = Array.from(store.widgetStates.values())
expect(states.length).toBe(1)
})
})
describe('widget unregistration', () => {
it('should unregister a widget by id', () => {
const widget = createMockDOMWidget('widget-1')
store.registerWidget(widget)
expect(store.widgetStates.has('widget-1')).toBe(true)
store.unregisterWidget('widget-1')
expect(store.widgetStates.has('widget-1')).toBe(false)
})
it('should handle unregistering non-existent widget gracefully', () => {
// Should not throw
expect(() => {
store.unregisterWidget('non-existent')
}).not.toThrow()
})
})
describe('widget state management', () => {
it('should activate a widget', () => {
const widget = createMockDOMWidget('widget-1')
store.registerWidget(widget)
// Set to inactive first
const state = store.widgetStates.get('widget-1')!
state.active = false
store.activateWidget('widget-1')
expect(state.active).toBe(true)
})
it('should deactivate a widget', () => {
const widget = createMockDOMWidget('widget-1')
store.registerWidget(widget)
store.deactivateWidget('widget-1')
const state = store.widgetStates.get('widget-1')
expect(state!.active).toBe(false)
})
it('should handle activating non-existent widget gracefully', () => {
expect(() => {
store.activateWidget('non-existent')
}).not.toThrow()
})
})
describe('computed states', () => {
it('should separate active and inactive widget states', () => {
const widget1 = createMockDOMWidget('widget-1')
const widget2 = createMockDOMWidget('widget-2')
store.registerWidget(widget1)
store.registerWidget(widget2)
// Deactivate widget2
store.deactivateWidget('widget-2')
expect(store.activeWidgetStates.length).toBe(1)
expect(store.activeWidgetStates[0].widget.id).toBe('widget-1')
expect(store.inactiveWidgetStates.length).toBe(1)
expect(store.inactiveWidgetStates[0].widget.id).toBe('widget-2')
})
})
describe('clear functionality', () => {
it('should clear all widget states', () => {
const widget1 = createMockDOMWidget('widget-1')
const widget2 = createMockDOMWidget('widget-2')
store.registerWidget(widget1)
store.registerWidget(widget2)
expect(store.widgetStates.size).toBe(2)
store.clear()
expect(store.widgetStates.size).toBe(0)
expect(store.activeWidgetStates.length).toBe(0)
expect(store.inactiveWidgetStates.length).toBe(0)
})
})
})

View File

@@ -4,10 +4,10 @@ import { nextTick, ref } from 'vue'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstalledPacksResponse,
ManagerPackInstalled
} from '@/types/comfyManagerTypes'
import { components } from '@/types/generatedManagerTypes'
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
type ManagerPackInstalled = components['schemas']['ManagerPackInstalled']
vi.mock('@/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()

Some files were not shown because too many files have changed in this diff Show More