Compare commits

...

21 Commits

Author SHA1 Message Date
Comfy Org PR Bot
9a8260e92c [backport cloud/1.39] [bugfix] Fix node replacements not loading due to feature flag timing (#9044)
Backport of #9037 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9044-backport-cloud-1-39-bugfix-Fix-node-replacements-not-loading-due-to-feature-flag-timi-30e6d73d36508174895fea354b910d46)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-02-20 19:25:11 -08:00
Comfy Org PR Bot
09d1540e3f [backport cloud/1.39] fix: promoted widget labels show widgetName instead of "nodeId: widgetName" (#9041)
Backport of #9013 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9041-backport-cloud-1-39-fix-promoted-widget-labels-show-widgetName-instead-of-nodeId-wid-30e6d73d365081e7bb38fd025b512553)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-20 19:24:56 -08:00
Comfy Org PR Bot
15a1ec32a0 [backport cloud/1.39] fix: restore mouse-wheel scrolling in preview-as-text outputs (#9007)
Backport of #8863 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9007-backport-cloud-1-39-fix-restore-mouse-wheel-scrolling-in-preview-as-text-outputs-30d6d73d365081c58024cd56484ba839)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-20 02:38:29 -08:00
Comfy Org PR Bot
a403049fe0 [backport cloud/1.39] fix: resolve ImageCrop input image through subgraph nodes (#8947)
Backport of #8899 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8947-backport-cloud-1-39-fix-resolve-ImageCrop-input-image-through-subgraph-nodes-30b6d73d3650814abba4dd6db0567756)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-18 02:24:16 -08:00
Comfy Org PR Bot
177f833233 [backport cloud/1.39] feat: gate node replacement loading on server feature flag (#8941)
Backport of #8750 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8941-backport-cloud-1-39-feat-gate-node-replacement-loading-on-server-feature-flag-30a6d73d36508141923bdcbf614dd917)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 11:56:52 -08:00
Comfy Org PR Bot
4eb8148e81 [backport cloud/1.39] fix: use gtag get for checkout attribution (#8937)
Backport of #8930 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8937-backport-cloud-1-39-fix-use-gtag-get-for-checkout-attribution-30a6d73d365081b49d55d96912d9913b)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-02-17 11:52:58 -08:00
Comfy Org PR Bot
105a02cf9b [backport cloud/1.39] [feat] Node replacement UI (#8934)
Backport of #8604 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8934-backport-cloud-1-39-feat-Node-replacement-UI-30a6d73d365081b9b3d9e93a403cc606)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 09:58:23 -08:00
Comfy Org PR Bot
af3deb52e9 [backport cloud/1.39] fix: align in-app pricing copy with comfy.org/cloud/pricing (#8913)
Backport of #8725 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8913-backport-cloud-1-39-fix-align-in-app-pricing-copy-with-comfy-org-cloud-pricing-3096d73d365081b7b6caf75f3bae102f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-17 02:24:34 -08:00
Comfy Org PR Bot
ca5c8b1968 [backport cloud/1.39] fix: prevent XSS vulnerability in context menu labels (#8925)
Backport of #8887 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8925-backport-cloud-1-39-fix-prevent-XSS-vulnerability-in-context-menu-labels-3096d73d3650813bae82c37f3349dfbd)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 02:23:17 -08:00
Comfy Org PR Bot
c7ef314978 [backport cloud/1.39] feat: classify missing nodes by replacement availability (#8932)
Backport of #8483 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8932-backport-cloud-1-39-feat-classify-missing-nodes-by-replacement-availability-30a6d73d365081158499c71ab13726e8)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-02-16 23:45:44 -08:00
Comfy Org PR Bot
ba94cdd7c3 [backport cloud/1.39] Fix labels on output slots in vue mode (#8910)
Backport of #8846 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8910-backport-cloud-1-39-Fix-labels-on-output-slots-in-vue-mode-3096d73d3650810fb70eed7533f31459)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-16 04:19:08 -08:00
Comfy Org PR Bot
8f2710da70 [backport cloud/1.39] fix: SaveImage node not updating outputs during batch runs (vue-nodes) (#8894)
Backport of #8862 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8894-backport-cloud-1-39-fix-SaveImage-node-not-updating-outputs-during-batch-runs-vue-nod-3086d73d3650812ab367c2a4c297a12c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-15 13:39:17 +00:00
Comfy Org PR Bot
ce5283f880 [backport cloud/1.39] fix: hide output images for ImageCropV2 node (#8884)
Backport of #8873 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8884-backport-cloud-1-39-fix-hide-output-images-for-ImageCropV2-node-3076d73d365081318305fe9d100dcd8f)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-15 05:01:56 -08:00
Comfy Org PR Bot
557075178e [backport cloud/1.39] feat: add hideOutputImages flag for nodes with custom preview (#8892)
Backport of #8857 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8892-backport-cloud-1-39-feat-add-hideOutputImages-flag-for-nodes-with-custom-preview-3086d73d365081f1b9c9eaa5a63c9c16)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-15 04:51:10 -08:00
AustinMroz
afbddeea64 Fix primitve assets on cloud (#8878)
THIS IS A PR DIRECTLY INTO `cloud/1.39`

#8598 made primitve widgets connected to an asset have the asset type,
but the `nodeType` parameter required to actually resolve valid models
wasn't getting passed correctly.

This `nodeType`, introduced by me back in #7576, was a mistake. I'm
pulling it out now and instead passing nodeType as an option. Of note:
code changes are only required to pass the option, not to utilize it.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/f1abfbd1-2502-4b82-841c-7ef697b3a431"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/099cd511-0101-496c-b24e-ee2c19f23384"/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8878-Fix-primitve-assets-on-cloud-3076d73d365081f5a3a7f8b632ba3f3f)
by [Unito](https://www.unito.io)
2026-02-14 12:51:32 -08:00
Comfy Org PR Bot
86046d2974 [backport cloud/1.39] fix: clear draft on workflow close to prevent stale state on reopen (#8871)
Backport of #8854 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8871-backport-cloud-1-39-fix-clear-draft-on-workflow-close-to-prevent-stale-state-on-reopen-3076d73d3650818484b5c4ca1c7a0bcf)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-14 02:59:31 -08:00
Comfy Org PR Bot
ab640623cc [backport cloud/1.39] fix: undo breaking Vue node image preview reactivity (#8850)
Backport of #8839 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8850-backport-cloud-1-39-fix-undo-breaking-Vue-node-image-preview-reactivity-3066d73d365081ebaa1cdcb7ae5c19d1)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-13 09:17:03 -08:00
Simula_r
5bcc643473 [backport cloud/1.39] feat: invite member upsell for single-seat plans (#8843)
Backport of #8801 to `cloud/1.39`

Cherry-pick of 85ae0a57c3

**Conflict resolution:**
- `SubscriptionPanelContentWorkspace.vue`: Kept target branch's `:class`
with `cn()` structure, applied PR's `bg-modal-panel-background` →
`bg-secondary-background` color change

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8843-backport-cloud-1-39-feat-invite-member-upsell-for-single-seat-plans-3066d73d365081459ea0d27971595e5b)
by [Unito](https://www.unito.io)
2026-02-12 18:07:29 -08:00
Comfy Org PR Bot
2c050c7347 [backport cloud/1.39] fix: set audio widget value after file upload (#8836)
Backport of #8814 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8836-backport-cloud-1-39-fix-set-audio-widget-value-after-file-upload-3056d73d3650815495c8de9c022c67a7)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-12 11:10:31 -08:00
Comfy Org PR Bot
4d5cbbb298 [backport cloud/1.39] Add z-index to popover component (#8825)
Backport of #8823 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8825-backport-cloud-1-39-Add-z-index-to-popover-component-3056d73d3650810a8f9deb8cfb33fd54)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-12 04:32:18 -08:00
Comfy Org PR Bot
a541fadc84 [backport cloud/1.39] fix: default onboardingSurveyEnabled flag to false (#8831)
Backport of #8829 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8831-backport-cloud-1-39-fix-default-onboardingSurveyEnabled-flag-to-false-3056d73d365081b28fb5f7b6f5042aa1)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 00:09:38 -08:00
63 changed files with 2731 additions and 274 deletions

View File

@@ -0,0 +1,205 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,186 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

25
global.d.ts vendored
View File

@@ -10,9 +10,28 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
client_id: string | number | undefined
session_id: string | number | undefined
session_number: string | number | undefined
}
interface GtagFunction {
<TField extends GtagGetFieldName>(
command: 'get',
targetId: string,
fieldName: TField,
callback: (value: GtagGetFieldValueMap[TField]) => void
): void
(...args: unknown[]): void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -36,12 +55,8 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
}

View File

@@ -1,12 +1,12 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
@@ -14,32 +14,210 @@
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- Missing Nodes List Wrapper -->
<div
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<!-- QUICK FIX AVAILABLE Section -->
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<Button
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
variant="primary"
size="md"
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
})
}}
</Button>
</div>
<!-- Replaceable nodes list -->
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<span class="text-xs">
{{ node.label }}
</span>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
<!-- Select All row (sticky header) -->
<div
:class="
cn(
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
"
@click="toggleSelectAll"
@keydown.enter.prevent="toggleSelectAll"
@keydown.space.prevent="toggleSelectAll"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
isAllSelected || isSomeSelected
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isAllSelected"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
<!-- Replaceable node items -->
<div
v-for="node in replaceableNodes"
:key="node.label"
:class="
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'true'
: 'false'
"
@click="toggleNode(node.label)"
@keydown.enter.prevent="toggleNode(node.label)"
@keydown.space.prevent="toggleNode(node.label)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
</span>
</div>
</div>
</div>
</div>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
<!-- MANUAL INSTALLATION REQUIRED Section -->
<div
v-if="nonReplaceableNodes.length > 0"
class="flex max-h-[200px] flex-col gap-2"
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
</div>
<!-- Non-replaceable nodes list -->
<div
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
:key="node.label"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
</div>
<Button
v-if="node.action"
variant="destructive-textonly"
size="sm"
@click="node.action.callback"
>
{{ node.action.text }}
</Button>
</div>
</div>
</div>
<!-- Bottom instruction box -->
<div
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<p class="m-0 text-xs leading-5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{
$t('nodeReplacement.redHighlight')
}}</span>
</template>
</i18n-t>
</p>
</div>
</div>
@@ -47,23 +225,39 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const props = defineProps<{
const { missingNodeTypes } = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
interface ProcessedNode {
label: string
hint?: string
action?: { text: string; callback: () => void }
isReplaceable: boolean
replacement?: NodeReplacement
}
const replacedTypes = ref<Set<string>>(new Set())
const uniqueNodes = computed<ProcessedNode[]>(() => {
const seenTypes = new Set<string>()
return missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
@@ -75,10 +269,81 @@ const uniqueNodes = computed(() => {
return {
label: node.type,
hint: node.hint,
action: node.action
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
}
}
return { label: node }
return { label: node, isReplaceable: false }
})
})
const replaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => n.isReplaceable)
)
const pendingNodes = computed(() =>
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
)
const nonReplaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => !n.isReplaceable)
)
// Selection state - all pending nodes selected by default
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
const isAllSelected = computed(
() =>
pendingNodes.value.length > 0 &&
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
)
const isSomeSelected = computed(
() => selectedTypes.value.size > 0 && !isAllSelected.value
)
function toggleNode(label: string) {
if (replacedTypes.value.has(label)) return
const next = new Set(selectedTypes.value)
if (next.has(label)) {
next.delete(label)
} else {
next.add(label)
}
selectedTypes.value = next
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedTypes.value = new Set()
} else {
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
}
}
function handleReplaceSelected() {
const selected = missingNodeTypes.filter((node) => {
const type = typeof node === 'object' ? node.type : node
return selectedTypes.value.has(type)
})
const result = replaceNodesInPlace(selected)
const nextReplaced = new Set(replacedTypes.value)
const nextSelected = new Set(selectedTypes.value)
for (const type of result) {
nextReplaced.add(type)
nextSelected.delete(type)
}
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)
)
if (allReplaced && nonReplaceableNodes.value.length === 0) {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
}
</script>

View File

@@ -30,8 +30,18 @@
</i18n-t>
</div>
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleGotItClick">
{{ $t('nodeReplacement.skipForNow') }}
</Button>
</div>
<!-- Cloud mode: Learn More + Got It buttons -->
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<div
v-else-if="isCloud"
class="flex w-full items-center justify-between gap-2"
>
<Button
variant="textonly"
size="sm"
@@ -48,9 +58,9 @@
}}</Button>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<!-- OSS mode: Manager buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="openManager">{{
<Button variant="textonly" @click="handleOpenManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
@@ -82,12 +92,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -112,6 +127,12 @@ function openShowMissingNodesSetting() {
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
function handleOpenManager() {
managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -131,15 +152,29 @@ const showInstallAllButton = computed(() => {
return managerState.shouldShowInstallButton.value
})
const openManager = async () => {
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
const hasNonReplaceableNodes = computed(
() =>
missingNodeTypes?.some(
(n) =>
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
) ?? false
)
// Computed to check if all missing nodes have been installed
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
const hadMissingPacks = ref(false)
watch(
missingNodePacks,
(packs) => {
if (packs && packs.length > 0) hadMissingPacks.value = true
},
{ immediate: true }
)
// Only consider "all installed" when packs transitioned from non-empty to empty
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
const allMissingNodesInstalled = computed(() => {
if (!hadMissingPacks.value) return false
return (
!isLoading.value &&
!isInstalling.value &&

View File

@@ -6,14 +6,15 @@
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
})
}}
</template>
@@ -27,7 +28,10 @@
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
@@ -45,14 +49,16 @@
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
isSingleSeatPlan
? 'grid-cols-1 py-0'
: activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -101,17 +107,19 @@
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
<template v-if="!isSingleSeatPlan">
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</template>
</div>
@@ -166,7 +174,7 @@
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
@@ -206,14 +214,14 @@
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<Button
@@ -237,8 +245,29 @@
</template>
</template>
<!-- Upsell Banner -->
<div
v-if="isSingleSeatPlan"
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
>
<p class="m-0 text-sm text-foreground">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="muted-textonly"
class="cursor-pointer underline text-sm"
@click="showSubscriptionDialog()"
>
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
<!-- Pending Invites -->
<template v-else>
<template v-if="activeView === 'pending'">
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
@@ -367,6 +398,27 @@ const {
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')

View File

@@ -55,8 +55,12 @@
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'opacity-50 cursor-not-allowed'
"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
@@ -129,6 +133,8 @@ import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
@@ -144,8 +150,19 @@ const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
@@ -187,11 +204,16 @@ const deleteTooltip = computed(() => {
})
const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onDismiss">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
}
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<Toast group="invite-accepted" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2 justify-between w-full">
<div class="flex flex-col justify-start">
<div class="text-base">
{{ slotProps.message.summary }}
</div>
<div class="mt-1 text-sm text-foreground">
{{ slotProps.message.detail.text }} <br />
{{ slotProps.message.detail.workspaceName }}
</div>
</div>
<Button
size="md"
variant="inverted"
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
>
{{ t('workspace.viewWorkspace') }}
</Button>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
}
</script>

View File

@@ -36,7 +36,7 @@ defineProps<{
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
Plan,
PreviewSubscribeResponse,
@@ -73,4 +74,5 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,25 +1,50 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
...(original as Record<string, unknown>),
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsPersonal.value
},
get activeWorkspace() {
return mockIsPersonal.value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
@@ -52,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
useBillingPlans: () => ({
get plans() {
return mockPlans
},
currentPlanSlug: { value: null },
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
@@ -88,6 +111,9 @@ describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
})
it('returns legacy type for personal workspace', () => {
@@ -161,4 +187,51 @@ describe('useBillingContext', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(1)
expect(getMaxSeats('pro')).toBe(1)
expect(getMaxSeats('founder')).toBe(1)
})
it('falls back to hardcoded values when no API plans available', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(5)
expect(getMaxSeats('pro')).toBe(20)
expect(getMaxSeats('founder')).toBe(1)
})
it('prefers API max_seats when plans are loaded', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockPlans.value = [
{
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 2110000,
max_seats: 50,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 10000,
total_credits_cents: 2110000
}
}
]
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('pro')).toBe(50)
// Tiers without API plans still fall back to hardcoded values
expect(getMaxSeats('creator')).toBe(5)
})
})
})

View File

@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -115,6 +120,16 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
const apiTier = KEY_TO_TIER[tierKey]
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
)
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
@@ -223,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
getMaxSeats,
initialize,
fetchStatus,

View File

@@ -6,7 +6,6 @@ import { reactiveComputed } from '@vueuse/core'
import { customRef, reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -51,7 +50,6 @@ export interface SafeWidgetData {
hasLayoutSize?: boolean
isDOMWidget?: boolean
label?: string
nodeType?: string
options?: IWidgetOptions
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
@@ -123,12 +121,6 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
}
}
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
return subNode?.type
}
/**
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
*/
@@ -139,8 +131,6 @@ interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
/** Input specification from node definition */
spec?: InputSpec
/** Node type (for subgraph promoted widgets) */
nodeType?: string
/** Border style for promoted/advanced widgets */
borderStyle?: string
/** Widget label */
@@ -164,7 +154,6 @@ export function getSharedWidgetEnhancements(
value: useReactiveWidgetValue(widget),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget),
borderStyle: widget.promoted
? 'ring ring-component-node-widget-promoted'
: widget.advanced

View File

@@ -20,7 +20,8 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled'
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
}
/**
@@ -61,7 +62,7 @@ export function useFeatureFlags() {
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
)
},
get linearToggleEnabled() {
@@ -96,6 +97,9 @@ export function useFeatureFlags() {
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
)
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
}
})

View File

@@ -155,11 +155,18 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const getInputImageUrl = (): string | null => {
if (!node.value) return null
const inputNode = node.value.getInputNode(0)
let sourceNode = node.value.getInputNode(0)
if (!sourceNode) return null
if (!inputNode) return null
if (sourceNode.isSubgraphNode()) {
const link = node.value.getInputLink(0)
if (!link) return null
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
sourceNode = resolved?.outputNode ?? null
if (!sourceNode) return null
}
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
if (urls?.length) {
return urls[0]
@@ -562,7 +569,10 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value = app.rootGraph?.getNodeById(nodeId) || null
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
}
updateImageUrl()

View File

@@ -3,8 +3,8 @@ import { describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -119,6 +119,94 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe('value')
})
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
expect(proxyWidget.name).toBe('1: seed')
})
test('Proxy widget label reflects linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
innerNodes[0].widgets![0].label = 'My Inner Label'
// Trigger re-resolve of linked widget
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Inner Label')
})
test('Proxy widget user rename takes priority over linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'My Custom Seed'
expect(proxyWidget.label).toBe('My Custom Seed')
innerNodes[0].widgets![0].label = 'Inner Override'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Custom Seed')
})
test('Proxy widget label resets to linked widget on undefined', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'Custom'
expect(proxyWidget.label).toBe('Custom')
proxyWidget.label = undefined
innerNodes[0].widgets![0].label = 'Inner Label'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('Inner Label')
})
test('Proxy widget labels are correct when loaded from serialized data', () => {
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
// to exercise the deserialization/onConfigure path from blueprint JSON.
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
subgraph.add(innerNode)
innerNode.addWidget('text', 'seed', 'value', () => {})
innerNode.addWidget('text', 'steps', 'value', () => {})
const parentGraph = new LGraph()
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
id: 1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
flags: {},
mode: 0,
order: 0
})
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].label).toBe('seed')
expect(subgraphNode.widgets[0].name).toBe('1: seed')
expect(subgraphNode.widgets[1].label).toBe('steps')
expect(subgraphNode.widgets[1].name).toBe('1: steps')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})

View File

@@ -199,12 +199,15 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
* and the value used as 'this' if property is a get/set method
* @param {unknown} value - only used on set calls. The thing being assigned
*/
let userLabel: string | undefined
const handler = {
get(_t: IBaseWidget, property: string, receiver: object) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == '_overlay') return overlay
else if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'label')
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
@@ -212,6 +215,10 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown) {
if (property == 'label') {
userLabel = value as string | undefined
return true
}
let redirectedTarget: object = backingWidget
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {

View File

@@ -6,6 +6,7 @@ useExtensionService().registerExtension({
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCropV2') return
node.hideOutputImages = true
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
}

View File

@@ -52,14 +52,12 @@ useExtensionService().registerExtension({
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.element.readOnly = true
showValueWidget.element.disabled = true
showValueWidget.serialize = false
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.element.disabled = true
showValueWidgetPlain.serialize = false
}

View File

@@ -70,6 +70,7 @@ async function uploadFile(
api.apiURL(getResourceURL(...splitFilePath(path)))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}

View File

@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -5,6 +7,38 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.innerHTML = options.title
element.textContent = options.title
root.append(element)
}
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const innerHtml = name === null ? '' : String(name)
const label = name === null ? '' : String(name)
if (typeof value === 'string') {
element.innerHTML = innerHtml
element.textContent = label
} else {
element.innerHTML = value?.title ?? innerHtml
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
if (value.disabled) {
disabled = true

View File

@@ -2039,7 +2039,7 @@
"howManyCredits": "How many credits would you like to add?",
"usdAmount": "${amount}",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"templateNote": "*Generated with Wan 2.2 Image-to-Video template",
"buy": "Buy",
"purchaseSuccess": "Credits added successfully!",
"purchaseError": "Purchase Failed",
@@ -2144,7 +2144,7 @@
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"yearlyDiscount": "20% DISCOUNT",
@@ -2175,7 +2175,7 @@
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"descriptionWorkspace": "Choose the best plan for your workspace",
@@ -2202,7 +2202,7 @@
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan 2.2 Image-to-Video template",
"videoEstimateHelp": "More details on this template",
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 seconds, 640x640, 16fps, 4-step sampling).",
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 sec, 16fps, 640x640, 4-step sampling).",
"videoEstimateTryTemplate": "Try this template",
"videoTemplateBasedCredits": "Videos generated with Wan 2.2 Image to Video",
"upgradePlan": "Upgrade Plan",
@@ -2263,7 +2263,7 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"membersCount": "{count}/{maxSeats} Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2279,6 +2279,9 @@
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
@@ -2317,6 +2320,14 @@
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
@@ -2881,6 +2892,25 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
},
"nodeReplacement": {
"quickFixAvailable": "Quick Fix Available",
"installationRequired": "Installation Required",
"compatibleAlternatives": "Compatible Alternatives",
"replaceable": "Replaceable",
"replaced": "Replaced",
"notReplaceable": "Install Required",
"selectAll": "Select All",
"replaceSelected": "Replace Selected ({count})",
"replacedNode": "Replaced node: {nodeType}",
"replacedAllNodes": "Replaced {count} node type(s)",
"replaceFailed": "Failed to replace nodes",
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
"redHighlight": "red",
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",
@@ -2963,8 +2993,9 @@
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
"addedToWorkspace": "You have been added to:",
"inviteFailed": "Failed to Accept Invite",
"viewWorkspace": "View workspace"
},
"workspaceAuth": {
"errors": {

View File

@@ -285,8 +285,8 @@
"name": "Show API node pricing badge"
},
"Comfy_NodeReplacement_Enabled": {
"name": "Enable automatic node replacement",
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
"name": "Enable node replacement suggestions",
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",

View File

@@ -249,8 +249,8 @@
"name": "API 노드 가격 배지 표시"
},
"Comfy_NodeReplacement_Enabled": {
"name": "자동 노드 교체 활성화",
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
"name": "노드 교체 제안 활성화",
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",

View File

@@ -105,7 +105,10 @@ export function createAssetWidget(
})
}
const options: IWidgetAssetOptions = { openModal }
const options: IWidgetAssetOptions = {
openModal,
nodeType: nodeTypeForBrowser
}
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
}

View File

@@ -226,7 +226,7 @@
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
href="https://cloud.comfy.org/?template=video_wan2_2_14B_i2v"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"

View File

@@ -235,7 +235,7 @@
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
href="https://cloud.comfy.org/?template=video_wan2_2_14B_i2v"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
@@ -374,7 +374,8 @@ const {
plans: apiPlans,
currentPlanSlug,
fetchPlans,
subscription
subscription,
getMaxSeats
} = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
@@ -404,11 +405,6 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
return currentBillingCycle.value === 'yearly' ? price / 12 : price
}
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.max_seats : null
}
const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
)
@@ -493,8 +489,7 @@ const getAnnualTotal = (tier: PricingTierConfig): number => {
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
}
const getMaxMembers = (tier: PricingTierConfig): number =>
getMaxSeatsFromApi(tier) ?? tier.maxMembers
const getMaxMembers = (tier: PricingTierConfig): number => getMaxSeats(tier.key)
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
tier.pricing.credits

