mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 09:57:33 +00:00
Compare commits
21 Commits
fix/load-a
...
cloud/1.39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8260e92c | ||
|
|
09d1540e3f | ||
|
|
15a1ec32a0 | ||
|
|
a403049fe0 | ||
|
|
177f833233 | ||
|
|
4eb8148e81 | ||
|
|
105a02cf9b | ||
|
|
af3deb52e9 | ||
|
|
ca5c8b1968 | ||
|
|
c7ef314978 | ||
|
|
ba94cdd7c3 | ||
|
|
8f2710da70 | ||
|
|
ce5283f880 | ||
|
|
557075178e | ||
|
|
afbddeea64 | ||
|
|
86046d2974 | ||
|
|
ab640623cc | ||
|
|
5bcc643473 | ||
|
|
2c050c7347 | ||
|
|
4d5cbbb298 | ||
|
|
a541fadc84 |
205
browser_tests/assets/missing/deprecated_nodes_complex.json
Normal file
205
browser_tests/assets/missing/deprecated_nodes_complex.json
Normal 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
|
||||
}
|
||||
186
browser_tests/assets/missing/deprecated_nodes_simple.json
Normal file
186
browser_tests/assets/missing/deprecated_nodes_simple.json
Normal 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
25
global.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
42
src/components/toast/InviteAcceptedToast.vue
Normal file
42
src/components/toast/InviteAcceptedToast.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -249,8 +249,8 @@
|
||||
"name": "API 노드 가격 배지 표시"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "자동 노드 교체 활성화",
|
||||
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
|
||||
"name": "노드 교체 제안 활성화",
|
||||
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl": {
|
||||
"name": "노드 검색 상자 구현",
|
||||
|
||||
@@ -105,7 +105,10 @@ export function createAssetWidget(
|
||||
})
|
||||
}
|
||||
|
||||
const options: IWidgetAssetOptions = { openModal }
|
||||
const options: IWidgetAssetOptions = {
|
||||
openModal,
|
||||
nodeType: nodeTypeForBrowser
|
||||
}
|
||||
|
||||
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
654
src/platform/nodeReplacement/useNodeReplacement.test.ts
Normal file
654
src/platform/nodeReplacement/useNodeReplacement.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
292
src/platform/nodeReplacement/useNodeReplacement.ts
Normal file
292
src/platform/nodeReplacement/useNodeReplacement.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
2
src/types/litegraph-augmentation.d.ts
vendored
2
src/types/litegraph-augmentation.d.ts
vendored
@@ -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 */
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user