Merge branch 'main' into sno-api-changelog

This commit is contained in:
sno
2025-10-30 16:57:22 +09:00
committed by GitHub
84 changed files with 2337 additions and 547 deletions

View File

@@ -503,7 +503,7 @@ export class NodeReference {
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 50, y: 50 },
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -49,27 +49,42 @@
--color-smoke-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #d6cfc2;
--color-sand-200: #fff7d5;
--color-sand-300: #888682;
--color-sand-400: #eed7ac;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-white: #ffffff;
--color-black: #000000;
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
--color-azure-300: #78bae9;
--color-azure-400: #31b9f4;
--color-azure-600: #0b8ce9;
--color-cobalt-800: #185a8b;
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
--color-coral-700: #b33a3a;
--color-magenta-300: #ceaac9;
--color-magenta-700: #6a246a;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
@@ -101,6 +116,11 @@
var(--color-smoke-500) 50%,
transparent
);
--color-alpha-smoke-500-20: #c5c5c533;
--color-alpha-smoke-400-40: #d9d9d966;
--color-alpha-azure-600-30: #0b8ce94d;
--color-alpha-magenta-700-60: #6a246a99;
--color-alpha-magenta-300-60: #ceaac999;
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
@@ -137,6 +157,8 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);
@@ -196,6 +218,27 @@
--text-secondary: var(--color-ash-500);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
/* Semantic tokens - light mode */
--muted-foreground: var(--color-charcoal-200);
--base-foreground: var(--color-charcoal-800);
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
--primary-background-hover: var(--color-cobalt-800);
--destructive-background: var(--color-coral-500);
--destructive-background-hover: var(--color-coral-600);
--inverted-background-hover: var(--color-charcoal-600);
--warning-background: var(--color-gold-400);
--warning-background-hover: var(--color-gold-500);
--border-default: var(--color-smoke-600);
--border-subtle: var(--color-smoke-400);
--muted-background: var(--color-smoke-700);
--accent-background: var(--color-smoke-800);
}
.dark-theme {
@@ -215,6 +258,8 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -257,6 +302,27 @@
--text-primary: var(--color-white);
--input-surface: rgb(130 130 130 / 0.1);
/* Semantic tokens - dark mode */
--muted-foreground: var(--color-smoke-800);
--base-foreground: var(--color-white);
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-charcoal-600);
--secondary-background-hover: var(--color-charcoal-400);
--secondary-background-selected: var(--color-charcoal-200);
--base-background: var(--color-charcoal-800);
--primary-background: var(--color-azure-600);
--primary-background-hover: var(--color-azure-400);
--destructive-background: var(--color-coral-700);
--destructive-background-hover: var(--color-coral-600);
--inverted-background-hover: var(--color-smoke-200);
--warning-background: var(--color-gold-600);
--warning-background-hover: var(--color-gold-500);
--border-default: var(--color-charcoal-200);
--border-subtle: var(--color-charcoal-300);
--muted-background: var(--color-charcoal-100);
--accent-background: var(--color-charcoal-100);
}
@theme inline {
@@ -266,6 +332,7 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(
--interface-menu-component-surface-hovered
@@ -321,6 +388,27 @@
--color-text-secondary: var(--text-secondary);
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-base-background: var(--base-background);
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);
--color-destructive-background-hover: var(--destructive-background-hover);
--color-inverted-background-hover: var(--inverted-background-hover);
--color-warning-background: var(--warning-background);
--color-warning-background-hover: var(--warning-background-hover);
--color-border-default: var(--border-default);
--color-border-subtle: var(--border-subtle);
--color-muted-background: var(--muted-background);
--color-accent-background: var(--accent-background);
--color-brand-yellow: var(--brand-yellow);
--color-brand-blue: var(--brand-blue);
}
@custom-variant dark-theme {

View File

@@ -21,6 +21,8 @@
@keyup.enter="blurInputElement"
@keyup.escape="cancelEditing"
@click.stop
@pointerdown.stop.capture
@pointermove.stop.capture
/>
</div>
</template>

View File

@@ -146,7 +146,7 @@
size="compact"
variant="ghost"
rounded="lg"
class="hover:bg-white dark-theme:hover:bg-zinc-800"
class="hover:bg-base-background"
>
<template #top>
<CardTop ratio="landscape">
@@ -180,7 +180,7 @@
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
class="hover:bg-white dark-theme:hover:bg-zinc-800"
class="hover:bg-base-background"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
@@ -325,7 +325,7 @@
size="compact"
variant="ghost"
rounded="lg"
class="hover:bg-white dark-theme:hover:bg-zinc-800"
class="hover:bg-base-background"
>
<template #top>
<CardTop ratio="square">

View File

@@ -3,8 +3,10 @@
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady">
<template v-if="showUI && workflowTabsPosition === 'Topbar'" #workflow-tabs>
<template v-if="showUI" #workflow-tabs>
<TryVueNodeBanner />
<div
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
>
<!-- Native drag area for Electron -->
@@ -152,6 +154,8 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import TryVueNodeBanner from '../topbar/TryVueNodeBanner.vue'
const emit = defineEmits<{
ready: []
}>()

View File

@@ -92,7 +92,7 @@ describe('BypassButton', () => {
const button = wrapper.find('button')
expect(button.classes()).not.toContain(
'dark-theme:[&:not(:active)]:!bg-[#262729]'
'dark-theme:[&:not(:active)]:!bg-charcoal-600'
)
})

View File

@@ -7,7 +7,7 @@
severity="secondary"
text
data-testid="bypass-button"
class="hover:bg-[#E7E6E6] hover:dark-theme:bg-charcoal-600"
class="hover:bg-secondary-background"
@click="toggleBypass"
>
<template #icon>

View File

@@ -4,7 +4,7 @@
value: t('selectionToolbox.executeButton.tooltip'),
showDelay: 1000
}"
class="size-8 bg-[#31B9F4] !p-0 dark-theme:bg-[#0B8CE9]"
class="size-8 bg-azure-400 !p-0 dark-theme:bg-azure-600"
text
@mouseenter="() => handleMouseEnter()"
@mouseleave="() => handleMouseLeave()"

View File

@@ -27,9 +27,9 @@
:severity="option.badge === 'new' ? 'info' : 'secondary'"
:value="t(option.badge)"
:class="{
'rounded-4xl bg-[#31B9F4] dark-theme:bg-[#0B8CE9]':
'rounded-4xl bg-azure-400 dark-theme:bg-azure-600':
option.badge === 'new',
'rounded-4xl bg-[#9C9EAB] dark-theme:bg-[#000]':
'rounded-4xl bg-slate-100 dark-theme:bg-black':
option.badge === 'deprecated',
'h-4 gap-2.5 px-1 text-[9px] text-white uppercase': true
}"

View File

@@ -104,6 +104,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
@@ -206,7 +207,9 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
disabled: isCloud
? !menuTargetTask.value?.isHistory
: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),

View File