View File

@@ -88,10 +88,13 @@
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base"
>{{ $t('subscription.perMonth') }} /
{{ $t('subscription.member') }}</span
>
<span class="text-base">
{{
isInPersonalWorkspace
? $t('subscription.usdPerMonth')
: $t('subscription.usdPerMonthPerMember')
}}
</span>
</div>
<div
v-if="isActiveSubscription"
@@ -179,7 +182,7 @@
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
'bg-secondary-background'
)
"
>
@@ -351,7 +354,6 @@ import { useSubscriptionActions } from '@/platform/cloud/subscription/composable
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
@@ -380,7 +382,7 @@ const {
manageSubscription,
fetchStatus,
fetchBalance,
plans: apiPlans
getMaxSeats
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
@@ -503,23 +505,6 @@ const tierPrice = computed(() =>
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
const plan = getApiPlanForTier(tierKey, 'monthly')
return plan ? plan.max_seats : null
}
function getMaxMembers(tierKey: TierKey): number {
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
}
const refillsDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
@@ -563,13 +548,18 @@ interface Benefit {
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
const benefits: Benefit[] = []
if (!isInPersonalWorkspace.value) {
benefits.push({
key: 'members',
type: 'icon',
label: t('subscription.membersLabel', { count: getMaxMembers(key) }),
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
icon: 'pi pi-user'
},
})
}
benefits.push(
{
key: 'maxDuration',
type: 'metric',
@@ -586,7 +576,7 @@ const tierBenefits = computed((): Benefit[] => {
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
)
if (getTierFeatures(key).customLoRAs) {
benefits.push({

View File

@@ -11,6 +11,13 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
FOUNDERS_EDITION: 'founder'
}
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
standard: 'STANDARD',
creator: 'CREATOR',
pro: 'PRO',
founder: 'FOUNDERS_EDITION'
}
export interface TierPricing {
monthly: number
yearly: number
@@ -19,9 +26,9 @@ export interface TierPricing {
}
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 120 },
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 211 },
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 600 }
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 380 },
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 670 },
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 1915 }
}
interface TierFeatures {

View File

@@ -3,7 +3,9 @@ import type { NodeReplacementResponse } from './types'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { fetchNodeReplacements } from './nodeReplacementService'
import { useNodeReplacementStore } from './nodeReplacementStore'
@@ -15,6 +17,12 @@ vi.mock('./nodeReplacementService', () => ({
fetchNodeReplacements: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
function mockSettingStore(enabled: boolean) {
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: vi.fn().mockImplementation((key: string) => {
@@ -27,9 +35,17 @@ function mockSettingStore(enabled: boolean) {
})
}
function createStore(enabled = true) {
function createStore(settingEnabled = true, serverFeatureEnabled = true) {
setActivePinia(createPinia())
mockSettingStore(enabled)
mockSettingStore(settingEnabled)
vi.mocked(api.getServerFeature).mockImplementation(
(flag: string, defaultValue?: unknown) => {
if (flag === ServerFeatureFlag.NODE_REPLACEMENTS) {
return serverFeatureEnabled
}
return defaultValue
}
)
return useNodeReplacementStore()
}
@@ -38,7 +54,7 @@ describe('useNodeReplacementStore', () => {
beforeEach(() => {
vi.clearAllMocks()
store = createStore(true)
store = createStore()
})
it('should initialize with empty replacements', () => {
@@ -228,7 +244,7 @@ describe('useNodeReplacementStore', () => {
consoleErrorSpy.mockRestore()
})
it('should not fetch when feature is disabled', async () => {
it('should not fetch when setting is disabled', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue({})
store = createStore(false)
@@ -238,6 +254,16 @@ describe('useNodeReplacementStore', () => {
expect(store.isLoaded).toBe(false)
})
it('should not fetch when server feature flag is disabled', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore(true, false)
await store.load()
expect(fetchNodeReplacements).not.toHaveBeenCalled()
expect(store.isLoaded).toBe(false)
})
it('should not re-fetch when called twice', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore()

View File

@@ -3,7 +3,9 @@ import type { NodeReplacement, NodeReplacementResponse } from './types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { fetchNodeReplacements } from './nodeReplacementService'
export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
@@ -15,7 +17,9 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
)
async function load() {
if (isLoaded.value || !isEnabled.value) return
if (!isEnabled.value || isLoaded.value) return
if (!api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false))
return
try {
replacements.value = await fetchNodeReplacements()
@@ -38,8 +42,8 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
return {
replacements,
isLoaded,
load,
isEnabled,
load,
getReplacementFor,
hasReplacement
}

View File

@@ -1,17 +1,14 @@
interface InputAssignOldId {
assign_type: 'old_id'
interface InputMapOldId {
new_id: string
old_id: string
}
interface InputAssignSetValue {
assign_type: 'set_value'
value: unknown
interface InputMapSetValue {
new_id: string
set_value: unknown
}
interface InputMap {
new_id: string
assign: InputAssignOldId | InputAssignSetValue
}
type InputMap = InputMapOldId | InputMapSetValue
interface OutputMap {
new_idx: number

View File

@@ -0,0 +1,654 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeReplacement } from './types'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn(),
registered_node_types: {}
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null },
sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '')
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: {
changeTracker: {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
}
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
import { app } from '@/scripts/app'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodeReplacement } from './useNodeReplacement'
function createMockLink(
id: number,
originId: number,
originSlot: number,
targetId: number,
targetSlot: number
) {
return {
id,
origin_id: originId,
origin_slot: originSlot,
target_id: targetId,
target_slot: targetSlot,
type: 'IMAGE'
}
}
function createMockGraph(
nodes: LGraphNode[],
links: ReturnType<typeof createMockLink>[] = []
): LGraph {
const linksMap = new Map(links.map((l) => [l.id, l]))
return {
_nodes: nodes,
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
links: linksMap,
updateExecutionOrder: vi.fn(),
setDirtyCanvas: vi.fn()
} as unknown as LGraph
}
function createPlaceholderNode(
id: number,
type: string,
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
graph?: LGraph
): LGraphNode {
return {
id,
type,
pos: [100, 200],
size: [200, 100],
order: 0,
mode: 0,
flags: {},
has_errors: true,
last_serialization: {
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
},
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
graph: graph ?? null,
serialize: vi.fn(() => ({
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
}))
} as unknown as LGraphNode
}
function createNewNode(
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
widgets: { name: string; value: unknown }[] = []
): LGraphNode {
return {
id: 0,
type: '',
pos: [0, 0],
size: [100, 50],
order: 0,
mode: 0,
flags: {},
has_errors: false,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
configure: vi.fn(),
serialize: vi.fn()
} as unknown as LGraphNode
}
function makeMissingNodeType(
type: string,
replacement: NodeReplacement
): MissingNodeType {
return {
type,
isReplaceable: true,
replacement
}
}
describe('useNodeReplacement', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
describe('replaceNodesInPlace', () => {
it('should return empty array when no placeholders exist', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([])
expect(result).toEqual([])
})
it('should use default mapping when no explicit mapping exists', () => {
const placeholder = createPlaceholderNode(1, 'Load3DAnimation')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['Load3DAnimation'])
expect(newNode.configure).not.toHaveBeenCalled()
expect(newNode.id).toBe(1)
expect(newNode.has_errors).toBe(false)
})
it('should transfer input connections using input_mapping', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
1,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(result).toEqual(['T2IAdapterLoader'])
// Link should be updated to point at new node's input
expect(link.target_id).toBe(1)
expect(link.target_slot).toBe(0)
expect(newNode.inputs[0].link).toBe(10)
})
it('should transfer output connections using output_mapping', () => {
const link = createMockLink(20, 1, 0, 5, 0)
const placeholder = createPlaceholderNode(
1,
'ResizeImagesByLongerEdge',
[],
[{ name: 'IMAGE', links: [20] }]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'image', link: null }],
[{ name: 'IMAGE', links: null }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Output link should be remapped
expect(link.origin_id).toBe(1)
expect(link.origin_slot).toBe(0)
expect(newNode.outputs[0].links).toEqual([20])
})
it('should apply set_value to widget', () => {
const placeholder = createPlaceholderNode(1, 'ImageScaleBy')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied to the widget
expect(newNode.widgets![0].value).toBe('scale by multiplier')
})
it('should transfer widget values using old_widget_ids', () => {
const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge')
// Set widget values in serialized data
placeholder.last_serialization!.widgets_values = [512]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[{ name: 'largest_size', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
})
it('should skip replacement when new node type is not registered', () => {
const placeholder = createPlaceholderNode(1, 'UnknownNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('UnknownNode', {
new_node_id: 'NonExistentNode',
old_node_id: 'UnknownNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual([])
})
it('should replace multiple different node types at once', () => {
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
const placeholder2 = createPlaceholderNode(
2,
'ConditioningAverage',
[],
[]
)
// sanitizeNodeName strips & from type names (HTML entity chars)
placeholder2.type = 'ConditioningAverage'
const graph = createMockGraph([placeholder1, placeholder2])
placeholder1.graph = graph
placeholder2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2])
const newNode1 = createNewNode()
const newNode2 = createNewNode()
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(newNode1)
.mockReturnValueOnce(newNode2)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('ConditioningAverage&', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage&',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toHaveLength(2)
expect(result).toContain('Load3DAnimation')
expect(result).toContain('ConditioningAverage&')
})
it('should copy position and identity for mapped replacements', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
42,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
placeholder.pos = [300, 400]
placeholder.size = [250, 150]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(newNode.id).toBe(42)
expect(newNode.pos).toEqual([300, 400])
expect(newNode.size).toEqual([250, 150])
expect(graph._nodes[0]).toBe(newNode)
})
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
const placeholder = createPlaceholderNode(
12,
'ImageScaleBy',
[{ name: 'image', link: 2 }],
[{ name: 'IMAGE', links: [3, 4] }]
)
// Real workflow data: widgets_values: ["lanczos", 2.0]
placeholder.last_serialization!.widgets_values = ['lanczos', 2.0]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied
expect(newNode.widgets![0].value).toBe('scale by multiplier')
// upscale_method (idx 0, value "lanczos") → scale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => {
const link = createMockLink(1, 5, 0, 8, 0)
const placeholder = createPlaceholderNode(
8,
'ResizeImagesByLongerEdge',
[{ name: 'images', link: 1 }],
[{ name: 'IMAGE', links: [2] }]
)
// Real workflow data: widgets_values: [1024]
placeholder.last_serialization!.widgets_values = [1024]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[
{ name: 'largest_size', value: 0 },
{ name: 'upscale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// longer_edge (idx 0, value 1024) → largest_size widget
expect(newNode.widgets![0].value).toBe(1024)
// set_value "lanczos" → upscale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer ConditioningAverage widget value with real workflow data', () => {
const link = createMockLink(4, 7, 0, 13, 0)
// sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space
const placeholder = createPlaceholderNode(
13,
'ConditioningAverage ',
[
{ name: 'conditioning_to', link: 4 },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: [6] }]
)
placeholder.last_serialization!.widgets_values = [0.75]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'conditioning_to', link: null },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: null }],
[{ name: 'conditioning_average', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ConditioningAverage ', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage ',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
// Default mapping transfers connections and widget values by name
expect(newNode.id).toBe(13)
expect(newNode.inputs[0].link).toBe(4)
expect(newNode.outputs[0].links).toEqual([6])
expect(newNode.widgets![0].value).toBe(0.75)
})
it('should skip dot-notation input connections but still transfer widget values', () => {
const placeholder = createPlaceholderNode(1, 'ImageBatch')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('ImageBatch', {
new_node_id: 'BatchImagesNode',
old_node_id: 'ImageBatch',
old_widget_ids: null,
input_mapping: [
{ new_id: 'images.image0', old_id: 'image1' },
{ new_id: 'images.image1', old_id: 'image2' }
],
output_mapping: null
})
])
// Should still succeed (dot-notation skipped gracefully)
expect(result).toEqual(['ImageBatch'])
})
})
})

View File

@@ -0,0 +1,292 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { t } from '@/i18n'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
/** Compares sanitized type strings to match placeholder → missing node type. */
function findMatchingType(
node: LGraphNode,
selectedTypes: MissingNodeType[]
): Extract<MissingNodeType, { type: string }> | undefined {
const nodeType = node.type
for (const selected of selectedTypes) {
if (typeof selected !== 'object' || !selected.isReplaceable) continue
if (sanitizeNodeName(selected.type) === nodeType) return selected
}
return undefined
}
function transferInputConnection(
oldNode: LGraphNode,
oldInputName: string,
newNode: LGraphNode,
newInputName: string,
graph: LGraph
): void {
const oldSlotIdx = oldNode.inputs?.findIndex((i) => i.name === oldInputName)
const newSlotIdx = newNode.inputs?.findIndex((i) => i.name === newInputName)
if (oldSlotIdx == null || oldSlotIdx === -1) return
if (newSlotIdx == null || newSlotIdx === -1) return
const linkId = oldNode.inputs[oldSlotIdx].link
if (linkId == null) return
const link = graph.links.get(linkId)
if (!link) return
link.target_id = newNode.id
link.target_slot = newSlotIdx
newNode.inputs[newSlotIdx].link = linkId
oldNode.inputs[oldSlotIdx].link = null
}
function transferOutputConnections(
oldNode: LGraphNode,
oldOutputIdx: number,
newNode: LGraphNode,
newOutputIdx: number,
graph: LGraph
): void {
const oldLinks = oldNode.outputs?.[oldOutputIdx]?.links
if (!oldLinks?.length) return
if (!newNode.outputs?.[newOutputIdx]) return
for (const linkId of oldLinks) {
const link = graph.links.get(linkId)
if (!link) continue
link.origin_id = newNode.id
link.origin_slot = newOutputIdx
}
newNode.outputs[newOutputIdx].links = [...oldLinks]
oldNode.outputs[oldOutputIdx].links = []
}
/** Uses old_widget_ids as name→index lookup into widgets_values. */
function transferWidgetValue(
serialized: ISerialisedNode,
oldWidgetIds: string[] | null,
oldInputName: string,
newNode: LGraphNode,
newInputName: string
): void {
if (!oldWidgetIds || !serialized.widgets_values) return
const oldWidgetIdx = oldWidgetIds.indexOf(oldInputName)
if (oldWidgetIdx === -1) return
const oldValue = serialized.widgets_values[oldWidgetIdx]
if (oldValue === undefined) return
const newWidget = newNode.widgets?.find((w) => w.name === newInputName)
if (newWidget) {
newWidget.value = oldValue
newWidget.callback?.(oldValue)
}
}
function applySetValue(
newNode: LGraphNode,
inputName: string,
value: unknown
): void {
const widget = newNode.widgets?.find((w) => w.name === inputName)
if (widget) {
widget.value = value as TWidgetValue
widget.callback?.(widget.value)
}
}
function isDotNotation(id: string): boolean {
return id.includes('.')
}
/** Auto-generates identity mapping by name for same-structure replacements without backend mapping. */
function generateDefaultMapping(
serialized: ISerialisedNode,
newNode: LGraphNode
): Pick<
NodeReplacement,
'input_mapping' | 'output_mapping' | 'old_widget_ids'
> {
const oldInputNames = new Set(serialized.inputs?.map((i) => i.name) ?? [])
const inputMapping: { old_id: string; new_id: string }[] = []
for (const newInput of newNode.inputs ?? []) {
if (oldInputNames.has(newInput.name)) {
inputMapping.push({ old_id: newInput.name, new_id: newInput.name })
}
}
const oldWidgetIds = (newNode.widgets ?? []).map((w) => w.name)
for (const widget of newNode.widgets ?? []) {
if (!oldInputNames.has(widget.name)) {
inputMapping.push({ old_id: widget.name, new_id: widget.name })
}
}
const outputMapping: { old_idx: number; new_idx: number }[] = []
for (const [oldIdx, oldOutput] of (serialized.outputs ?? []).entries()) {
const newIdx = newNode.outputs?.findIndex((o) => o.name === oldOutput.name)
if (newIdx != null && newIdx !== -1) {
outputMapping.push({ old_idx: oldIdx, new_idx: newIdx })
}
}
return {
input_mapping: inputMapping.length > 0 ? inputMapping : null,
output_mapping: outputMapping.length > 0 ? outputMapping : null,
old_widget_ids: oldWidgetIds.length > 0 ? oldWidgetIds : null
}
}
function replaceWithMapping(
node: LGraphNode,
newNode: LGraphNode,
replacement: NodeReplacement,
nodeGraph: LGraph,
idx: number
): void {
newNode.id = node.id
newNode.pos = [...node.pos]
newNode.size = [...node.size]
newNode.order = node.order
newNode.mode = node.mode
if (node.flags) newNode.flags = { ...node.flags }
nodeGraph._nodes[idx] = newNode
newNode.graph = nodeGraph
nodeGraph._nodes_by_id[newNode.id] = newNode
const serialized = node.last_serialization ?? node.serialize()
if (serialized.title != null) newNode.title = serialized.title
if (serialized.properties) {
newNode.properties = { ...serialized.properties }
if ('Node name for S&R' in newNode.properties) {
newNode.properties['Node name for S&R'] = replacement.new_node_id
}
}
if (replacement.input_mapping) {
for (const inputMap of replacement.input_mapping) {
if ('old_id' in inputMap) {
if (isDotNotation(inputMap.new_id)) continue // Autogrow/DynamicCombo
transferInputConnection(
node,
inputMap.old_id,
newNode,
inputMap.new_id,
nodeGraph
)
transferWidgetValue(
serialized,
replacement.old_widget_ids,
inputMap.old_id,
newNode,
inputMap.new_id
)
} else {
if (!isDotNotation(inputMap.new_id)) {
applySetValue(newNode, inputMap.new_id, inputMap.set_value)
}
}
}
}
if (replacement.output_mapping) {
for (const outMap of replacement.output_mapping) {
transferOutputConnections(
node,
outMap.old_idx,
newNode,
outMap.new_idx,
nodeGraph
)
}
}
newNode.has_errors = false
}
export function useNodeReplacement() {
const toastStore = useToastStore()
function replaceNodesInPlace(selectedTypes: MissingNodeType[]): string[] {
const replacedTypes: string[] = []
const graph = app.rootGraph
const changeTracker =
useWorkflowStore().activeWorkflow?.changeTracker ?? null
changeTracker?.beforeChange()
try {
const placeholders = collectAllNodes(
graph,
(n) => !!n.has_errors && !!n.last_serialization
)
for (const node of placeholders) {
const match = findMatchingType(node, selectedTypes)
if (!match?.replacement) continue
const replacement = match.replacement
const nodeGraph = node.graph
if (!nodeGraph) continue
const idx = nodeGraph._nodes.indexOf(node)
if (idx === -1) continue
const newNode = LiteGraph.createNode(replacement.new_node_id)
if (!newNode) continue
const hasMapping =
replacement.input_mapping != null ||
replacement.output_mapping != null
const effectiveReplacement = hasMapping
? replacement
: {
...replacement,
...generateDefaultMapping(
node.last_serialization ?? node.serialize(),
newNode
)
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
}
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
toastStore.add({
severity: 'success',
summary: t('g.success'),
detail: t('nodeReplacement.replacedAllNodes', {
count: replacedTypes.length
}),
life: 3000
})
}
} finally {
changeTracker?.afterChange()
}
return replacedTypes
}
return {
replaceNodesInPlace
}
}

View File

@@ -27,6 +27,7 @@ type FirebaseRuntimeConfig = {
*/
export type RemoteConfig = {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert

View File

@@ -1196,9 +1196,9 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.NodeReplacement.Enabled',
category: ['Comfy', 'Workflow', 'NodeReplacement'],
name: 'Enable automatic node replacement',
name: 'Enable node replacement suggestions',
tooltip:
'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.',
'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.',
type: 'boolean',
defaultValue: false,
experimental: true,

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
describe('GtmTelemetryProvider', () => {
beforeEach(() => {
window.__CONFIG__ = {}
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
})
it('injects the GTM runtime script', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
}
new GtmTelemetryProvider()
const gtmScript = document.querySelector(
'script[src="https://www.googletagmanager.com/gtm.js?id=GTM-TEST123"]'
)
expect(gtmScript).not.toBeNull()
expect(window.dataLayer?.[0]).toMatchObject({
event: 'gtm.js'
})
})
it('bootstraps gtag when a GA measurement id exists', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
new GtmTelemetryProvider()
const gtagScript = document.querySelector(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
)
const dataLayer = window.dataLayer as unknown[]
expect(gtagScript).not.toBeNull()
expect(typeof window.gtag).toBe('function')
expect(dataLayer).toHaveLength(2)
expect(Array.from(dataLayer[0] as IArguments)[0]).toBe('js')
expect(Array.from(dataLayer[1] as IArguments)).toEqual([
'config',
'G-TEST123',
{
send_page_view: false
}
])
})
it('does not inject duplicate gtag scripts across repeated init', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
new GtmTelemetryProvider()
new GtmTelemetryProvider()
const gtagScripts = document.querySelectorAll(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
)
expect(gtagScripts).toHaveLength(1)
})
})

View File

@@ -22,13 +22,21 @@ export class GtmTelemetryProvider implements TelemetryProvider {
if (typeof window === 'undefined') return
const gtmId = window.__CONFIG__?.gtm_container_id
if (!gtmId) {
if (gtmId) {
this.initializeGtm(gtmId)
} else {
if (import.meta.env.MODE === 'development') {
console.warn('[GTM] No GTM ID configured, skipping initialization')
}
return
}
const measurementId = window.__CONFIG__?.ga_measurement_id
if (measurementId) {
this.bootstrapGtag(measurementId)
}
}
private initializeGtm(gtmId: string): void {
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
@@ -44,6 +52,38 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.initialized = true
}
private bootstrapGtag(measurementId: string): void {
window.dataLayer = window.dataLayer || []
if (typeof window.gtag !== 'function') {
function gtag() {
// gtag queue shape is dataLayer.push(arguments)
// eslint-disable-next-line prefer-rest-params
;(window.dataLayer as unknown[] | undefined)?.push(arguments)
}
window.gtag = gtag as Window['gtag']
}
const gtagScriptSrc = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`
const existingGtagScript = document.querySelector(
`script[src="${gtagScriptSrc}"]`
)
if (!existingGtagScript) {
const script = document.createElement('script')
script.async = true
script.src = gtagScriptSrc
document.head.insertBefore(script, document.head.firstChild)
}
const gtag = window.gtag
if (typeof gtag !== 'function') return
gtag('js', new Date())
gtag('config', measurementId, { send_page_view: false })
}
private pushEvent(event: string, properties?: Record<string, unknown>): void {
if (!this.initialized) return
window.dataLayer?.push({ event, ...properties })

View File

@@ -9,17 +9,37 @@ describe('getCheckoutAttribution', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
window.__ga_identity__ = undefined
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: undefined
}
window.gtag = undefined
window.ire = undefined
window.history.pushState({}, '', '/')
})
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
window.__ga_identity__ = {
client_id: '123.456',
session_id: '1700000000',
session_number: '2'
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: 'G-TEST123'
}
const gtagSpy = vi.fn(
(
_command: 'get',
_targetId: string,
fieldName: GtagGetFieldName,
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
) => {
const valueByField = {
client_id: '123.456',
session_id: '1700000000',
session_number: '2'
}
callback(valueByField[fieldName])
}
)
window.gtag = gtagSpy as unknown as Window['gtag']
window.history.pushState(
{},
'',
@@ -48,6 +68,61 @@ describe('getCheckoutAttribution', () => {
'generateClickId',
expect.any(Function)
)
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'client_id',
expect.any(Function)
)
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'session_id',
expect.any(Function)
)
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'session_number',
expect.any(Function)
)
})
it('stringifies numeric GA values from gtag', async () => {
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: 'G-TEST123'
}
const gtagSpy = vi.fn(
(
_command: 'get',
_targetId: string,
fieldName: GtagGetFieldName,
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
) => {
const valueByField = {
client_id: '123.456',
session_id: 1700000000,
session_number: 2
}
callback(valueByField[fieldName])
}
)
window.gtag = gtagSpy as unknown as Window['gtag']
const attribution = await getCheckoutAttribution()
expect(attribution).toMatchObject({
ga_client_id: '123.456',
ga_session_id: '1700000000',
ga_session_number: '2'
})
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'session_number',
expect.any(Function)
)
})
it('falls back to URL click id when generateClickId is unavailable', async () => {

View File

@@ -9,6 +9,13 @@ type GaIdentity = {
session_number?: string
}
const GA_IDENTITY_FIELDS = [
'client_id',
'session_id',
'session_number'
] as const satisfies ReadonlyArray<GtagGetFieldName>
type GaIdentityField = GtagGetFieldName
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
@@ -23,6 +30,7 @@ const ATTRIBUTION_QUERY_KEYS = [
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
const GET_GA_IDENTITY_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
@@ -93,19 +101,53 @@ function hasAttributionChanges(
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function getGaIdentity(): GaIdentity | undefined {
if (typeof window === 'undefined') return undefined
async function getGaIdentityField(
measurementId: string,
fieldName: GaIdentityField
): Promise<string | undefined> {
if (typeof window === 'undefined' || typeof window.gtag !== 'function') {
return undefined
}
const gtag = window.gtag
const identity = window.__ga_identity__
if (!isPlainObject(identity)) return undefined
return withTimeout(
() =>
new Promise<string | undefined>((resolve) => {
gtag('get', measurementId, fieldName, (value) => {
resolve(asNonEmptyString(value))
})
}),
GET_GA_IDENTITY_TIMEOUT_MS
).catch(() => undefined)
}
async function getGaIdentity(): Promise<GaIdentity | undefined> {
const measurementId = asNonEmptyString(window.__CONFIG__?.ga_measurement_id)
if (!measurementId) {
return undefined
}
const [clientId, sessionId, sessionNumber] = await Promise.all(
GA_IDENTITY_FIELDS.map((fieldName) =>
getGaIdentityField(measurementId, fieldName)
)
)
if (!clientId && !sessionId && !sessionNumber) {
return undefined
}
return {
client_id: asNonEmptyString(identity.client_id),
session_id: asNonEmptyString(identity.session_id),
session_number: asNonEmptyString(identity.session_number)
client_id: clientId,
session_id: sessionId,
session_number: sessionNumber
}
}
@@ -170,7 +212,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
persistAttribution(attribution)
}
const gaIdentity = getGaIdentity()
const gaIdentity = await getGaIdentity()
return {
...attribution,

View File

@@ -215,6 +215,8 @@ export const useWorkflowService = () => {
}
}
workflowDraftStore.removeDraft(workflow.path)
// If this is the last workflow, create a new default temporary workflow
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()

View File

@@ -12,6 +12,7 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
@@ -911,4 +912,41 @@ describe('useWorkflowStore', () => {
expect(mostRecent).toBeNull()
})
})
describe('closeWorkflow draft cleanup', () => {
it('should remove draft for persisted workflows on close', async () => {
const draftStore = useWorkflowDraftStore()
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
draftStore.saveDraft('workflows/a.json', {
data: '{"dirty":true}',
updatedAt: Date.now(),
name: 'a.json',
isTemporary: false
})
expect(draftStore.getDraft('workflows/a.json')).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft('workflows/a.json')).toBeUndefined()
})
it('should remove draft for temporary workflows on close', async () => {
const draftStore = useWorkflowDraftStore()
const workflow = store.createTemporary('temp.json')
draftStore.saveDraft(workflow.path, {
data: '{"dirty":true}',
updatedAt: Date.now(),
name: 'temp.json',
isTemporary: true
})
expect(draftStore.getDraft(workflow.path)).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
})
})
})

View File

@@ -320,11 +320,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
useWorkflowDraftStore().removeDraft(workflow.path)
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
// Clear draft when unsaved workflow tab is closed
useWorkflowDraftStore().removeDraft(workflow.path)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()

View File

@@ -239,15 +239,6 @@ interface CreateTopupResponse {
amount_cents: number
}
interface TopupStatusResponse {
topup_id: string
status: TopupStatus
amount_cents: number
error_message?: string
created_at: string
completed_at?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
export interface BillingOpStatusResponse {
@@ -701,23 +692,6 @@ export const workspaceApi = {
}
},
/**
* Get top-up status
* GET /api/billing/topup/:id
*/
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<TopupStatusResponse>(
api.apiURL(`/billing/topup/${topupId}`),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing events
* GET /api/billing/events

View File

@@ -130,8 +130,13 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
detail: {
text: 'You have been added to Test Workspace',
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
},
group: 'invite-accepted',
closable: true
})
})

View File

@@ -81,12 +81,17 @@ export function useInviteUrlLoader() {
toast.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
life: 5000
detail: {
text: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
workspaceName: result.workspaceName,
workspaceId: result.workspaceId
},
group: 'invite-accepted',
closable: true
})
} catch (error) {
toast.add({

View File

@@ -567,7 +567,8 @@ const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
if (!node || !newOutputs?.images?.length) return undefined
if (!node || !newOutputs?.images?.length || node.hideOutputImages)
return undefined
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined

View File

@@ -184,7 +184,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.label,
nodeType: widget.nodeType,
options: widgetOptions,
spec: widget.spec
}

View File

@@ -7,7 +7,11 @@
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{ slotData.localized_name || (slotData.name ?? `Output ${index}`) }}
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Output ${index}`)
}}
</span>
</div>
<!-- Connection Dot -->

View File

@@ -4,6 +4,8 @@
<div
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
:class="isEditing === false ? 'visible' : 'invisible'"
tabindex="0"
data-capture-wheel="true"
v-html="renderedHtml"
/>

View File

@@ -16,7 +16,6 @@
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
:placeholder
:readonly="isReadOnly"
:disabled="isReadOnly"
fluid
data-capture-wheel="true"
@pointerdown.capture.stop

View File

@@ -690,6 +690,7 @@ export class ComfyApi extends EventTarget {
'Server feature flags received:',
this.serverFeatureFlags
)
this.dispatchCustomEvent('feature_flags', msg.data)
break
default:
if (this._registered.has(msg.type)) {

View File

@@ -112,7 +112,7 @@ import { pasteImageNode } from '@/composables/usePaste'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
function sanitizeNodeName(string: string) {
export function sanitizeNodeName(string: string) {
let entityMap = {
'&': '',
'<': '',
@@ -723,6 +723,10 @@ export class ComfyApp {
releaseSharedObjectUrl(blobUrl)
})
api.addEventListener('feature_flags', () => {
void useNodeReplacementStore().load()
})
api.init()
}
@@ -786,7 +790,6 @@ export class ComfyApp {
await useWorkspaceStore().workflow.syncWorkflows()
//Doesn't need to block. Blueprints will load async
void useSubgraphStore().fetchSubgraphs()
void useNodeReplacementStore().load()
await useExtensionService().loadExtensions()
this.addProcessKeyHandler()
@@ -1134,6 +1137,8 @@ export class ComfyApp {
const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore()
const collectMissingNodesAndModels = (
nodes: ComfyWorkflowJSON['nodes'],
path: string = ''
@@ -1146,25 +1151,17 @@ export class ComfyApp {
return
}
for (let n of nodes) {
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
if (n.type == 'T2IAdapterLoader') n.type = 'ControlNetLoader'
if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage' //typo fix
if (n.type == 'SDV_img2vid_Conditioning')
n.type = 'SVD_img2vid_Conditioning' //typo fix
if (n.type == 'Load3DAnimation') n.type = 'Load3D' // Animation node merged into Load3D
if (n.type == 'Preview3DAnimation') n.type = 'Preview3D' // Animation node merged into Load3D
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
// Include context about subgraph location if applicable
if (path) {
missingNodeTypes.push({
type: n.type,
hint: `in subgraph '${path}'`
})
} else {
missingNodeTypes.push(n.type)
}
const replacement = nodeReplacementStore.getReplacementFor(n.type)
missingNodeTypes.push({
type: n.type,
...(path && { hint: `in subgraph '${path}'` }),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
n.type = sanitizeNodeName(n.type)
}

View File

@@ -113,7 +113,10 @@ export const useDialogService = () => {
}
}
},
props
props,
footerProps: {
missingNodeTypes: props.missingNodeTypes
}
})
}
@@ -711,6 +714,22 @@ export const useDialogService = () => {
})
}
async function showInviteMemberUpsellDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member-upsell',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
@@ -782,6 +801,7 @@ export const useDialogService = () => {
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog
}

View File

@@ -85,6 +85,65 @@ describe('imagePreviewStore setNodeOutputsByExecutionId with merge', () => {
)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
})
it('should create a new object reference on merge so Vue detects the change', () => {
const store = useNodeOutputStore()
const executionId = '1'
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId(executionId, initialOutput)
const refBefore = store.nodeOutputs[executionId]
const newOutput = createMockOutputs([{ filename: 'b.png' }])
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
const refAfter = store.nodeOutputs[executionId]
expect(refAfter).not.toBe(refBefore)
expect(refAfter?.images).toHaveLength(2)
})
})
describe('imagePreviewStore restoreOutputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should keep reactivity after restoreOutputs followed by setNodeOutputsByExecutionId', () => {
const store = useNodeOutputStore()
// Simulate execution: set outputs for node "4" (e.g., PreviewImage)
const executionOutput = createMockOutputs([
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
])
const savedOutputs: Record<string, ExecutedWsMessage['output']> = {
'4': executionOutput
}
// Simulate undo: restoreOutputs makes app.nodeOutputs and the ref
// share the same underlying object if not handled correctly.
store.restoreOutputs(savedOutputs)
expect(store.nodeOutputs['4']).toStrictEqual(executionOutput)
expect(store.nodeOutputs['3']).toBeUndefined()
// Simulate widget callback setting outputs for node "3" (e.g., LoadImage)
const widgetOutput = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId('3', widgetOutput)
// The reactive store must reflect the new output.
// Before the fix, the raw write to app.nodeOutputs would mutate the
// proxy's target before the proxy write, causing Vue to skip the
// reactivity update.
expect(store.nodeOutputs['3']).toStrictEqual(widgetOutput)
expect(app.nodeOutputs['3']).toStrictEqual(widgetOutput)
})
})
describe('imagePreviewStore getPreviewParam', () => {

View File

@@ -152,7 +152,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
existingOutput[k] = newValue
}
}
nodeOutputs.value[nodeLocatorId] = existingOutput
nodeOutputs.value[nodeLocatorId] = { ...existingOutput }
return
}
}
@@ -365,7 +365,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
outputs: Record<string, ExecutedWsMessage['output']>
) {
app.nodeOutputs = outputs
nodeOutputs.value = outputs
nodeOutputs.value = { ...outputs }
}
function updateNodeImages(node: LGraphNode) {

View File

@@ -3,6 +3,7 @@ import type {
Positionable
} from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import type { SettingParams } from '@/platform/settings/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Keybinding } from '@/platform/keybindings/types'
@@ -93,6 +94,8 @@ export type MissingNodeType =
text: string
callback: () => void
}
isReplaceable?: boolean
replacement?: NodeReplacement
}
export interface ComfyExtension {

View File

@@ -177,6 +177,8 @@ declare module '@/lib/litegraph/src/litegraph' {
isLoading?: boolean
/** The content type of the node's preview media */
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
/** If true, output images are stored but not rendered below the node */
hideOutputImages?: boolean
preview: string[]
/** Index of the currently selected image on a multi-image node such as Preview Image */

View File

@@ -16,6 +16,7 @@
</div>
<GlobalToast />
<InviteAcceptedToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
<ManagerProgressToast />
@@ -44,6 +45,7 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'