@@ -66,6 +66,7 @@ function updateToastPosition() {
.p-toast.p-component.p-toast-top-right {
top: ${rect.top + 100}px !important;
right: ${window.innerWidth - (rect.left + rect.width) + 20}px !important;
z-index: 10000 !important;
}
`
}

View File

@@ -0,0 +1,48 @@
<template>
<Toast
group="vue-nodes-migration"
position="bottom-center"
class="w-auto"
@close="handleClose"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigration.message')
}}</span>
<Button
class="whitespace-nowrap"
size="small"
:label="t('vueNodesMigration.button')"
text
@click="handleOpenSettings"
/>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const toast = useToast()
const dialogService = useDialogService()
const isDismissed = useVueNodesMigrationDismissed()
const handleOpenSettings = () => {
dialogService.showSettingsDialog()
toast.removeGroup('vue-nodes-migration')
isDismissed.value = true
}
const handleClose = () => {
isDismissed.value = true
}
</script>

View File

@@ -37,7 +37,7 @@
>
{{ badge.label }}
</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div class="text-sm font-inter">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
@@ -90,7 +90,7 @@
>
{{ badge.label }}
</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div class="text-sm font-inter">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
@@ -117,7 +117,7 @@
>
{{ badge.label }}
</div>
<div class="font-inter text-sm font-extrabold" :class="textClasses">
<div class="font-inter text-sm" :class="textClasses">
{{ badge.text }}
</div>
</div>

View File

@@ -0,0 +1,71 @@
<template>
<div
v-if="showVueNodesBanner"
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center">
<i class="icon-[lucide--sparkles]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.message') }}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"
>
{{ $t('vueNodesBanner.tryItOut') }}
</Button>
</div>
<Button
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4"
unstyled
@click="handleDismiss"
>
<i class="w-5 h-5 icon-[lucide--x]"></i>
</Button>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const STORAGE_KEY = 'vueNodesBannerDismissed'
const settingStore = useSettingStore()
const bannerDismissed = useLocalStorage(STORAGE_KEY, false)
const vueNodesEnabled = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
return false
}
})
const showVueNodesBanner = computed(() => {
if (vueNodesEnabled.value) {
return false
}
if (bannerDismissed.value) {
return false
}
return true
})
const handleDismiss = (): void => {
bannerDismissed.value = true
}
const handleTryItOut = async (): Promise<void> => {
try {
await settingStore.set('Comfy.VueNodes.Enabled', true)
} catch (error) {
console.error('Failed to enable Vue nodes:', error)
} finally {
handleDismiss()
}
}
</script>

View File

@@ -4,6 +4,7 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -11,6 +12,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -21,6 +23,8 @@ function useVueNodeLifecycleIndividual() {
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph
@@ -75,11 +79,20 @@ function useVueNodeLifecycleIndividual() {
// Watch for Vue nodes enabled state changes
watch(
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
(enabled) => {
(enabled, wasEnabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale()
if (!wasEnabled && !isVueNodeToastDismissed.value) {
useToastStore().add({
group: 'vue-nodes-migration',
severity: 'info',
life: 0
})
}
} else {
comfyApp.canvas?.setDirty(true, true)
disposeNodeManagerAndSyncs()
}
},

View File

@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { BadgePosition, LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -12,7 +13,6 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import { adjustColor } from '@/utils/colorUtil'
/**
* Add LGraphBadge to LGraphNode based on settings.
@@ -27,6 +27,7 @@ export const useNodeBadge = () => {
const settingStore = useSettingStore()
const extensionStore = useExtensionStore()
const colorPaletteStore = useColorPaletteStore()
const priceBadge = usePriceBadge()
const nodeSourceBadgeMode = computed(
() =>
@@ -118,29 +119,7 @@ export const useNodeBadge = () => {
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
const isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
return priceBadge.getCreditsBadge(price)
}
if (hasDynamicPricing) {
@@ -162,6 +141,23 @@ export const useNodeBadge = () => {
node.badges.push(() => creditsBadge.value)
}
},
init() {
app.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
() => {
for (const node of app.canvas.graph?.nodes ?? [])
priceBadge.updateSubgraphCredits(node)
}
)
app.canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => priceBadge.updateSubgraphCredits(e.detail.subgraphNode)
)
},
afterConfigureGraph() {
for (const node of app.canvas.graph?.nodes ?? [])
priceBadge.updateSubgraphCredits(node)
}
})
})

View File

@@ -0,0 +1,69 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
export const usePriceBadge = () => {
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
const newBadges = collectCreditsBadges(node.subgraph)
if (newBadges.length > 1) {
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
} else {
node.badges.push(...newBadges)
}
}
function collectCreditsBadges(
graph: LGraph,
visited: Set<string> = new Set()
): (LGraphBadge | (() => LGraphBadge))[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const badges = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
? collectCreditsBadges(node.subgraph, visited)
: node.badges.filter((b) => isCreditsBadge(b)))
)
}
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
return (
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
)
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
return {
getCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -5,10 +5,7 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import {
promoteRecommendedWidgets,
tryToggleWidgetPromotion
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { tryToggleWidgetPromotion } from '@/core/graph/subgraph/proxyWidgetUtils'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { t } from '@/i18n'
import {
@@ -945,7 +942,6 @@ export function useCoreCommands(): ComfyCommand[] {
const { node } = res
canvas.select(node)
promoteRecommendedWidgets(node)
canvasStore.updateSelectedItems()
}
},

View File

@@ -0,0 +1,8 @@
import { createSharedComposable, useLocalStorage } from '@vueuse/core'
// Browser storage events don't fire in the same tab, so separate
// useLocalStorage() calls create isolated reactive refs. Use shared
// composable to ensure all components use the same ref instance.
export const useVueNodesMigrationDismissed = createSharedComposable(() =>
useLocalStorage('comfy.vueNodesMigration.dismissed', false)
)

View File

@@ -1,4 +1,7 @@
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import {
demoteWidget,
promoteRecommendedWidgets
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
@@ -62,6 +65,10 @@ export function registerProxyWidgets(canvas: LGraphCanvas) {
}
}
})
canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
)
SubgraphNode.prototype.onConfigure = onConfigure
}
@@ -158,7 +165,11 @@ function resolveLinkedWidget(
const { graph, nodeId, widgetName } = overlay
const n = getNodeByExecutionId(graph, nodeId)
if (!n) return [undefined, undefined]
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
//Slightly hacky. Force recursive resolution of nested widgets
if (widget instanceof disconnectedWidget.constructor && isProxyWidget(widget))
widget.computedHeight = 20
return [n, widget]
}
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {

View File

@@ -3,10 +3,13 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -287,6 +290,16 @@ useExtensionService().registerExtension({
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for Load3D nodes
if (node.constructor.comfyClass !== 'Load3D') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'Load3D') return
@@ -505,6 +518,16 @@ useExtensionService().registerExtension({
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for Preview3D nodes
if (node.constructor.comfyClass !== 'Preview3D') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
PREVIEW_3D(node) {

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { CameraManager } from './CameraManager'
@@ -22,6 +22,7 @@ import {
type MaterialMode,
type UpDirection
} from './interfaces'
import { app } from '@/scripts/app'
class Load3d {
renderer: THREE.WebGLRenderer
@@ -51,6 +52,13 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
@@ -164,6 +172,8 @@ class Load3d {
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.handleResize()
this.startAnimation()
@@ -172,6 +182,65 @@ class Load3d {
}, 100)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
*/
private initContextMenu(): void {
const canvas = this.renderer.domElement
this.contextMenuAbortController = new AbortController()
const { signal } = this.contextMenuAbortController
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
}
const contextmenuHandler = (e: MouseEvent) => {
const wasDragging = this.rightMouseMoved
this.rightMouseMoved = false
if (wasDragging) {
return
}
e.preventDefault()
e.stopPropagation()
this.showNodeContextMenu(e)
}
canvas.addEventListener('mousedown', mousedownHandler, { signal })
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
}
private showNodeContextMenu(event: MouseEvent): void {
const menuOptions = app.canvas.getNodeMenuOptions(this.node)
new LiteGraph.ContextMenu(menuOptions, {
event,
title: this.node.type,
extra: this.node
})
}
getEventManager(): EventManager {
return this.eventManager
}
@@ -621,6 +690,11 @@ class Load3d {
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null
}
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {

View File

@@ -0,0 +1,59 @@
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import Load3d from '@/extensions/core/load3d/Load3d'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const EXPORT_FORMATS = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
] as const
/**
* Creates export menu items for a 3D node using the new extension API.
* Returns an array of context menu items including a separator and export submenu.
*/
export function createExportMenuItems(
load3d: Load3d
): (IContextMenuValue | null)[] {
return [
null, // Separator
{
content: 'Save',
has_submenu: true,
callback: (_value, _options, event, prev_menu) => {
const submenuOptions: IContextMenuValue[] = EXPORT_FORMATS.map(
(format) => ({
content: format.label,
callback: () => {
void (async () => {
try {
await load3d.exportModel(format.value)
useToastStore().add({
severity: 'success',
summary: t('toastMessages.exportSuccess', {
format: format.label
})
})
} catch (error) {
console.error('Export failed:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.label
})
)
}
})()
}
})
)
new LiteGraph.ContextMenu(submenuOptions, {
event,
parentMenu: prev_menu
})
}
}
]
}

View File

@@ -1,7 +1,10 @@
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
@@ -42,6 +45,16 @@ useExtensionService().registerExtension({
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for SaveGLB nodes
if (node.constructor.comfyClass !== 'SaveGLB') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'SaveGLB') return

View File

@@ -1710,6 +1710,14 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
bubbles: true,
detail: { subgraphNode: subgraphNode as SubgraphNode }
})
)
)
return { subgraph, node: subgraphNode as SubgraphNode }
}

View File

@@ -86,7 +86,11 @@ import {
RenderShape,
TitleMode
} from './types/globalEnums'
import type { ClipboardItems, SubgraphIO } from './types/serialisation'
import type {
ClipboardItems,
ISerialisedNode,
SubgraphIO
} from './types/serialisation'
import type { NeverNever, PickNevers } from './types/utility'
import type { IBaseWidget } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
@@ -1772,47 +1776,24 @@ export class LGraphCanvas
menu: ContextMenu,
node: LGraphNode
): void {
const { graph } = node
if (!graph) throw new NullGraphError()
graph.beforeChange()
const newSelected = new Set<LGraphNode>()
const fApplyMultiNode = function (
node: LGraphNode,
newNodes: Set<LGraphNode>
): void {
if (node.clonable === false) return
const newnode = node.clone()
if (!newnode) return
newnode.pos = [node.pos[0] + 5, node.pos[1] + 5]
if (!node.graph) throw new NullGraphError()
node.graph.add(newnode)
newNodes.add(newnode)
}
const canvas = LGraphCanvas.active_canvas
if (
!canvas.selected_nodes ||
Object.keys(canvas.selected_nodes).length <= 1
) {
fApplyMultiNode(node, newSelected)
} else {
for (const i in canvas.selected_nodes) {
fApplyMultiNode(canvas.selected_nodes[i], newSelected)
}
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
// Find top-left-most boundary
let offsetX = Infinity
let offsetY = Infinity
for (const item of nodes) {
if (item.pos == null)
throw new TypeError(
'Invalid node encountered on clone. `pos` was null.'
)
if (item.pos[0] < offsetX) offsetX = item.pos[0]
if (item.pos[1] < offsetY) offsetY = item.pos[1]
}
if (newSelected.size) {
canvas.selectNodes([...newSelected])
}
graph.afterChange()
canvas.setDirty(true, true)
canvas._deserializeItems(canvas._serializeItems(nodes), {
position: [offsetX + 5, offsetY + 5]
})
}
/**
@@ -2384,42 +2365,22 @@ export class LGraphCanvas
node &&
this.allow_interaction
) {
let newType = node.type
const items = this._deserializeItems(this._serializeItems([node]), {
position: node.pos
})
const cloned = items?.created[0] as LGraphNode | undefined
if (!cloned) return
if (node instanceof SubgraphNode) {
const cloned = node.subgraph.clone().asSerialisable()
cloned.pos[0] += 5
cloned.pos[1] += 5
const subgraph = graph.createSubgraph(cloned)
subgraph.configure(cloned)
newType = subgraph.id
}
const node_data = node.clone()?.serialize()
if (node_data?.type != null) {
// Ensure the cloned node is configured against the correct type (especially for SubgraphNodes)
node_data.type = newType
const cloned = LiteGraph.createNode(newType)
if (cloned) {
cloned.configure(node_data)
cloned.pos[0] += 5
cloned.pos[1] += 5
if (this.allow_dragnodes) {
pointer.onDragStart = (pointer) => {
graph.add(cloned, false)
this.#startDraggingItems(cloned, pointer)
}
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
} else {
// TODO: Check if before/after change are necessary here.
graph.beforeChange()
graph.add(cloned, false)
graph.afterChange()
}
return
if (this.allow_dragnodes) {
pointer.onDragStart = (pointer) => {
this.#startDraggingItems(cloned, pointer)
}
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
}
return
}
// Node clicked
@@ -2711,7 +2672,7 @@ export class LGraphCanvas
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
...(output._floatingLinks ?? new Set())
]
return outputLinks.some(
(linkId) =>
@@ -3963,17 +3924,26 @@ export class LGraphCanvas
const { created, nodes, links, reroutes } = results
// const failedNodes: ISerialisedNode[] = []
const subgraphIdMap: Record<string, string> = {}
// SubgraphV2: Remove always-clone behaviour
//Update subgraph ids
for (const subgraphInfo of parsed.subgraphs)
subgraphInfo.id = subgraphIdMap[subgraphInfo.id] = createUuidv4()
const allNodeInfo: ISerialisedNode[] = [
parsed.nodes ? [parsed.nodes] : [],
parsed.subgraphs ? parsed.subgraphs.map((s) => s.nodes ?? []) : []
].flat(2)
for (const nodeInfo of allNodeInfo)
if (nodeInfo.type in subgraphIdMap)
nodeInfo.type = subgraphIdMap[nodeInfo.type]
// Subgraphs
for (const info of parsed.subgraphs) {
// SubgraphV2: Remove always-clone behaviour
const originalId = info.id
info.id = createUuidv4()
const subgraph = graph.createSubgraph(info)
subgraph.configure(info)
results.subgraphs.set(originalId, subgraph)
results.subgraphs.set(info.id, subgraph)
}
for (const info of parsed.subgraphs)
results.subgraphs.get(info.id)?.configure(info)
// Groups
for (const info of parsed.groups) {
@@ -3985,17 +3955,6 @@ export class LGraphCanvas
created.push(group)
}
// Update subgraph ids with nesting
function updateSubgraphIds(nodes: { type: string }[]) {
for (const info of nodes) {
const subgraph = results.subgraphs.get(info.type)
if (!subgraph) continue
info.type = subgraph.id
updateSubgraphIds(subgraph.nodes)
}
}
updateSubgraphIds(parsed.nodes)
// Nodes
for (const info of parsed.nodes) {
const node = info.type == null ? null : LiteGraph.createNode(info.type)

View File

@@ -2569,7 +2569,12 @@ export class LGraphNode
findInputByType(
type: ISlotType
): { index: number; slot: INodeInputSlot } | undefined {
return findFreeSlotOfType(this.inputs, type, (input) => input.link == null)
return findFreeSlotOfType(
this.inputs,
type,
(input) =>
input.link == null || !!this.graph?.getLink(input.link)?._dragging
)
}
/**

View File

@@ -21,6 +21,11 @@ export interface LGraphCanvasEventMap {
fromNode: SubgraphNode
}
/** Dispatched after a group of items has been converted to a subgraph*/
'subgraph-converted': {
subgraphNode: SubgraphNode
}
'litegraph:canvas':
| { subType: 'before-change' | 'after-change' }
| {

View File

@@ -618,4 +618,17 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Call parent serialize method
return super.serialize()
}
override clone() {
const clone = super.clone()
// force reasign so domWidgets reset ownership
// eslint-disable-next-line no-self-assign
this.properties.proxyWidgets = this.properties.proxyWidgets
//TODO: Consider deep cloning subgraphs here.
//It's the safest place to prevent creation of linked subgraphs
//But the frequency of clone().serialize() calls is likely to result in
//pollution of rootGraph.subgraphs
return clone
}
}

View File

@@ -1472,6 +1472,7 @@
"failedToApplyTexture": "Failed to apply texture",
"no3dSceneToExport": "No 3D scene to export",
"failedToExportModel": "Failed to export model as {format}",
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"interrupted": "Execution has been interrupted",
@@ -1653,18 +1654,24 @@
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"apiNodesBalance": "\"API Nodes\" Credit Balance",
"apiNodesDescription": "For running commercial/proprietary models",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
"viewUsageHistory": "View usage history",
"addApiCredits": "Add API credits",
"addCredits": "Add credits",
"monthlyCreditsRollover": "These credits will rollover to the next month",
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Credits purchased separately that don't expire",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "$10 in monthly credits for API models — top up when needed",
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"required": {
@@ -1812,5 +1819,13 @@
"Close": "Close"
}
}
},
"vueNodesMigration": {
"message": "Prefer the classic node design?",
"button": "Open Settings"
},
"vueNodesBanner": {
"message": "Nodes just got a new look and feel",
"tryItOut": "Try it out"
}
}
}

View File

@@ -27,8 +27,7 @@
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'text-slate-800',
'dark-theme:text-white'
'text-base-foreground'
)
"
:title="asset.name"
@@ -112,7 +111,7 @@ const cardClasses = computed(() => {
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-smoke-100 dark-theme:bg-charcoal-800',
'hover:bg-smoke-200 dark-theme:hover:bg-charcoal-600',
'hover:bg-secondary-background',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'
)

View File

@@ -3,7 +3,7 @@
<IconTextButton
v-if="asset?.kind !== '3D'"
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Inspect asset"
@click="handleInspect"
>
@@ -15,7 +15,7 @@
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Add to current workflow"
@click="handleAddToWorkflow"
>
@@ -26,7 +26,7 @@
<IconTextButton
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Download"
@click="handleDownload"
>
@@ -40,7 +40,7 @@
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Open as workflow in new tab"
@click="handleOpenWorkflow"
>
@@ -52,7 +52,7 @@
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Export workflow"
@click="handleExportWorkflow"
>
@@ -66,7 +66,7 @@
<IconTextButton
v-if="showCopyJobId"
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Copy job ID"
@click="handleCopyJobId"
>
@@ -80,7 +80,7 @@
<IconTextButton
v-if="shouldShowDeleteButton"
type="transparent"
class="dark-theme:text-white"
class="text-base-foreground"
label="Delete"
@click="handleDelete"
>

View File

@@ -1,6 +1,6 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
class="m-0 line-clamp-1 text-sm font-bold text-base-foreground"
:title="fullName"
>
{{ displayName }}

View File

@@ -1,15 +1,15 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-start gap-2 pb-2">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
@@ -19,8 +19,15 @@
text
icon="pi pi-external-link"
icon-pos="left"
size="small"
class="self-start !p-0 text-sm hover:!bg-transparent [&]:!text-[inherit]"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
@click="handleViewMoreDetails"
/>
</div>

View File

@@ -1,10 +1,10 @@
<template>
<TabPanel value="PlanCredits" class="subscription-container h-full">
<div class="flex h-full flex-col">
<div class="flex items-center gap-2">
<h2 class="text-2xl">
<div class="flex h-full flex-col gap-6">
<div class="flex items-baseline gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
{{ $t('subscription.title') }}
</h2>
</span>
<CloudBadge
reverse-order
background-color="var(--p-dialog-background)"
@@ -12,17 +12,20 @@
</div>
<div class="grow overflow-auto">
<div class="rounded-lg border border-charcoal-400 p-4">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold">{{
formattedMonthlyPrice
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
<span>{{ $t('subscription.perMonth') }}</span>
</div>
<div v-if="isActiveSubscription" class="text-xs text-muted">
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
@@ -43,7 +46,15 @@
v-if="isActiveSubscription"
:label="$t('subscription.manageSubscription')"
severity="secondary"
class="text-xs"
class="text-xs bg-interface-menu-component-surface-selected"
:pt="{
root: {
style: 'border-radius: 8px; padding: 8px 16px;'
},
label: {
class: 'text-text-primary'
}
}"
@click="manageSubscription"
/>
<SubscribeButton
@@ -56,92 +67,143 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 rounded-lg pt-10 lg:grid-cols-2">
<div class="flex flex-col">
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.apiNodesBalance') }}
{{ $t('subscription.partnerNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-xs text-muted">
{{ $t('subscription.apiNodesDescription') }}
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
</div>
</div>
</div>
<div
class="flex flex-col gap-3 rounded-lg border p-4 dark-theme:border-0 dark-theme:bg-charcoal-600"
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-smoke-100 dark-theme:bg-charcoal-600'
)
"
>
<Button
v-tooltip="refreshTooltip"
icon="pi pi-sync"
text
size="small"
class="absolute top-0.5 right-0"
:loading="isLoadingBalance"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
},
loadingIcon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleRefresh"
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
${{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<div class="flex flex-col gap-1">
<div class="flex items-center gap-4">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
</div>
<Button
v-tooltip="$t('subscription.monthlyCreditsRollover')"
icon="pi pi-question-circle"
text
rounded
size="small"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
}
}"
/>
</div>
</div>
<div class="flex items-center gap-4">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
icon="pi pi-question-circle"
text
rounded
size="small"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
}
}"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<div class="text-2xl font-bold">${{ totalCredits }}</div>
</div>
<Button
icon="pi pi-sync"
severity="secondary"
size="small"
:loading="isLoadingBalance"
@click="handleRefresh"
/>
</div>
<div
v-if="latestEvents.length > 0"
class="flex flex-col gap-2 pt-3 text-xs"
>
<div
v-for="event in latestEvents"
:key="event.event_id"
class="flex items-center justify-between py-1"
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-text-secondary underline hover:text-text-secondary"
style="text-decoration: underline"
>
<div class="flex flex-col gap-0.5">
<span class="font-medium">
{{
event.event_type
? customerEventService.formatEventType(
event.event_type
)
: ''
}}
</span>
<span class="text-muted">
{{
event.createdAt
? customerEventService.formatDate(event.createdAt)
: ''
}}
</span>
</div>
<div
v-if="event.params?.amount !== undefined"
class="font-bold"
>
${{
customerEventService.formatAmount(
event.params.amount as number
)
}}
</div>
</div>
</div>
<div class="flex items-center justify-between pt-2">
<Button
:label="$t('subscription.viewUsageHistory')"
text
severity="secondary"
class="p-0 text-xs text-muted"
@click="handleViewUsageHistory"
/>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
:label="$t('subscription.addApiCredits')"
:label="$t('subscription.addCredits')"
severity="secondary"
class="text-xs"
class="p-2 min-h-8 bg-interface-menu-component-surface-selected"
:pt="{
root: {
style: 'border-radius: 8px;'
},
label: {
class: 'text-sm'
}
}"
@click="handleAddApiCredits"
/>
</div>
@@ -149,7 +211,7 @@
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2 flex-1">
<div class="text-sm">
{{ $t('subscription.yourPlanIncludes') }}
</div>
@@ -161,7 +223,7 @@
</div>
<div
class="flex items-center justify-between border-t border-charcoal-400 pt-3"
class="flex items-center justify-between border-t border-interface-stroke pt-3"
>
<div class="flex gap-2">
<Button
@@ -170,7 +232,15 @@
severity="secondary"
icon="pi pi-question-circle"
class="text-xs"
@click="handleLearnMore"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleLearnMoreClick"
/>
<Button
:label="$t('subscription.messageSupport')"
@@ -178,6 +248,15 @@
severity="secondary"
icon="pi pi-comment"
class="text-xs"
:loading="isLoadingSupport"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleMessageSupport"
/>
</div>
@@ -189,6 +268,14 @@
icon="pi pi-external-link"
icon-pos="right"
class="text-xs"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleInvoiceHistory"
/>
</div>
@@ -198,26 +285,16 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, onMounted, ref } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { AuditLog } from '@/services/customerEventsService'
import { useCustomerEventsService } from '@/services/customerEventsService'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const authStore = useFirebaseAuthStore()
const customerEventService = useCustomerEventsService()
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { cn } from '@/utils/tailwindUtil'
const {
isActiveSubscription,
@@ -226,54 +303,20 @@ const {
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
handleViewUsageHistory,
handleLearnMore,
handleInvoiceHistory,
fetchStatus
handleInvoiceHistory
} = useSubscription()
const latestEvents = ref<AuditLog[]>([])
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const totalCredits = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
const fetchLatestEvents = async () => {
try {
const response = await customerEventService.getMyEvents({
page: 1,
limit: 2
})
if (response?.events) {
latestEvents.value = response.events
}
} catch (error) {
console.error('[SubscriptionPanel] Error fetching latest events:', error)
}
}
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
await commandStore.execute('Comfy.ContactSupport')
}
const handleRefresh = async () => {
await Promise.all([
authActions.fetchBalance(),
fetchStatus(),
fetchLatestEvents()
])
}
const {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
} = useSubscriptionActions()
</script>
<style scoped>

View File

@@ -1,4 +1,5 @@
import { computed, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
@@ -14,34 +15,30 @@ import {
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
interface CloudSubscriptionCheckoutResponse {
type CloudSubscriptionCheckoutResponse = {
checkout_url: string
}
interface CloudSubscriptionStatusResponse {
type CloudSubscriptionStatusResponse = {
is_active: boolean
subscription_id: string
renewal_date: string | null
end_date?: string | null
}
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const isActiveSubscription = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
const isSubscribedOrIsNotCloud = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
return subscriptionStatus.value?.is_active ?? false
})
let isWatchSetup = false
export function useSubscription() {
const authActions = useFirebaseAuthActions()
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const dialogService = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { reportError } = useFirebaseAuthActions()
const { isLoggedIn } = useCurrentUser()
@@ -54,7 +51,7 @@ export function useSubscription() {
const renewalDate = new Date(subscriptionStatus.value.renewal_date)
return renewalDate.toLocaleDateString('en-US', {
return renewalDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
@@ -66,7 +63,7 @@ export function useSubscription() {
const endDate = new Date(subscriptionStatus.value.end_date)
return endDate.toLocaleDateString('en-US', {
return endDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
@@ -77,9 +74,10 @@ export function useSubscription() {
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const fetchStatus = wrapWithErrorHandlingAsync(async () => {
return await fetchSubscriptionStatus()
}, reportError)
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
)
const subscribe = wrapWithErrorHandlingAsync(async () => {
const response = await initiateSubscriptionCheckout()
@@ -104,13 +102,13 @@ export function useSubscription() {
}
const manageSubscription = async () => {
await authActions.accessBillingPortal()
await accessBillingPortal()
}
const requireActiveSubscription = async (): Promise<void> => {
await fetchSubscriptionStatus()
if (!isActiveSubscription.value) {
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog()
}
}
@@ -124,61 +122,55 @@ export function useSubscription() {
}
const handleInvoiceHistory = async () => {
await authActions.accessBillingPortal()
await accessBillingPortal()
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
const fetchSubscriptionStatus =
async (): Promise<CloudSubscriptionStatusResponse | null> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
)
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
)
}
const statusData = await response.json()
subscriptionStatus.value = statusData
return statusData
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
if (!isWatchSetup) {
isWatchSetup = true
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
},
{ immediate: true }
}
)
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
)
}
const statusData = await response.json()
subscriptionStatus.value = statusData
return statusData
}
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
}
},
{ immediate: true }
)
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
@@ -213,7 +205,7 @@ export function useSubscription() {
return {
// State
isActiveSubscription,
isActiveSubscription: isSubscribedOrIsNotCloud,
isCancelled,
formattedRenewalDate,
formattedEndDate,
@@ -230,3 +222,5 @@ export function useSubscription() {
handleInvoiceHistory
}
}
export const useSubscription = createSharedComposable(useSubscriptionInternal)

View File

@@ -0,0 +1,66 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
const { t } = useI18n()
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const { fetchStatus, formattedRenewalDate } = useSubscription()
const isLoadingSupport = ref(false)
const refreshTooltip = computed(() => {
const date =
formattedRenewalDate.value || t('subscription.nextBillingCycle')
return `Refreshes on ${date}`
})
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
} finally {
isLoadingSupport.value = false
}
}
const handleRefresh = async () => {
try {
await Promise.all([authActions.fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}
}
const handleLearnMoreClick = () => {
window.open('https://docs.comfy.org/get_started/cloud', '_blank')
}
return {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
}
}

View File

@@ -0,0 +1,61 @@
import { computed } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const totalCredits = computed(() => {
if (!authStore.balance?.amount_micros) return '0.00'
try {
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting total credits:',
error
)
return '0.00'
}
})
const monthlyBonusCredits = computed(() => {
const balance = authStore.balance as any
if (!balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(balance.cloud_credit_balance_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const prepaidCredits = computed(() => {
const balance = authStore.balance as any
if (!balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(balance.prepaid_balance_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
isLoadingBalance
}
}

View File

@@ -13,11 +13,6 @@
import { isCloud } from '@/platform/distribution/types'
import type { TaskItem } from '@/schemas/apiSchema'
interface ReconciliationResult {
/** All items to display, sorted by queueIndex descending (newest first) */
items: TaskItem[]
}
/**
* V1 reconciliation: QueueIndex-based filtering works because V1 has stable,
* monotonically increasing queue indices.
@@ -25,13 +20,15 @@ interface ReconciliationResult {
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
* results remain sorted. clientHistory is assumed already sorted from previous update.
*
* @returns All items to display, sorted by queueIndex descending (newest first)
*/
function reconcileHistoryV1(
serverHistory: TaskItem[],
clientHistory: TaskItem[],
maxItems: number,
lastKnownQueueIndex: number | undefined
): ReconciliationResult {
): TaskItem[] {
const sortedServerHistory = serverHistory.sort(
(a, b) => b.prompt[0] - a.prompt[0]
)
@@ -53,13 +50,9 @@ function reconcileHistoryV1(
)
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
const allItems = [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
return [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
.sort((a, b) => b.prompt[0] - a.prompt[0])
.slice(0, maxItems)
return {
items: allItems
}
}
/**
@@ -69,12 +62,14 @@ function reconcileHistoryV1(
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
* results remain sorted. clientHistory is assumed already sorted from previous update.
*
* @returns All items to display, sorted by queueIndex descending (newest first)
*/
function reconcileHistoryV2(
serverHistory: TaskItem[],
clientHistory: TaskItem[],
maxItems: number
): ReconciliationResult {
): TaskItem[] {
const sortedServerHistory = serverHistory.sort(
(a, b) => b.prompt[0] - a.prompt[0]
)
@@ -84,29 +79,18 @@ function reconcileHistoryV2(
)
const clientPromptIds = new Set(clientHistory.map((item) => item.prompt[1]))
const newPromptIds = new Set(
[...serverPromptIds].filter((id) => !clientPromptIds.has(id))
const newItems = sortedServerHistory.filter(
(item) => !clientPromptIds.has(item.prompt[1])
)
const newItems = sortedServerHistory.filter((item) =>
newPromptIds.has(item.prompt[1])
)
const retainedPromptIds = new Set(
[...serverPromptIds].filter((id) => clientPromptIds.has(id))
)
const clientItemsStillOnServer = clientHistory.filter((item) =>
retainedPromptIds.has(item.prompt[1])
serverPromptIds.has(item.prompt[1])
)
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
const allItems = [...newItems, ...clientItemsStillOnServer]
return [...newItems, ...clientItemsStillOnServer]
.sort((a, b) => b.prompt[0] - a.prompt[0])
.slice(0, maxItems)
return {
items: allItems
}
}
/**
@@ -125,7 +109,7 @@ export function reconcileHistory(
clientHistory: TaskItem[],
maxItems: number,
lastKnownQueueIndex?: number
): ReconciliationResult {
): TaskItem[] {
if (isCloud) {
return reconcileHistoryV2(serverHistory, clientHistory, maxItems)
}

View File

@@ -1057,17 +1057,17 @@ export const CORE_SETTINGS: SettingParams[] = [
*/
{
id: 'Comfy.VueNodes.Enabled',
name: 'Enable Vue node rendering (hidden)',
type: 'hidden',
name: 'Modern Node Design (Vue Nodes)',
type: 'boolean',
tooltip:
'Render nodes as Vue components instead of canvas. Hidden; toggle via Experimental keybinding.',
'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.',
defaultValue: false,
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.VueNodes.AutoScaleLayout',
name: 'Auto-scale layout for Vue nodes',
name: 'Auto-scale layout (Vue nodes)',
tooltip:
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
type: 'boolean',

View File

@@ -0,0 +1,21 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { PromptId } from '@/schemas/apiSchema'
export async function getWorkflowFromHistory(
fetchApi: (url: string) => Promise<Response>,
promptId: PromptId
): Promise<ComfyWorkflowJSON | undefined> {
try {
const res = await fetchApi(`/history_v2/${promptId}`)
const json = await res.json()
const historyItem = json[promptId]
if (!historyItem) return undefined
const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow
return workflow ?? undefined
} catch (error) {
console.error(`Failed to fetch workflow for prompt ${promptId}:`, error)
return undefined
}
}

View File

@@ -0,0 +1,10 @@
/**
* Cloud: Fetches workflow by prompt_id. Desktop: Returns undefined (workflows already in history).
*/
import { isCloud } from '@/platform/distribution/types'
import { getWorkflowFromHistory as cloudImpl } from './getWorkflowFromHistory'
export const getWorkflowFromHistory = isCloud
? cloudImpl
: async () => undefined

View File

@@ -154,10 +154,13 @@ export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph
const connector = app.canvas?.linkConnector
if (!graph || !connector) return null
let adapter = adapterByGraph.get(graph)
if (!adapter || adapter.linkConnector !== connector) {
adapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, adapter)
const adapter = adapterByGraph.get(graph)
if (adapter && adapter.linkConnector === connector) {
return adapter
}
return adapter
const newAdapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, newAdapter)
return newAdapter
}

View File

@@ -34,7 +34,7 @@
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain"
class="block size-full object-contain pointer-events-none"
@load="handleImageLoad"
@error="handleImageError"
/>

View File

@@ -15,7 +15,7 @@
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent -outline-offset-2 outline-2',
'outline-transparent outline-2',
borderClass,
outlineClass,
{
@@ -44,7 +44,20 @@
@wheel="handleWheel"
@contextmenu="handleContextMenu"
>
<div class="flex items-center">
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
@@ -132,12 +145,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
@@ -233,6 +248,9 @@ const nodeOpacity = computed(() => {
return globalOpacity
})
const hasInputs = computed(() => nonWidgetedInputs(nodeData).length > 0)
const hasOutputs = computed((): boolean => !!nodeData.outputs?.length)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()

View File

@@ -2,8 +2,11 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ $t('Node Slots Error') }}
</div>
<div v-else class="lg-node-slots flex justify-between">
<div v-if="filteredInputs.length" class="flex flex-col gap-1">
<div v-else :class="cn('flex justify-between', unifiedWrapperClass)">
<div
v-if="filteredInputs.length"
:class="cn('flex flex-col gap-1', unifiedDotsClass)"
>
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
@@ -14,7 +17,10 @@
/>
</div>
<div v-if="nodeData?.outputs?.length" class="ml-auto flex flex-col gap-1">
<div
v-if="nodeData?.outputs?.length"
:class="cn('ml-auto flex flex-col gap-1', unifiedDotsClass)"
>
<OutputSlot
v-for="(output, index) in nodeData.outputs"
:key="`output-${index}`"
@@ -33,40 +39,43 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { isSlotObject } from '@/utils/typeGuardUtil'
import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
nodeData?: VueNodeData
nodeData: VueNodeData
unified?: boolean
}
const { nodeData = null } = defineProps<NodeSlotsProps>()
const { nodeData, unified = false } = defineProps<NodeSlotsProps>()
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
if (!nodeData?.inputs) return []
const linkedWidgetInputs = computed(() =>
unified ? linkedWidgetedInputs(nodeData) : []
)
return nodeData.inputs
.filter((input) => {
// Check if this slot has a widget property (indicating it has a corresponding widget)
if (isSlotObject(input) && 'widget' in input && input.widget) {
// This slot has a widget, so we should not display it separately
return false
}
return true
})
.map((input) =>
isSlotObject(input)
? input
: ({
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
const filteredInputs = computed(() => [
...nonWidgetedInputs(nodeData),
...linkedWidgetInputs.value
])
const unifiedWrapperClass = computed((): string =>
cn(
unified &&
'absolute inset-0 items-center pointer-events-none opacity-0 z-30'
)
)
const unifiedDotsClass = computed((): string =>
cn(
unified &&
'grid grid-cols-1 grid-rows-1 gap-0 [&>*]:row-span-full [&>*]:col-span-full place-items-center'
)
)
// Get the actual index of an input slot in the node's inputs array
// (accounting for filtered widget slots)

View File

@@ -12,9 +12,9 @@
: 'pointer-events-none'
)
"
@pointerdown.stop="handleWidgetPointerEvent"
@pointermove.stop="handleWidgetPointerEvent"
@pointerup.stop="handleWidgetPointerEvent"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<div
v-for="(widget, index) in processedWidgets"
@@ -24,7 +24,12 @@
<!-- Widget Input Slot Dot -->
<div
class="z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
:class="
cn(
'z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 flex items-center',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
@@ -35,7 +40,7 @@
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index"
:dot-only="true"
dot-only
/>
</div>
<!-- Widget Component -->
@@ -83,10 +88,10 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const handleWidgetPointerEvent = (event: PointerEvent) => {
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
}
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
event.stopPropagation()
forwardEventToCanvas(event)
}
// Error boundary implementation
@@ -140,9 +145,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts
if (slotMetadata?.linked) {
widgetOptions = widget.options
? { ...widget.options, disabled: true }
: { disabled: true }
widgetOptions = { ...widget.options, disabled: true }
}
const simplified: SimplifiedWidget = {

View File

@@ -1,6 +1,5 @@
import { useEventListener } from '@vueuse/core'
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import type { Fn } from '@vueuse/core'
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
@@ -555,6 +554,8 @@ export function useSlotLinkInteraction({
if (event.button !== 0) return
if (!nodeId) return
if (pointerSession.isActive()) return
event.preventDefault()
event.stopPropagation()
const canvas = app.canvas
const graph = canvas?.graph
@@ -613,7 +614,7 @@ export function useSlotLinkInteraction({
if (shouldBatchDisconnectOutputLinks && resolvedNode) {
resolvedNode.disconnectOutput(index)
app.canvas?.setDirty(true, true)
canvas.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
return
@@ -634,20 +635,18 @@ export function useSlotLinkInteraction({
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
if (activeAdapter) {
if (isOutputSlot) {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (isOutputSlot) {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
syncRenderLinkOrigins()
@@ -678,21 +677,19 @@ export function useSlotLinkInteraction({
toCanvasPointerEvent(event)
updatePointerState(event)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
}
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
pointerSession.register(
useEventListener(window, 'pointermove', handlePointerMove, {
useEventListener('pointermove', handlePointerMove, {
capture: true
}),
useEventListener(window, 'pointerup', handlePointerUp, {
useEventListener('pointerup', handlePointerUp, {
capture: true
}),
useEventListener(window, 'pointercancel', handlePointerCancel, {
useEventListener('pointercancel', handlePointerCancel, {
capture: true
})
)
@@ -710,12 +707,10 @@ export function useSlotLinkInteraction({
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
canvas.setDirty(true, true)
}
onBeforeUnmount(() => {
tryOnScopeDispose(() => {
if (pointerSession.isActive()) {
cleanupInteraction()
}

View File

@@ -0,0 +1,127 @@
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
INodeInputSlot,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import type { LinkId } from '@/renderer/core/layout/types'
import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { describe, it } from 'vitest'
function makeFakeInputSlot(
name: string,
withWidget = false,
link: LinkId | null = null
): INodeInputSlot {
const widget: IWidgetLocator | undefined = withWidget ? { name } : undefined
return {
name,
widget,
link,
boundingRect: [0, 0, 0, 0],
type: 'FAKE'
}
}
function makeFakeNodeData(inputs: INodeInputSlot[]): VueNodeData {
const nodeData: Partial<VueNodeData> = { inputs }
return nodeData as VueNodeData
}
describe('nodeDataUtils', () => {
describe('nonWidgetedInputs', () => {
it('should handle an empty inputs list', () => {
const inputs: INodeInputSlot[] = []
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should handle a list of only widgeted inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first', true),
makeFakeInputSlot('second', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should handle a list of only slot inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second')
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
it('should handle a list of mixed inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
})
describe('linkedWidgetedInputs', () => {
it('should return input slots that are bound to widgets and are linked: none present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should return input slots that are bound to widgets and are linked: one present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true, 1)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(1)
})
it('should return input slots that are bound to widgets and are linked: multiple present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true, 1),
makeFakeInputSlot('fifth', true, 2)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
})
})

View File

@@ -0,0 +1,36 @@
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { INodeInputSlot, INodeSlot } from '@/lib/litegraph/src/interfaces'
import { isSlotObject } from '@/utils/typeGuardUtil'
function coerceINodeSlot(input: INodeInputSlot): INodeSlot {
return isSlotObject(input)
? input
: {
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0]
}
}
function inputHasWidget(input: INodeInputSlot) {
return isSlotObject(input) && 'widget' in input && input.widget
}
export function nonWidgetedInputs(
nodeData: VueNodeData | undefined
): INodeSlot[] {
if (!nodeData?.inputs) return []
return nodeData.inputs
.filter((input) => !inputHasWidget(input))
.map(coerceINodeSlot)
}
export function linkedWidgetedInputs(
nodeData: VueNodeData | undefined
): INodeSlot[] {
if (!nodeData?.inputs) return []
return nodeData.inputs
.filter((input) => inputHasWidget(input) && !!input.link)
.map(coerceINodeSlot)
}

View File

@@ -112,7 +112,7 @@
<!-- Audio player -->
<div class="group relative px-2">
<div
class="flex items-center gap-4 rounded-lg bg-[#1a1b1e] p-4"
class="flex items-center gap-4 rounded-lg bg-charcoal-800 p-4"
style="border: 1px solid #262729"
>
<!-- Audio icon -->
@@ -135,7 +135,7 @@
<!-- Delete button -->
<button
:aria-label="$t('g.deleteAudioFile')"
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-[#262729] focus:outline-none"
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-charcoal-600 focus:outline-none"
@click="clearFile"
>
<i class="pi pi-times text-sm text-white"></i>

View File

@@ -48,7 +48,7 @@
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handleStopRecording"
>
<div class="size-2.5 rounded-sm bg-[#C02323]" />
<div class="size-2.5 rounded-sm bg-danger-100" />
</button>
<button

View File

@@ -38,9 +38,7 @@
</div>
<!-- Time Display -->
<div
class="text-sm font-normal text-nowrap text-black dark-theme:text-white"
>
<div class="text-sm font-normal text-nowrap text-base-foreground">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
@@ -116,10 +114,9 @@
>
<template #item="{ item }">
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">
<label
class="mb-2 block text-xs text-black dark-theme:text-white"
>{{ item.label }}</label
>
<label class="mb-2 block text-xs text-base-foreground">{{
item.label
}}</label>
<Slider
:model-value="volume * 10"
:min="0"
@@ -134,12 +131,10 @@
class="flex cursor-pointer items-center px-4 py-2 text-xs hover:bg-white/10"
@click="item.onClick?.()"
>
<span class="text-black dark-theme:text-white">{{
item.label
}}</span>
<span class="text-base-foreground">{{ item.label }}</span>
<i
v-if="item.selected"
class="ml-auto icon-[lucide--check] size-4 text-black dark-theme:text-white"
class="ml-auto icon-[lucide--check] size-4 text-base-foreground"
/>
</div>
</template>

View File

@@ -21,7 +21,7 @@ const actionButtonStyle =
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
const layoutSwitchItemStyle =
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-black hover:dark-theme:text-white active:scale-95'
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-base-foreground active:scale-95'
const sortPopoverRef = useTemplateRef('sortPopoverRef')
const sortTriggerRef = useTemplateRef('sortTriggerRef')
@@ -50,7 +50,7 @@ function handleSortSelected(item: SortOption) {
cn(
actionButtonStyle,
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-black dark-theme:text-white' : '',
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
'hover:!outline-blue-500/80',
'focus-within:!outline-blue-500/80'
)
@@ -150,7 +150,7 @@ function handleSortSelected(item: SortOption) {
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'list'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
? 'bg-neutral-500/50 text-base-foreground'
: ''
)
"
@@ -164,7 +164,7 @@ function handleSortSelected(item: SortOption) {
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'grid'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
? 'bg-neutral-500/50 text-base-foreground'
: ''
)
"

View File

@@ -19,10 +19,10 @@ const filterSelected = defineModel<OptionId>('filterSelected')
cn(
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
'transition-all duration-150',
'hover:text-black hover:dark-theme:text-white hover:bg-zinc-500/10',
'hover:text-base-foreground hover:bg-zinc-500/10',
'active:scale-95',
filterSelected === option.id
? '!bg-zinc-500/20 text-black dark-theme:text-white'
? '!bg-zinc-500/20 text-base-foreground'
: 'bg-transparent'
)
"

View File

@@ -2,8 +2,8 @@ import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'bg-node-component-widget-input-surface',
'text-node-component-widget-input',
'not-disabled:bg-node-component-widget-input-surface',
'not-disabled:text-node-component-widget-input',
// Outline
'border-none',
'outline outline-offset-[-1px] outline-node-stroke',

View File

@@ -13,6 +13,7 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
export const zQueueIndex = z.number()
export const zPromptId = z.string()
export type PromptId = z.infer<typeof zPromptId>
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>

View File

@@ -69,6 +69,7 @@ export const useDialogService = () => {
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined

View File

@@ -2,7 +2,9 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
import { getWorkflowFromHistory } from '@/platform/workflow/cloud'
import type {
ComfyWorkflowJSON,
NodeId
@@ -379,24 +381,37 @@ export class TaskItemImpl {
}
public async loadWorkflow(app: ComfyApp) {
if (!this.workflow) {
return
}
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
let workflowData = this.workflow
if (isCloud && !workflowData && this.isHistory) {
workflowData = await getWorkflowFromHistory(
(url) => app.api.fetchApi(url),
this.promptId
)
}
if (!workflowData) {
return
}
await app.loadGraphData(toRaw(workflowData))
if (!this.outputs) {
return
}
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
}
public flatten(): TaskItemImpl[] {
@@ -492,7 +507,7 @@ export const useQueueStore = defineStore('queue', () => {
const currentHistory = toValue(historyTasks)
const { items } = reconcileHistory(
const items = reconcileHistory(
history.History,
currentHistory.map((impl) => impl.toTaskItem()),
toValue(maxHistoryItems),

View File

@@ -140,16 +140,16 @@ export interface ComfyExtension {
/**
* Allows the extension to add context menu items to canvas right-click menus
* @param canvas The canvas instance
* @returns An array of context menu items to add
* @returns An array of context menu items to add (null values represent separators)
*/
getCanvasMenuItems?(canvas: LGraphCanvas): IContextMenuValue[]
getCanvasMenuItems?(canvas: LGraphCanvas): (IContextMenuValue | null)[]
/**
* Allows the extension to add context menu items to node right-click menus
* @param node The node being right-clicked
* @returns An array of context menu items to add
* @returns An array of context menu items to add (null values represent separators)
*/
getNodeMenuItems?(node: LGraphNode): IContextMenuValue[]
getNodeMenuItems?(node: LGraphNode): (IContextMenuValue | null)[]
/**
* Allows the extension to add additional handling to the node before it is registered with **LGraph**

View File

@@ -15,6 +15,7 @@
<GlobalToast />
<RerouteMigrationToast />
<VueNodesMigrationToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger />
</template>
@@ -40,6 +41,7 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import VueNodesMigrationToast from '@/components/toast/VueNodesMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'

View File

@@ -51,9 +51,7 @@
type="transparent"
@click="dismissWarningBanner"
>
<i
class="pi pi-times text-xs text-neutral-900 dark-theme:text-white"
></i>
<i class="pi pi-times text-xs text-base-foreground"></i>
</IconButton>
</div>
<RegistrySearchBar

View File

@@ -4,9 +4,7 @@
<div class="flex h-full w-full flex-col gap-2 px-4 py-6">
<!-- Description -->
<div v-if="showAfterWhatsNew">
<p
class="m-0 mb-4 text-sm leading-4 text-neutral-800 dark-theme:text-white"
>
<p class="m-0 mb-4 text-sm leading-4 text-base-foreground">
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}

View File

@@ -6,7 +6,7 @@
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="text-sm dark-theme:text-white">{{
<span class="text-sm text-base-foreground">{{
t('serverStart.openLogs')
}}</span>
</button>

View File

@@ -0,0 +1,64 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/LGraphBadge'
import type { LGraphIcon } from '@/lib/litegraph/src/LGraphIcon'
import { subgraphTest } from '../../litegraph/subgraph/fixtures/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
}))
const { updateSubgraphCredits } = usePriceBadge()
const mockNode = new LGraphNode('mock node')
const mockIcon: Partial<LGraphIcon> = { unicode: '\ue96b' }
const badge: Partial<LGraphBadge> = {
icon: mockIcon as LGraphIcon,
text: '$0.05/Run'
}
mockNode.badges = [badge as LGraphBadge]
function getBadgeText(node: LGraphNode): string {
const badge = node.badges[0]
return (typeof badge === 'function' ? badge() : badge).text
}
describe('subgraph pricing', () => {
subgraphTest(
'should not display badge for subgraphs without API nodes',
({ subgraphWithNode }) => {
const { subgraphNode } = subgraphWithNode
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(0)
}
)
subgraphTest(
'should return the price of a single contained API node',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('$0.05/Run')
}
)
subgraphTest(
'should return the number of api nodes if more than one exists',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
for (let i = 0; i < 5; i++) subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
})

View File

@@ -0,0 +1,213 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock composables
const mockSubscriptionData = {
isActiveSubscription: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
formattedMonthlyPrice: '$9.99',
manageSubscription: vi.fn(),
handleInvoiceHistory: vi.fn()
}
const mockCreditsData = {
totalCredits: '10.00',
monthlyBonusCredits: '5.00',
prepaidCredits: '5.00',
isLoadingBalance: false
}
const mockActionsData = {
isLoadingSupport: false,
refreshTooltip: 'Refreshes on 2024-12-31',
handleAddApiCredits: vi.fn(),
handleMessageSupport: vi.fn(),
handleRefresh: vi.fn(),
handleLearnMoreClick: vi.fn()
}
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => mockSubscriptionData
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
() => ({
useSubscriptionCredits: () => mockCreditsData
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => mockActionsData
})
)
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
title: 'Subscription',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
prepaidCreditsInfo: 'Prepaid credits info',
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}'
}
}
}
})
function createWrapper(overrides = {}) {
return mount(SubscriptionPanel, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
CloudBadge: true,
SubscribeButton: true,
SubscriptionBenefits: true,
Button: {
template:
'<button @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon">{{ label }}</button>',
props: [
'loading',
'label',
'icon',
'text',
'severity',
'size',
'iconPos',
'pt'
],
emits: ['click']
},
Skeleton: {
template: '<div class="skeleton"></div>'
}
}
},
...overrides
})
}
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockSubscriptionData.isActiveSubscription = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Manage Subscription')
expect(wrapper.text()).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
mockSubscriptionData.isActiveSubscription = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
)
expect(wrapper.text()).not.toContain('Manage Subscription')
expect(wrapper.text()).not.toContain('Add Credits')
})
it('shows renewal date for active non-cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = false
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Renews 2024-12-31')
})
it('shows expiry date for cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('$10.00') // totalCredits
expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBe(0)
})
})
describe('action buttons', () => {
it('should call handleLearnMoreClick when learn more is clicked', async () => {
const wrapper = createWrapper()
const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
await learnMoreButton.trigger('click')
expect(mockActionsData.handleLearnMoreClick).toHaveBeenCalledOnce()
})
it('should call handleMessageSupport when message support is clicked', async () => {
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
await supportButton.trigger('click')
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
const wrapper = createWrapper()
// Find the refresh button by icon
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
await refreshButton.trigger('click')
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe('loading states', () => {
it('should show loading state on support button when loading', () => {
mockActionsData.isLoadingSupport = true
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
expect(supportButton.attributes('disabled')).toBeDefined()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
expect(refreshButton.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockT = vi.fn((key: string) => {
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
return key
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
const mockFormattedRenewalDate = { value: '2024-12-31' }
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus,
formattedRenewalDate: mockFormattedRenewalDate
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
})
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
writable: true,
value: mockOpen
})
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormattedRenewalDate.value = '2024-12-31'
})
describe('refreshTooltip', () => {
it('should format tooltip with renewal date', () => {
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes on 2024-12-31')
})
it('should use fallback text when no renewal date', () => {
mockFormattedRenewalDate.value = ''
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes on next billing cycle')
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
})
})
describe('handleAddApiCredits', () => {
it('should call showTopUpCreditsDialog', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
})
})
describe('handleMessageSupport', () => {
it('should execute support command and manage loading state', async () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(isLoadingSupport.value).toBe(false)
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
await handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
})
})
describe('handleLearnMoreClick', () => {
it('should open learn more URL', () => {
const { handleLearnMoreClick } = useSubscriptionActions()
handleLearnMoreClick()
expect(mockOpen).toHaveBeenCalledWith(
'https://docs.comfy.org/get_started/cloud',
'_blank'
)
})
})
})

View File

@@ -0,0 +1,146 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn(() => ({
onAuthStateChanged: vi.fn(),
setPersistence: vi.fn()
}))
}))
vi.mock('firebase/auth', () => ({
onAuthStateChanged: vi.fn(() => {
// Mock the callback to be called immediately for testing
return vi.fn()
}),
onIdTokenChanged: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined),
browserLocalPersistence: {},
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
}
}))
// Mock other dependencies
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
track: vi.fn()
})
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
headers: {}
})
}))
// Mock formatMetronomeCurrency
vi.mock('@/utils/formatUtil', () => ({
formatMetronomeCurrency: vi.fn((micros: number) => {
// Simple mock that converts micros to dollars
return (micros / 1000000).toFixed(2)
})
}))
describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
beforeEach(() => {
setActivePinia(createPinia())
authStore = useFirebaseAuthStore()
vi.clearAllMocks()
})
describe('totalCredits', () => {
it('should return "0.00" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
it('should return "0.00" when amount_micros is missing', () => {
authStore.balance = {} as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 5000000 } as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('5.00')
})
it('should handle formatting errors gracefully', async () => {
const mockFormatMetronomeCurrency = vi.mocked(
await import('@/utils/formatUtil')
).formatMetronomeCurrency
mockFormatMetronomeCurrency.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 5000000 } as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
})
describe('monthlyBonusCredits', () => {
it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as any
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('2.50')
})
})
describe('prepaidCredits', () => {
it('should return "0.00" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as any
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = { prepaid_balance_micros: 7500000 } as any
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('7.50')
})
})
describe('isLoadingBalance', () => {
it('should reflect authStore.isFetchingBalance', () => {
authStore.isFetchingBalance = true
const { isLoadingBalance } = useSubscriptionCredits()
expect(isLoadingBalance.value).toBe(true)
authStore.isFetchingBalance = false
expect(isLoadingBalance.value).toBe(false)
})
})
})

View File

@@ -21,8 +21,8 @@ function createHistoryItem(promptId: string, queueIndex = 0): TaskItem {
}
}
function getAllPromptIds(result: { items: TaskItem[] }): string[] {
return result.items.map((item) => item.prompt[1])
function getAllPromptIds(result: TaskItem[]): string[] {
return result.map((item) => item.prompt[1])
}
describe('reconcileHistory (V1)', () => {
@@ -74,9 +74,9 @@ describe('reconcileHistory (V1)', () => {
const result = reconcileHistory(serverHistory, [], 10, undefined)
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('item-1')
expect(result.items[1].prompt[1]).toBe('item-2')
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('item-1')
expect(result[1].prompt[1]).toBe('item-2')
})
})
@@ -144,9 +144,9 @@ describe('reconcileHistory (V1)', () => {
const result = reconcileHistory(serverHistory, clientHistory, 2, 10)
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('new-1')
expect(result.items[1].prompt[1]).toBe('new-2')
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('new-1')
expect(result[1].prompt[1]).toBe('new-2')
})
})
@@ -168,13 +168,13 @@ describe('reconcileHistory (V1)', () => {
const result = reconcileHistory([], clientHistory, 10, 5)
expect(result.items).toHaveLength(0)
expect(result).toHaveLength(0)
})
it('should return empty result when both collections are empty', () => {
const result = reconcileHistory([], [], 10, undefined)
expect(result.items).toHaveLength(0)
expect(result).toHaveLength(0)
})
})
})
@@ -295,9 +295,9 @@ describe('reconcileHistory (V2/Cloud)', () => {
const result = reconcileHistory(serverHistory, clientHistory, 2)
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('new-1')
expect(result.items[1].prompt[1]).toBe('new-2')
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('new-1')
expect(result[1].prompt[1]).toBe('new-2')
})
})
@@ -310,9 +310,9 @@ describe('reconcileHistory (V2/Cloud)', () => {
const result = reconcileHistory(serverHistory, [], 10)
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('item-1')
expect(result.items[1].prompt[1]).toBe('item-2')
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('item-1')
expect(result[1].prompt[1]).toBe('item-2')
})
it('should return empty result when server history is empty', () => {
@@ -323,13 +323,13 @@ describe('reconcileHistory (V2/Cloud)', () => {
const result = reconcileHistory([], clientHistory, 10)
expect(result.items).toHaveLength(0)
expect(result).toHaveLength(0)
})
it('should return empty result when both collections are empty', () => {
const result = reconcileHistory([], [], 10)
expect(result.items).toHaveLength(0)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getWorkflowFromHistory } from '@/platform/workflow/cloud/getWorkflowFromHistory'
const mockWorkflow: ComfyWorkflowJSON = {
id: 'test-workflow-id',
revision: 0,
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
const mockHistoryResponse = {
'test-prompt-id': {
prompt: {
priority: 1,
prompt_id: 'test-prompt-id',
extra_data: {
client_id: 'test-client',
extra_pnginfo: {
workflow: mockWorkflow
}
}
},
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
}
}
}
describe('getWorkflowFromHistory', () => {
it('should fetch workflow from /history_v2/{prompt_id} endpoint', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => mockHistoryResponse
})
await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2/test-prompt-id')
})
it('should extract and return workflow from response', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => mockHistoryResponse
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toEqual(mockWorkflow)
})
it('should return undefined when prompt_id not found in response', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => ({})
})
const result = await getWorkflowFromHistory(mockFetchApi, 'nonexistent-id')
expect(result).toBeUndefined()
})
it('should return undefined when workflow is missing from extra_pnginfo', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => ({
'test-prompt-id': {
prompt: {
priority: 1,
prompt_id: 'test-prompt-id',
extra_data: {
client_id: 'test-client'
}
},
outputs: {}
}
})
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
it('should handle fetch errors gracefully', async () => {
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
it('should handle malformed JSON responses', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => {
throw new Error('Invalid JSON')
}
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
})

View File

@@ -0,0 +1,175 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { TaskItemImpl } from '@/stores/queueStore'
import * as getWorkflowModule from '@/platform/workflow/cloud'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: vi.fn(() => ({
invokeExtensions: vi.fn()
}))
}))
const mockWorkflow: ComfyWorkflowJSON = {
id: 'test-workflow-id',
revision: 0,
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
const createHistoryTaskWithWorkflow = (): TaskItemImpl => {
return new TaskItemImpl(
'History',
[
0, // queueIndex
'test-prompt-id', // promptId
{}, // promptInputs
{
client_id: 'test-client',
extra_pnginfo: {
workflow: mockWorkflow
}
},
[] // outputsToExecute
],
{
status_str: 'success',
completed: true,
messages: []
},
{} // outputs
)
}
const createHistoryTaskWithoutWorkflow = (): TaskItemImpl => {
return new TaskItemImpl(
'History',
[
0,
'test-prompt-id',
{},
{
client_id: 'test-client'
// No extra_pnginfo.workflow
},
[]
],
{
status_str: 'success',
completed: true,
messages: []
},
{}
)
}
describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
let mockApp: ComfyApp
let mockFetchApi: ReturnType<typeof vi.fn>
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockFetchApi = vi.fn()
mockApp = {
loadGraphData: vi.fn(),
nodeOutputs: {},
api: {
fetchApi: mockFetchApi
}
} as unknown as ComfyApp
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory')
})
it('should load workflow directly when workflow is in extra_pnginfo', async () => {
const task = createHistoryTaskWithWorkflow()
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('should fetch workflow from cloud when workflow is missing from history task', async () => {
const task = createHistoryTaskWithoutWorkflow()
// Mock getWorkflowFromHistory to return workflow
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
mockWorkflow
)
await task.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalledWith(
expect.any(Function),
'test-prompt-id'
)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
})
it('should not load workflow when fetch returns undefined', async () => {
const task = createHistoryTaskWithoutWorkflow()
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
undefined
)
await task.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should only fetch for history tasks, not running tasks', async () => {
const runningTask = new TaskItemImpl(
'Running',
[
0,
'test-prompt-id',
{},
{
client_id: 'test-client'
},
[]
],
undefined,
{}
)
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
mockWorkflow
)
await runningTask.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).not.toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should handle fetch errors gracefully by returning undefined', async () => {
const task = createHistoryTaskWithoutWorkflow()
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
undefined
)
await task.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
})

View File

@@ -1,13 +1,16 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
import type { IncomingMessage, ServerResponse } from 'http'
import { Readable } from 'stream'
import type { ReadableStream as NodeReadableStream } from 'stream/web'
import { visualizer } from 'rollup-plugin-visualizer'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import type { UserConfig } from 'vite'
import type { ProxyOptions, UserConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -49,10 +52,74 @@ const DEV_SEVER_FALLBACK_URL =
const DEV_SERVER_COMFYUI_URL =
DEV_SERVER_COMFYUI_ENV_URL || DEV_SEVER_FALLBACK_URL
// Cloud proxy configuration
const cloudProxyConfig =
DISTRIBUTION === 'cloud' ? { secure: false, changeOrigin: true } : {}
function handleGcsRedirect(
proxyRes: IncomingMessage,
_req: IncomingMessage,
res: ServerResponse
) {
const location = proxyRes.headers.location
const isGcsRedirect =
proxyRes.statusCode === 302 &&
location?.includes('storage.googleapis.com') &&
proxyRes.headers.via?.includes('google')
// Not a GCS redirect - pass through normally
if (!isGcsRedirect || !location) {
Object.keys(proxyRes.headers).forEach((key) => {
const value = proxyRes.headers[key]
if (value !== undefined) {
res.setHeader(key, value)
}
})
res.writeHead(proxyRes.statusCode || 200)
proxyRes.pipe(res)
return
}
// GCS redirect detected - fetch server-side to avoid CORS
fetch(location)
.then(async (gcsResponse) => {
if (!gcsResponse.body) {
res.statusCode = 500
res.end('Empty response from GCS')
return
}
// Set response headers from GCS
res.statusCode = 200
res.setHeader(
'Content-Type',
gcsResponse.headers.get('content-type') || 'application/octet-stream'
)
const contentLength = gcsResponse.headers.get('content-length')
if (contentLength) {
res.setHeader('Content-Length', contentLength)
}
// Convert Web ReadableStream to Node.js stream and pipe to client
const readable = Readable.fromWeb(gcsResponse.body as NodeReadableStream)
readable.pipe(res)
})
.catch((error) => {
console.error('Error fetching from GCS:', error)
res.statusCode = 500
res.end('Error fetching media')
})
}
const gcsRedirectProxyConfig: ProxyOptions = {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig,
selfHandleResponse: true,
configure: (proxy) => {
proxy.on('proxyRes', handleGcsRedirect)
}
}
export default defineConfig({
base: '',
server: {
@@ -80,6 +147,13 @@ export default defineConfig({
...cloudProxyConfig
},
...(DISTRIBUTION === 'cloud'
? {
'/api/view': gcsRedirectProxyConfig,
'/api/viewvideo': gcsRedirectProxyConfig
}
: {}),
'/api': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig,