Compare commits

...

14 Commits

Author SHA1 Message Date
bymyself
b8f563d3f8 refactor: split subscription popover section 2025-12-08 19:17:09 -08:00
bymyself
8ce0c9ea58 fix: skip subscription check when not required 2025-12-06 15:37:28 -08:00
bymyself
1ebdab9b22 fix: use subscription requirement flag in cloud remote config 2025-12-06 15:23:54 -08:00
bymyself
6fb677feca chore: keep legacy subscription alias 2025-12-06 15:22:41 -08:00
bymyself
5fd6aa69b8 don't use compound name, use isSubscriptionRequirementMet 2025-12-06 15:22:41 -08:00
bymyself
cd15c2706d improve test 2025-12-06 15:22:41 -08:00
bymyself
afaceb5dd8 dont import cloud in core commands module 2025-12-06 15:22:41 -08:00
bymyself
9d889198f8 rename isSubscribed and dont import cloud on local 2025-12-06 15:22:41 -08:00
Christian Byrne
10288ee239 feat: propagate errors up subgraphs and show slot errors in subgraphs (#6963)
https://github.com/user-attachments/assets/6531879d-a8a2-420a-aaca-ee329386dd1a

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6963-feat-propagate-errors-up-subgraphs-and-show-slot-errors-in-subgraphs-2b76d73d3650813e8391fac0a5e6dc9b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-06 16:02:57 -07:00
Alexander Brown
bca7a435ed fix: Button color and default ordering (#7199)
## Summary

Small fixes for the button and Modal sorting.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7199-fix-Button-color-and-default-ordering-2c16d73d3650812a8a08f8e8fcd7fd55)
by [Unito](https://www.unito.io)
2025-12-06 13:24:58 -08:00
Terry Jia
23ab924405 fix: add fixed min-width to sidebar panels to prevent content clipping (#7212)
## Summary

The default 10% min-size is too small for panels like Media Assets,
causing content to be clipped.
Use a fixed minimum width to ensure the panel content displays properly.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/7210

## Screenshots


https://github.com/user-attachments/assets/65a15f0f-45c1-4361-adc9-eee4ccfad0ee

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7212-fix-add-fixed-min-width-to-sidebar-panels-to-prevent-content-clipping-2c16d73d365081d8a5a4de30c8eb5e07)
by [Unito](https://www.unito.io)
2025-12-06 12:42:14 -08:00
AustinMroz
a8f6bea371 Color links as common type (#7211)
Previously the color of a link would simply use the type of the target
slot and fallback to the type of the origin slot. When a connection is
made to a node that accepts the any type ('*'), the link has the green
color of an unknown type.

Instead, when a connection is made, the type of a link is now calculated
as the greatest common type of the source and destination. This means
that connections to reroutes are correctly colored.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/a5544730-e69a-4c85-af33-b303bb30ae71"
/>| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/7d7b59fd-1b79-440b-a97d-a1657313c484"
/>|

The code for calculating common types already exists, it has simply been
moved into litegraph and given a more descriptive name.

Resolves #7196

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7211-Color-links-as-common-type-2c16d73d365081188460f6b5973db962)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-06 12:00:07 -08:00
Alexander Piskun
1d18583e42 feat(api-nodes): add pricing for seedream4.5 (#7189)
## Summary

The same logic, with price increased by 25% compared to previous version
of this model.

## Screenshots (if applicable)

<img width="1438" height="685" alt="Screenshot From 2025-12-05 20-47-45"
src="https://github.com/user-attachments/assets/3d2846ff-c1c7-4e2f-a789-45dc32018e25"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7189-feat-api-nodes-add-pricing-for-seedream4-5-2c06d73d3650817b975cf6dc64cbe084)
by [Unito](https://www.unito.io)
2025-12-06 14:15:48 +02:00
Alexander Brown
f74c176423 Cleanup: Properties Panel (#7137)
## Summary

- Code cleanup
- Copy, padding, color, alignment of components
- Subgraph Edit mode changes
- Partial fix for the Node Info location (need to do context menu still)
- Editing node title

### Still to-do

- Bi-directionality in values

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7137-WIP-Cleanup-Properties-Panel-2be6d73d3650813e9430f6bcb09dfb4d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-05 21:33:52 -08:00
77 changed files with 996 additions and 565 deletions

View File

@@ -1,13 +1,13 @@
// Import only English locale eagerly as the default/fallback
// ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*),
// but these are properly configured in tsconfig.json and resolved by Vite at build time.
// eslint-disable-next-line import-x/no-unresolved
import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import en from '@frontend-locales/en/main.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { createI18n } from 'vue-i18n'
@@ -27,7 +27,7 @@ function buildLocale<
// Locale loader map - dynamically import locales only when needed
// ESLint cannot statically resolve these dynamic imports, but they are valid at build time
/* eslint-disable import-x/no-unresolved */
const localeLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>

View File

@@ -27,18 +27,32 @@ dotenv.config()
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyPropertiesPanel {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId('properties-panel')
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder('Search...')
}
}
class ComfyMenu {
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
public readonly sideToolbar: Locator
public readonly propertiesPanel: ComfyPropertiesPanel
public readonly themeToggleButton: Locator
public readonly saveButton: Locator
constructor(public readonly page: Page) {
this.sideToolbar = page.locator('.side-tool-bar-container')
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
this.propertiesPanel = new ComfyPropertiesPanel(page)
this.saveButton = page
.locator('button[title="Save the current workflow"]')
.nth(0)

View File

@@ -5,14 +5,18 @@ import type { AutoQueueMode } from '../../src/stores/queueStore'
export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar')
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
}
async isDocked() {
const className = await this.root.getAttribute('class')
const className = await this.root
.locator('.actionbar')
.getAttribute('class')
return className?.includes('static') ?? false
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -52,13 +52,10 @@ test.describe('Node Help', () => {
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the node library sidebar is opened
await expect(
comfyPage.menu.nodeLibraryTab.selectedTabButton
).toBeVisible()
// Verify that the help page is shown for the correct node
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
@@ -170,7 +167,9 @@ test.describe('Node Help', () => {
await helpButton.click()
// Verify loading spinner is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
// Wait for content to load
@@ -200,7 +199,9 @@ test.describe('Node Help', () => {
await helpButton.click()
// Verify fallback content is shown (description, inputs, outputs)
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('Description')
await expect(helpPage).toContainText('Inputs')
await expect(helpPage).toContainText('Outputs')
@@ -233,7 +234,9 @@ test.describe('Node Help', () => {
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('KSampler Documentation')
// Check that relative image paths are prefixed correctly
@@ -281,7 +284,9 @@ test.describe('Node Help', () => {
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
// Check relative video paths are prefixed
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
@@ -354,7 +359,9 @@ This is documentation for a custom node.
if (await helpButton.isVisible()) {
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
@@ -394,7 +401,9 @@ This is documentation for a custom node.
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
// Dangerous elements should be removed
await expect(helpPage.locator('script')).toHaveCount(0)
@@ -461,7 +470,9 @@ This is English documentation.
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
@@ -484,7 +495,9 @@ This is English documentation.
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
@@ -528,7 +541,9 @@ This is English documentation.
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('KSampler Help')
await expect(helpPage).toContainText('This is KSampler documentation')

View File

@@ -0,0 +1,35 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Properties panel', () => {
test('opens and updates title based on selection', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText(
'No node(s) selected'
)
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
await propertiesPanel.searchBox.fill('seed')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(0)
await propertiesPanel.searchBox.fill('')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 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: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -1,5 +1,7 @@
<template>
<div class="splitter-overlay-root pointer-events-none flex flex-col">
<div
class="w-full h-full absolute top-0 left-0 z-999 pointer-events-none flex flex-col"
>
<slot name="workflow-tabs" />
<div
@@ -15,15 +17,19 @@
<Splitter
:key="splitterRefreshKey"
class="splitter-overlay flex-1 overflow-hidden"
:pt:gutter="getSplitterGutterClasses"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel
v-if="sidebarLocation === 'left'"
class="side-bar-panel pointer-events-auto"
v-if="sidebarLocation === 'left' && !focusMode"
:class="
cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
"
:min-size="10"
:size="20"
:style="{
@@ -40,14 +46,16 @@
</SplitterPanel>
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible="sidebarPanelVisible" />
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
class="bg-transparent pointer-events-none border-none splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
layout="vertical"
:pt:gutter="
'rounded-tl-lg rounded-tr-lg ' +
(bottomPanelVisible ? '' : 'hidden')
cn(
'rounded-tl-lg rounded-tr-lg ',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@@ -57,8 +65,8 @@
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible"
class="bottom-panel pointer-events-auto rounded-lg"
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel border border-(--p-panel-border-color) max-w-full overflow-x-auto bg-comfy-menu-bg pointer-events-auto rounded-lg"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -66,8 +74,13 @@
</SplitterPanel>
<SplitterPanel
v-if="sidebarLocation === 'right'"
class="side-bar-panel pointer-events-auto"
v-if="sidebarLocation === 'right' && !focusMode"
:class="
cn(
'side-bar-panel pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
"
:min-size="10"
:size="20"
:style="{
@@ -85,8 +98,8 @@
<!-- Right Side Panel - independent of sidebar -->
<SplitterPanel
v-if="rightSidePanelVisible"
class="right-side-panel pointer-events-auto"
v-if="rightSidePanelVisible && !focusMode"
class="bg-comfy-menu-bg pointer-events-auto"
:min-size="15"
:size="20"
>
@@ -98,6 +111,8 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { storeToRefs } from 'pinia'
import Splitter from 'primevue/splitter'
import type { SplitterResizeStartEvent } from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
@@ -107,9 +122,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
@@ -118,38 +136,19 @@ const unifiedWidth = computed(() =>
settingStore.get('Comfy.Sidebar.UnifiedWidth')
)
const sidebarPanelVisible = computed(
() => useSidebarTabStore().activeSidebarTab !== null
)
const bottomPanelVisible = computed(
() => useBottomPanelStore().bottomPanelVisible
)
const rightSidePanelVisible = computed(() => rightSidePanelStore.isOpen)
const activeSidebarTabId = computed(
() => useSidebarTabStore().activeSidebarTabId
)
const { focusMode } = storeToRefs(workspaceStore)
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarStateKey = computed(() => {
if (unifiedWidth.value) {
return 'unified-sidebar'
}
// When no tab is active, use a default key to maintain state
return activeSidebarTabId.value ?? 'default-sidebar'
})
/**
* Force refresh the splitter when right panel visibility changes to recalculate the width
*/
const splitterRefreshKey = computed(() => {
return rightSidePanelVisible.value
? 'main-splitter-with-right-panel'
: 'main-splitter'
})
// Gutter visibility should be controlled by CSS targeting specific gutters
const getSplitterGutterClasses = computed(() => {
// Empty string - let individual gutter styles handle visibility
return ''
return unifiedWidth.value
? 'unified-sidebar'
: // When no tab is active, use a default key to maintain state
(activeSidebarTabId.value ?? 'default-sidebar')
})
/**
@@ -158,11 +157,18 @@ const getSplitterGutterClasses = computed(() => {
function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
event.preventDefault()
}
/*
* Force refresh the splitter when right panel visibility changes to recalculate the width
*/
const splitterRefreshKey = computed(() => {
return rightSidePanelVisible.value
? 'main-splitter-with-right-panel'
: 'main-splitter'
})
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-splitter-gutter) {
pointer-events: auto;
}
@@ -179,36 +185,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
display: none;
}
.side-bar-panel {
background-color: var(--bg-color);
}
.right-side-panel {
background-color: var(--bg-color);
}
.bottom-panel {
background-color: var(--comfy-menu-bg);
border: 1px solid var(--p-panel-border-color);
max-width: 100%;
overflow-x: auto;
}
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
transform: translateY(5px);
}
.splitter-overlay {
@apply bg-transparent pointer-events-none border-none;
}
.splitter-overlay-root {
@apply w-full h-full absolute top-0 left-0;
/* Set it the same as the ComfyUI menu */
/* Note: Lite-graph DOM widgets have the same z-index as the node id, so
999 should be sufficient to make sure splitter overlays on node's DOM
widgets */
z-index: 999;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger no-drag top-0 right-0"
class="fixed z-9999 flex flex-row no-drag top-0 right-0"
>
<Button
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
@@ -44,11 +44,3 @@ watchEffect(() => {
}
})
</script>
<style scoped>
@reference '../assets/css/style.css';
.comfy-menu-hamburger {
@apply fixed z-9999 flex flex-row;
}
</style>

View File

@@ -24,17 +24,14 @@
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:class="queueHistoryButtonBackgroundClass"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i
class="icon-[lucide--history] block size-4 text-muted-foreground"
/>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
@@ -49,14 +46,11 @@
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isRightSidePanelOpen"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="toggleRightSidePanel"
@click="rightSidePanelStore.togglePanel"
>
<i
class="icon-[lucide--panel-right] block size-4 text-muted-foreground"
/>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay
@@ -68,6 +62,7 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -98,22 +93,13 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const queueHistoryButtonBackgroundClass = computed(() =>
isQueueOverlayExpanded.value
? 'bg-secondary-background-selected'
: 'bg-secondary-background'
)
// Right side panel toggle
const isRightSidePanelOpen = computed(() => rightSidePanelStore.isOpen)
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
const toggleRightSidePanel = () => {
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
onMounted(() => {

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
:key="isSubscriptionRequirementMet ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const { isSubscriptionRequirementMet } = useSubscription()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
isSubscriptionRequirementMet.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -15,7 +15,7 @@
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
v-else-if="isSubscriptionRequirementMet"
:label="$t('credits.purchaseCredits')"
:loading="loading"
@click="handlePurchaseCreditsClick"
@@ -125,6 +125,7 @@ import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.v
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -144,7 +145,11 @@ const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useSubscription()
const subscription = isCloud ? useSubscription() : null
const isSubscriptionRequirementMet = computed(() => {
if (!isCloud) return true
return subscription?.isSubscriptionRequirementMet.value ?? false
})
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)

View File

@@ -44,7 +44,7 @@
<template #graph-canvas-panel>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
<MiniMap
v-if="comfyAppReady && minimapEnabled && showUI"
v-if="comfyAppReady && minimapEnabled && betaMenuEnabled"
class="pointer-events-auto"
/>
</template>

View File

@@ -9,17 +9,17 @@
severity="secondary"
@click="onInfoClick"
>
<i class="icon-[lucide--info] h-4 w-4" />
<i class="icon-[lucide--info] size-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const { showNodeHelp: toggleHelp } = useSelectionState()
const rightSidePanelStore = useRightSidePanelStore()
/**
* Track node info button click and toggle node help.
@@ -28,6 +28,6 @@ const onInfoClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
})
toggleHelp()
rightSidePanelStore.openPanel('info')
}
</script>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, watchEffect } from 'vue'
import { computed, ref, toValue, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -27,8 +29,8 @@ const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
const hasSelection = computed(() => selectedItems.value.length > 0)
const selectedNodes = computed(() => {
return selectedItems.value.filter(isLGraphNode) as LGraphNode[]
const selectedNodes = computed((): LGraphNode[] => {
return selectedItems.value.filter(isLGraphNode)
})
const isSubgraphNode = computed(() => {
@@ -44,25 +46,29 @@ const selectedNode = computed(() => {
const selectionCount = computed(() => selectedItems.value.length)
const panelTitle = computed(() => {
if (!hasSelection.value) return t('rightSidePanel.properties')
if (isSingleNodeSelected.value && selectedNode.value) {
return selectedNode.value.title || selectedNode.value.type || 'Node'
}
return t('rightSidePanel.multipleSelection', { count: selectionCount.value })
return t('rightSidePanel.title', { count: selectionCount.value })
})
function closePanel() {
rightSidePanelStore.closePanel()
}
const tabs = computed<{ label: () => string; value: string }[]>(() => {
const list = [
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
}>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = [
{
label: () => t('rightSidePanel.parameters'),
value: 'parameters'
},
{
label: () => t('rightSidePanel.settings'),
label: () => t('g.settings'),
value: 'settings'
}
]
@@ -80,19 +86,57 @@ const tabs = computed<{ label: () => string; value: string }[]>(() => {
// Use global state for activeTab and ensure it's valid
watchEffect(() => {
if (!tabs.value.some((tab) => tab.value === activeTab.value)) {
activeTab.value = tabs.value[0].value as 'parameters' | 'settings' | 'info'
if (
!tabs.value.some((tab) => tab.value === activeTab.value) &&
!(activeTab.value === 'subgraph' && isSubgraphNode.value)
) {
rightSidePanelStore.openPanel(tabs.value[0].value)
}
})
const isEditing = ref(false)
function handleTitleEdit(newTitle: string) {
isEditing.value = false
const trimmedTitle = newTitle.trim()
if (!trimmedTitle) return
const node = toValue(selectedNode)
if (!node) return
if (trimmedTitle === node.title) return
node.title = trimmedTitle
canvasStore.canvas?.setDirty(true, false)
}
function handleTitleCancel() {
isEditing.value = false
}
</script>
<template>
<div class="flex h-full w-full flex-col bg-interface-panel-surface">
<div
data-testid="properties-panel"
class="flex size-full flex-col bg-interface-panel-surface"
>
<!-- Panel Header -->
<div class="border-b border-interface-stroke pt-1">
<section class="pt-1">
<div class="flex items-center justify-between pl-4 pr-3">
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
{{ panelTitle }}
<EditableText
v-if="isSingleNodeSelected"
:model-value="panelTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
@dblclick="isEditing = true"
/>
<template v-else>
{{ panelTitle }}
</template>
</h3>
<div class="flex gap-2">
@@ -100,36 +144,41 @@ watchEffect(() => {
v-if="isSubgraphNode"
type="transparent"
size="sm"
class="bg-secondary-background hover:bg-secondary-background-hover"
:class="
cn(
'bg-secondary-background hover:bg-secondary-background-hover',
isEditingSubgraph
? 'bg-secondary-background-selected'
: 'bg-secondary-background'
'bg-secondary-background hover:bg-secondary-background-hover text-base-foreground',
isEditingSubgraph && 'bg-secondary-background-selected'
)
"
@click="
rightSidePanelStore.openPanel(
isEditingSubgraph ? 'parameters' : 'subgraph'
)
"
@click="isEditingSubgraph = !isEditingSubgraph"
>
<i class="icon-[lucide--settings-2]" />
</IconButton>
<IconButton
type="transparent"
size="sm"
class="bg-secondary-background hover:bg-secondary-background-hover"
class="bg-secondary-background hover:bg-secondary-background-hover text-base-foreground"
:aria-pressed="rightSidePanelStore.isOpen"
:aria-label="t('rightSidePanel.togglePanel')"
@click="closePanel"
>
<i class="icon-[lucide--panel-right]" />
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
</div>
<div
v-if="hasSelection && !(isSubgraphNode && isEditingSubgraph)"
class="px-4 pb-2 pt-1"
>
<TabList v-model="activeTab">
<nav v-if="hasSelection" class="px-4 pb-2 pt-1">
<TabList
:model-value="activeTab"
@update:model-value="
(newTab: RightSidePanelTab) => {
rightSidePanelStore.openPanel(newTab)
}
"
>
<Tab
v-for="tab in tabs"
:key="tab.value"
@@ -139,23 +188,21 @@ watchEffect(() => {
{{ tab.label() }}
</Tab>
</TabList>
</div>
</div>
</nav>
</section>
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<div
v-if="!hasSelection"
class="flex size-full p-4 items-start justify-start text-sm text-muted-foreground"
>
{{ $t('rightSidePanel.noSelection') }}
</div>
<SubgraphEditor
v-if="isSubgraphNode && isEditingSubgraph"
v-else-if="isSubgraphNode && isEditingSubgraph"
:node="selectedNode"
/>
<div
v-else-if="!hasSelection"
class="flex h-full items-center justify-center text-center"
>
<div class="px-4 text-sm text-base-foreground-muted">
{{ $t('rightSidePanel.noSelection') }}
</div>
</div>
<template v-else>
<TabParameters
v-if="activeTab === 'parameters'"

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const props = defineProps<{
const { nodes } = defineProps<{
nodes: LGraphNode[]
}>()
const node = computed(() => props.nodes[0])
const node = computed(() => nodes[0])
const nodeDefStore = useNodeDefStore()
const nodeHelpStore = useNodeHelpStore()
@@ -19,19 +20,17 @@ const nodeInfo = computed(() => {
})
// Open node help when the selected node changes
watch(
whenever(
nodeInfo,
(info) => {
if (info) {
nodeHelpStore.openHelp(info)
}
nodeHelpStore.openHelp(info)
},
{ immediate: true }
)
</script>
<template>
<div v-if="nodeInfo" class="rounded-lg bg-interface-surface p-3">
<div v-if="nodeInfo" class="p-3">
<NodeHelpContent :node="nodeInfo" />
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { cn } from '@/utils/tailwindUtil'
defineProps<{
isEmpty?: boolean
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
</script>
<template>
<div class="flex flex-col bg-interface-panel-surface">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>
<button
v-tooltip="
isEmpty
? {
value: $t('rightSidePanel.inputsNoneTooltip'),
showDelay: 1_000
}
: undefined
"
type="button"
:class="
cn(
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
!isEmpty && 'cursor-pointer'
)
"
:disabled="isEmpty"
@click="isCollapse = !isCollapse"
>
<span class="text-sm font-semibold line-clamp-2">
<slot name="label" />
</span>
<i
v-if="!isEmpty"
:class="
cn(
'text-muted-foreground group-hover:text-base-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
isCollapse && '-rotate-180'
)
"
/>
</button>
</div>
<div v-if="!isCollapse && !isEmpty" class="pb-4">
<slot />
</div>
</div>
</template>

View File

@@ -1,51 +0,0 @@
<script lang="ts" setup>
import { watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
label?: string
defaultCollapse?: boolean
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
if (props.defaultCollapse) {
isCollapse.value = true
}
watch(
() => props.defaultCollapse,
(value) => (isCollapse.value = value)
)
</script>
<template>
<div class="flex flex-col">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12"
>
<button
class="group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3 cursor-pointer"
@click="isCollapse = !isCollapse"
>
<span class="text-sm font-semibold line-clamp-2">
<slot name="label">
{{ props.label ?? '' }}
</slot>
</span>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-5 min-w-5 transition-all',
isCollapse && 'rotate-90'
)
"
class="relative top-px text-xs leading-none text-node-component-header-icon group-hover:text-base-foreground"
/>
</button>
</div>
<div v-if="!isCollapse" class="pb-4">
<slot />
</div>
</div>
</template>

View File

@@ -1,39 +1,31 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import { ref, toRef, watch } from 'vue'
import { ref, toRef, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: (() => unknown) | unknown
}>(),
{
searcher: async () => {}
}
)
const { searcher = async () => {}, updateKey } = defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
}>()
const searchQuery = defineModel<string>({ default: '' })
const isQuerying = ref(false)
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
maxWait: 700
const debouncedSearchQuery = refDebounced(searchQuery, 100, {
maxWait: 100
})
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
const updateKey =
typeof props.updateKey === 'function'
? props.updateKey
: toRef(props, 'updateKey')
const updateKeyRef = toRef(() => toValue(updateKey))
watch(
[debouncedSearchQuery, updateKey],
[debouncedSearchQuery, updateKeyRef],
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
@@ -42,8 +34,10 @@ watch(
cleanupFn?.()
})
void props
.searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
void searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
.catch((error) => {
console.error('[SidePanelSearch] searcher failed', error)
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
@@ -56,24 +50,28 @@ watch(
<label
:class="
cn(
'h-8 bg-zinc-500/20 rounded-lg outline outline-offset-[-1px] outline-node-component-border transition-all duration-150',
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
'hover:outline-component-node-widget-background-highlighted/80',
'focus-within:outline-component-node-widget-background-highlighted/80'
'mt-1 py-1.5 bg-secondary-background rounded-lg transition-all duration-150',
'flex-1 flex gap-2 px-2 items-center',
'text-base-foreground border-0',
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80'
)
"
>
<i
v-if="isQuerying"
class="mr-2 icon-[lucide--loader-circle] size-4 animate-spin"
:class="
cn(
'size-4 text-muted-foreground',
isQuerying
? 'icon-[lucide--loader-circle] animate-spin'
: 'icon-[lucide--search]'
)
"
/>
<i v-else class="mr-2 icon-[lucide--search] size-4" />
<input
v-model="searchQuery"
type="text"
class="bg-transparent border-0 outline-0 ring-0 text-left"
:placeholder="$t('g.search')"
class="bg-transparent border-0 outline-0 ring-0 h-5"
:placeholder="$t('g.searchPlaceholder')"
/>
</label>
</template>

View File

@@ -1,15 +1,20 @@
<script setup lang="ts">
import { provide } from 'vue'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { cn } from '@/utils/tailwindUtil'
import RightPanelSection from '../layout/RightPanelSection.vue'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
defineProps<{
const { label, widgets } = defineProps<{
label?: string
widgets: { widget: IBaseWidget; node: LGraphNode }[]
}>()
@@ -17,6 +22,7 @@ defineProps<{
provide('hideLayoutField', true)
const canvasStore = useCanvasStore()
const { t } = useI18n()
function getWidgetComponent(widget: IBaseWidget) {
const component = getComponent(widget.type, widget.name)
@@ -31,17 +37,27 @@ function onWidgetValueChange(
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
}
const isEmpty = computed(() => widgets.length === 0)
const displayLabel = computed(
() =>
label ??
(isEmpty.value
? t('rightSidePanel.inputsNone')
: t('rightSidePanel.inputs'))
)
</script>
<template>
<RightPanelSection>
<PropertiesAccordionItem :is-empty>
<template #label>
<slot name="label">
{{ label ?? $t('rightSidePanel.inputs') }}
{{ displayLabel }}
</slot>
</template>
<div class="space-y-4 rounded-lg bg-interface-surface px-4">
<div v-if="!isEmpty" class="space-y-4 rounded-lg bg-interface-surface px-4">
<div
v-for="({ widget, node }, index) in widgets"
:key="`widget-${index}-${widget.name}`"
@@ -58,7 +74,7 @@ function onWidgetValueChange(
:model-value="widget.value"
:node-id="String(node.id)"
:node-type="node.type"
class="col-span-1"
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
@update:model-value="
(value: string | number | boolean | object) =>
onWidgetValueChange(widget, value)
@@ -66,5 +82,5 @@ function onWidgetValueChange(
/>
</div>
</div>
</RightPanelSection>
</PropertiesAccordionItem>
</template>

View File

@@ -7,35 +7,30 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import SidePanelSearch from '../layout/SidePanelSearch.vue'
import SectionWidgets from './SectionWidgets.vue'
const props = defineProps<{
const { nodes } = defineProps<{
nodes: LGraphNode[]
}>()
const widgetsSectionDataList = computed(() => {
const list: {
widgets: { node: LGraphNode; widget: IBaseWidget }[]
node: LGraphNode
}[] = []
for (const node of props.nodes) {
const shownWidgets: IBaseWidget[] = []
for (const widget of node.widgets ?? []) {
if (widget.options?.canvasOnly || widget.options?.hidden) continue
shownWidgets.push(widget)
}
list.push({
widgets: shownWidgets?.map((widget) => ({ node, widget })) ?? [],
type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
type NodeWidgetsListList = Array<{
node: LGraphNode
widgets: NodeWidgetsList
}>
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return {
widgets: shownWidgets,
node
})
}
return list
}
})
})
const searchedWidgetsSectionDataList = shallowRef<
{
widgets: { node: LGraphNode; widget: IBaseWidget }[]
node: LGraphNode
}[]
>([])
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>([])
/**
* Searches widgets in all selected nodes and returns search results.
@@ -72,7 +67,7 @@ async function searcher(query: string) {
</script>
<template>
<div class="p-4 flex gap-2 border-b border-interface-stroke">
<div class="px-4 pb-4 flex gap-2 border-b border-interface-stroke">
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
</div>
<SectionWidgets

View File

@@ -1,8 +1,8 @@
<template>
<div class="space-y-4 rounded-lg bg-interface-surface p-3">
<div class="space-y-4 p-3 text-sm text-muted-foreground">
<!-- Node State -->
<div class="flex flex-col gap-2">
<span class="text-sm text-text-secondary">
<span>
{{ t('rightSidePanel.nodeState') }}
</span>
<FormSelectButton
@@ -27,11 +27,11 @@
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<span class="text-sm text-text-secondary">
<span>
{{ t('rightSidePanel.color') }}
</span>
<div
class="bg-component-node-widget-background text-component-node-foreground border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
>
<button
v-for="option of colorOptions"
@@ -39,12 +39,9 @@
:class="
cn(
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
{
'bg-interface-menu-component-surface-selected':
option.name === nodeColor,
'hover:bg-interface-menu-component-surface-selected':
option.name !== nodeColor
}
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
@@ -71,7 +68,7 @@
<!-- Pinned Toggle -->
<div class="flex items-center justify-between">
<span class="text-sm text-text-secondary">
<span>
{{ t('rightSidePanel.pinned') }}
</span>
<ToggleSwitch v-model="isPinned" />
@@ -81,7 +78,7 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -93,8 +90,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
node?: LGraphNode
const { nodes = [] } = defineProps<{
nodes?: LGraphNode[]
}>()
@@ -106,36 +102,25 @@ const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const targetNodes = shallowRef<LGraphNode[]>([])
watchEffect(() => {
if (props.node) {
targetNodes.value = [props.node]
} else {
targetNodes.value = props.nodes || []
}
})
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
get(): LGraphNode['mode'] | null {
if (!nodes.length) return null
if (nodes.length === 1) {
return nodes[0].mode
}
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
if (targetNodes.value.length > 1) {
mode = targetNodes.value[0].mode
if (!targetNodes.value.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = targetNodes.value[0].mode
const mode: LGraphNode['mode'] = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
return null
}
return mode
},
set(value: LGraphNode['mode']) {
targetNodes.value.forEach((node) => {
nodes.forEach((node) => {
node.mode = value
})
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -143,11 +128,10 @@ const nodeState = computed({
// Pinned state
const isPinned = computed<boolean>({
get() {
return targetNodes.value.some((node) => node.pinned)
return nodes.some((node) => node.pinned)
},
set(value) {
targetNodes.value.forEach((node) => node.pin(value))
triggerRef(targetNodes)
nodes.forEach((node) => node.pin(value))
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -191,10 +175,8 @@ const colorOptions: NodeColorOption[] = [
const nodeColor = computed<NodeColorOption['name'] | null>({
get() {
if (targetNodes.value.length === 0) return null
const theColorOptions = targetNodes.value.map((item) =>
item.getColorOption()
)
if (nodes.length === 0) return null
const theColorOptions = nodes.map((item) => item.getColorOption())
let colorOption: ColorOption | null | false = theColorOptions[0]
if (!theColorOptions.every((option) => option === colorOption)) {
@@ -220,10 +202,9 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
? null
: LGraphCanvas.node_colors[colorName]
for (const item of targetNodes.value) {
for (const item of nodes) {
item.setColorOption(canvasColorOption)
}
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})

View File

@@ -257,9 +257,11 @@ useResizeObserver(footerRef, (entries) => {
})
// Determine if we should show compact mode (icon only)
// Threshold: 350px or less shows icon only
// Threshold matches when grid switches from 2 columns to 1 column
// 2 columns need about ~430px
const COMPACT_MODE_THRESHOLD_PX = 430
const isCompact = computed(
() => footerWidth.value > 0 && footerWidth.value <= 350
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
)
// Hover state for selection count button

View File

@@ -12,19 +12,19 @@
</button>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="T extends string = string">
import type { Ref } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { value, panelId } = defineProps<{
value: string
value: T
panelId?: string
}>()
const currentValue = inject<Ref<string>>('tabs-value')
const updateValue = inject<(value: string) => void>('tabs-update')
const currentValue = inject<Ref<T>>('tabs-value')
const updateValue = inject<(value: T) => void>('tabs-update')
const tabId = computed(() => `tab-${value}`)
const isActive = computed(() => currentValue?.value === value)

View File

@@ -3,8 +3,13 @@ import { ref } from 'vue'
import Tab from './Tab.vue'
import TabList from './TabList.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
const meta: Meta<typeof TabList> = {
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: ComponentExposed<C>
}
const meta: GenericMeta<typeof TabList> = {
title: 'Components/Tab/TabList',
component: TabList,
tags: ['autodocs'],

View File

@@ -4,14 +4,14 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="T extends string = string">
import { provide } from 'vue'
const modelValue = defineModel<string>({ required: true })
const modelValue = defineModel<T>({ required: true })
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: string) => {
provide('tabs-update', (value: T) => {
modelValue.value = value
})
</script>

View File

@@ -82,7 +82,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: { value: true },
isSubscriptionRequirementMet: { value: true },
fetchStatus: mockFetchStatus
}))
}))
@@ -121,6 +121,11 @@ describe('CurrentUserPopover', () => {
vi.clearAllMocks()
})
const findButtonByLabel = (wrapper: VueWrapper, label: string) =>
wrapper
.findAllComponents(Button)
.find((button) => button.props('label') === label)!
const mountComponent = (): VueWrapper => {
const i18n = createI18n({
legacy: false,
@@ -161,8 +166,7 @@ describe('CurrentUserPopover', () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (third button)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
const settingsButton = findButtonByLabel(wrapper, 'User Settings')
// Click the settings button
await settingsButton.trigger('click')
@@ -179,8 +183,7 @@ describe('CurrentUserPopover', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutButton = findButtonByLabel(wrapper, 'Log Out')
// Click the logout button
await logoutButton.trigger('click')
@@ -197,8 +200,10 @@ describe('CurrentUserPopover', () => {
const wrapper = mountComponent()
// Find all buttons and get the Partner Nodes info button (first one)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
const partnerNodesButton = findButtonByLabel(
wrapper,
'Partner Nodes pricing table'
)
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
@@ -218,8 +223,7 @@ describe('CurrentUserPopover', () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (second one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
const topUpButton = findButtonByLabel(wrapper, 'Top Up')
// Click the top-up button
await topUpButton.trigger('click')

View File

@@ -23,37 +23,49 @@
</div>
</div>
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<component
:is="SubscriptionSection"
v-if="SubscriptionSection"
@top-up="handleTopUp"
@open-partner-info="handleOpenPartnerNodesInfo"
@open-plan-settings="handleOpenPlanAndCreditsSettings"
/>
<template v-else>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
</div>
<Button
:label="$t('subscription.partnerNodesCredits')"
:label="$t('credits.topUp.topUp')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
@click="handleTopUp"
/>
</div>
<Button
:label="$t('credits.topUp.topUp')"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
size="small"
@click="handleTopUp"
@click="handleOpenPlanAndCreditsSettings"
/>
</div>
<SubscribeButton
v-else
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
</template>
<Divider class="my-2" />
@@ -67,17 +79,6 @@
@click="handleOpenUserSettings"
/>
<Button
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
@click="handleOpenPlanAndCreditsSettings"
/>
<Divider class="my-2" />
<Button
@@ -95,15 +96,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { onMounted } from 'vue'
import { defineAsyncComponent, onMounted } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -114,15 +113,18 @@ const emit = defineEmits<{
const { buildDocsUrl } = useExternalLink()
const planSettingsLabel = isCloud
? 'settingsCategories.PlanCredits'
: 'settingsCategories.Credits'
const planSettingsLabel = 'settingsCategories.Credits'
const SubscriptionSection = isCloud
? defineAsyncComponent(
() => import('./CurrentUserPopoverSubscriptionSection.vue')
)
: null
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
@@ -161,10 +163,6 @@ const handleLogout = async () => {
emit('close')
}
const handleSubscribed = async () => {
await fetchStatus()
}
onMounted(() => {
void authActions.fetchBalance()
})

View File

@@ -0,0 +1,78 @@
<template>
<div v-if="isSubscriptionRequirementMet" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerInfo"
/>
</div>
<Button
:label="$t('credits.topUp.topUp')"
severity="secondary"
size="small"
@click="handleTopUp"
/>
</div>
<Button
class="justify-start"
:label="$t('settingsCategories.PlanCredits')"
icon="pi pi-receipt"
text
fluid
severity="secondary"
@click="handleOpenPlanSettings"
/>
</div>
<SubscribeButton
v-else
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
class="w-full"
@subscribed="handleSubscribed"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import UserCredit from '@/components/common/UserCredit.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const emit = defineEmits<{
'top-up': []
'open-partner-info': []
'open-plan-settings': []
}>()
const { isSubscriptionRequirementMet, fetchStatus } = useSubscription()
const handleSubscribed = async () => {
await fetchStatus()
}
const handleTopUp = () => {
emit('top-up')
}
const handleOpenPartnerInfo = () => {
emit('open-partner-info')
}
const handleOpenPlanSettings = () => {
emit('open-plan-settings')
}
</script>

View File

@@ -6,7 +6,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
@@ -82,8 +81,13 @@ export const useFirebaseAuthActions = () => {
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
const { isActiveSubscription } = useSubscription()
if (!isActiveSubscription.value) return
if (isCloud) {
const { useSubscription } = await import(
'@/platform/cloud/subscription/composables/useSubscription'
)
const { isSubscriptionRequirementMet } = useSubscription()
if (!isSubscriptionRequirementMet.value) return
}
const response = await authStore.initiateCreditPurchase({
amount_micros: usdToMicros(amount),

View File

@@ -1768,6 +1768,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
ByteDanceSeedreamNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const sequentialGenerationWidget = node.widgets?.find(
(w) => w.name === 'sequential_image_generation'
) as IComboWidget
@@ -1775,21 +1778,31 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
(w) => w.name === 'max_images'
) as IComboWidget
if (!sequentialGenerationWidget || !maxImagesWidget)
return '$0.03/Run ($0.03 for one output image)'
if (
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
) {
return '$0.03/Run'
const model = String(modelWidget?.value ?? '').toLowerCase()
let pricePerImage = 0.03 // default for seedream-4-0-250828 and fallback
if (model.includes('seedream-4-5-251128')) {
pricePerImage = 0.04
} else if (model.includes('seedream-4-0-250828')) {
pricePerImage = 0.03
}
const maxImages = Number(maxImagesWidget.value)
if (!sequentialGenerationWidget || !maxImagesWidget) {
return `$${pricePerImage}/Run ($${pricePerImage} for one output image)`
}
const seqMode = String(sequentialGenerationWidget.value).toLowerCase()
if (seqMode === 'disabled') {
return `$${pricePerImage}/Run`
}
const maxImagesRaw = Number(maxImagesWidget.value)
const maxImages =
Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 ? maxImagesRaw : 1
if (maxImages === 1) {
return '$0.03/Run'
return `$${pricePerImage}/Run`
}
const cost = (0.03 * maxImages).toFixed(2)
return `$${cost}/Run ($0.03 for one output image)`
const totalCost = (pricePerImage * maxImages).toFixed(2)
return `$${totalCost}/Run ($${pricePerImage} for one output image)`
}
},
ByteDanceTextToVideoNode: {

View File

@@ -1,3 +1,5 @@
import { computed } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
@@ -46,6 +48,7 @@ import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { isCloud } from '@/platform/distribution/types'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
@@ -63,7 +66,8 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const defaultSubscriptionState = computed(() => true)
const noop = () => {}
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -85,6 +89,11 @@ export function useCoreCommands(): ComfyCommand[] {
useSelectedLiteGraphItems()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const subscription = isCloud ? useSubscription() : null
const subscriptionState =
subscription?.isSubscriptionRequirementMet ?? defaultSubscriptionState
const subscriptionDialog = subscription?.showSubscriptionDialog ?? noop
const moveSelectedNodes = (
positionUpdater: (pos: Point, gridSize: number) => Point
) => {
@@ -475,8 +484,8 @@ export function useCoreCommands(): ComfyCommand[] {
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
if (!subscriptionState.value) {
subscriptionDialog()
return
}
@@ -498,8 +507,8 @@ export function useCoreCommands(): ComfyCommand[] {
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
if (!subscriptionState.value) {
subscriptionDialog()
return
}
@@ -520,8 +529,8 @@ export function useCoreCommands(): ComfyCommand[] {
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
if (!subscriptionState.value) {
subscriptionDialog()
return
}

View File

@@ -1,5 +1,3 @@
import { without } from 'es-toolkit'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
@@ -10,6 +8,7 @@ import type {
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -20,7 +19,6 @@ import {
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { isStrings } from '@/utils/typeGuardUtil'
const INLINE_INPUTS = false
@@ -244,30 +242,6 @@ function changeOutputType(
}
}
function combineTypes(...types: ISlotType[]): ISlotType | undefined {
if (!isStrings(types)) return undefined
const withoutWildcards = without(types, '*')
if (withoutWildcards.length === 0) return '*'
const typeLists: string[][] = withoutWildcards.map((type) => type.split(','))
const combinedTypes = intersection(...typeLists)
if (combinedTypes.length === 0) return undefined
return combinedTypes.join(',')
}
function intersection(...sets: string[][]): string[] {
const itemCounts: Record<string, number> = {}
for (const set of sets)
for (const item of new Set(set))
itemCounts[item] = (itemCounts[item] ?? 0) + 1
return Object.entries(itemCounts)
.filter(([, count]) => count == sets.length)
.map(([key]) => key)
}
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (node.comfyMatchType) return
node.comfyMatchType = {}
@@ -290,8 +264,6 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!matchGroup) return
if (iscon && linf) {
const { output, subgraphInput } = linf.resolve(this.graph)
//TODO: fix this bug globally. A link type (and therefore color)
//should be the combinedType of origin and target type
const connectingType = (output ?? subgraphInput)?.type
if (connectingType) linf.type = connectingType
}
@@ -316,14 +288,14 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
...connectedTypes.slice(0, idx),
...connectedTypes.slice(idx + 1)
]
const combinedType = combineTypes(
const combinedType = commonType(
...otherConnected,
matchGroup[input.name]
)
if (!combinedType) throw new Error('invalid connection')
input.type = combinedType
})
const outputType = combineTypes(...connectedTypes)
const outputType = commonType(...connectedTypes)
if (!outputType) throw new Error('invalid connection')
this.outputs.forEach((output, idx) => {
if (!(outputGroups?.[idx] == matchKey)) return

View File

@@ -15,10 +15,10 @@ useExtensionService().registerExtension({
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
const { isSubscriptionRequirementMet } = useSubscription()
watchDebounced(
[isLoggedIn, isActiveSubscription],
[isLoggedIn, isSubscriptionRequirementMet],
() => {
if (!isLoggedIn.value) return
void refreshRemoteConfig()

View File

@@ -10,6 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { LayoutSource } from '@/renderer/core/layout/types'
import { adjustColor } from '@/utils/colorUtil'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import { commonType, toClass } from '@/lib/litegraph/src/utils/type'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import type { DragAndScale } from './DragAndScale'
@@ -84,7 +85,6 @@ import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { toClass } from './utils/type'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
@@ -2832,9 +2832,12 @@ export class LGraphNode
inputNode.disconnectInput(inputIndex, true)
}
const maybeCommonType =
input.type && output.type && commonType(input.type, output.type)
const link = new LLink(
++graph.state.lastLinkId,
input.type || output.type,
maybeCommonType || input.type || output.type,
this.id,
outputIndex,
inputNode.id,

View File

@@ -1,4 +1,6 @@
import type { IColorable } from '@/lib/litegraph/src/interfaces'
import { without } from 'es-toolkit'
import type { IColorable, ISlotType } from '@/lib/litegraph/src/interfaces'
/**
* Converts a plain object to a class instance if it is not already an instance of the class.
@@ -26,3 +28,31 @@ export function isColorable(obj: unknown): obj is IColorable {
'getColorOption' in obj
)
}
export function commonType(...types: ISlotType[]): ISlotType | undefined {
if (!isStrings(types)) return undefined
const withoutWildcards = without(types, '*')
if (withoutWildcards.length === 0) return '*'
const typeLists: string[][] = withoutWildcards.map((type) => type.split(','))
const combinedTypes = intersection(...typeLists)
if (combinedTypes.length === 0) return undefined
return combinedTypes.join(',')
}
function intersection(...sets: string[][]): string[] {
const itemCounts: Record<string, number> = {}
for (const set of sets)
for (const item of new Set(set))
itemCounts[item] = (itemCounts[item] ?? 0) + 1
return Object.entries(itemCounts)
.filter(([, count]) => count === sets.length)
.map(([key]) => key)
}
function isStrings(types: unknown[]): types is string[] {
return types.every((t) => typeof t === 'string')
}

View File

@@ -2259,20 +2259,17 @@
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",
"multipleSelection": "{count} items selected",
"title": "No node(s) selected | 1 node selected | {count} nodes selected",
"parameters": "Parameters",
"info": "Info",
"nodeType": "Type",
"nodeId": "ID",
"description": "Description",
"color": "Node color",
"pinned": "Pinned",
"bypass": "Bypass",
"normal": "Normal",
"mute": "Mute",
"inputs": "INPUTS",
"properties": "Properties",
"nodeState": "Node state",
"settings": "Settings"
"inputsNone": "NO INPUTS",
"inputsNoneTooltip": "Node has no inputs",
"nodeState": "Node state"
}
}
}

View File

@@ -63,7 +63,7 @@ export function useAssetBrowser(
const searchQuery = ref('')
const selectedCategory = ref('all')
const filters = ref<FilterState>({
sortBy: 'name-asc',
sortBy: 'recent',
fileFormats: [],
baseModels: []
})

View File

@@ -51,7 +51,8 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const { subscribe, isSubscriptionRequirementMet, fetchStatus } =
useSubscription()
const telemetry = useTelemetry()
const isLoading = ref(false)
@@ -76,7 +77,7 @@ const startPollingSubscriptionStatus = () => {
await fetchStatus()
if (isActiveSubscription.value) {
if (isSubscriptionRequirementMet.value) {
stopPolling()
telemetry?.trackMonthlySubscriptionSucceeded()
emit('subscribed')

View File

@@ -4,7 +4,7 @@
<div class="flex items-baseline gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
{{
isActiveSubscription
isSubscriptionRequirementMet
? $t('subscription.title')
: $t('subscription.titleUnsubscribed')
}}
@@ -27,7 +27,7 @@
}}</span>
</div>
<div
v-if="isActiveSubscription"
v-if="isSubscriptionRequirementMet"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
@@ -47,7 +47,7 @@
</div>
</div>
<Button
v-if="isActiveSubscription"
v-if="isSubscriptionRequirementMet"
:label="$t('subscription.manageSubscription')"
severity="secondary"
class="text-xs bg-interface-menu-component-surface-selected"
@@ -196,7 +196,7 @@
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
v-if="isSubscriptionRequirementMet"
:label="$t('subscription.addCredits')"
severity="secondary"
class="p-2 min-h-8 bg-interface-menu-component-surface-selected"
@@ -320,7 +320,7 @@ import { cn } from '@/utils/tailwindUtil'
const { buildDocsUrl } = useExternalLink()
const {
isActiveSubscription,
isSubscriptionRequirementMet,
isCancelled,
formattedRenewalDate,
formattedEndDate,

View File

@@ -31,8 +31,12 @@ function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const telemetry = useTelemetry()
const isSubscribedOrIsNotCloud = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
const isSubscriptionCheckRequired = computed(() =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
)
const isSubscriptionRequirementMet = computed(() => {
if (!isSubscriptionCheckRequired.value) return true
return subscriptionStatus.value?.is_active ?? false
})
@@ -106,12 +110,12 @@ function useSubscriptionInternal() {
}
const shouldWatchCancellation = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
isSubscriptionCheckRequired.value
const { startCancellationWatcher, stopCancellationWatcher } =
useSubscriptionCancellationWatcher({
fetchStatus,
isActiveSubscription: isSubscribedOrIsNotCloud,
isSubscriptionRequirementMet,
subscriptionStatus,
telemetry,
shouldWatchCancellation
@@ -123,9 +127,11 @@ function useSubscriptionInternal() {
}
const requireActiveSubscription = async (): Promise<void> => {
if (!isSubscriptionCheckRequired.value) return
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
if (!isSubscriptionRequirementMet.value) {
showSubscriptionDialog()
}
}
@@ -223,7 +229,7 @@ function useSubscriptionInternal() {
return {
// State
isActiveSubscription: isSubscribedOrIsNotCloud,
isSubscriptionRequirementMet,
isCancelled,
formattedRenewalDate,
formattedEndDate,

View File

@@ -36,8 +36,8 @@ export function useSubscriptionActions() {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
const handleAddApiCredits = async () => {
await dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {

View File

@@ -12,7 +12,7 @@ const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals
type CancellationWatcherOptions = {
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
isActiveSubscription: ComputedRef<boolean>
isSubscriptionRequirementMet: ComputedRef<boolean>
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
shouldWatchCancellation: () => boolean
@@ -20,7 +20,7 @@ type CancellationWatcherOptions = {
export function useSubscriptionCancellationWatcher({
fetchStatus,
isActiveSubscription,
isSubscriptionRequirementMet,
subscriptionStatus,
telemetry,
shouldWatchCancellation
@@ -73,7 +73,7 @@ export function useSubscriptionCancellationWatcher({
try {
await fetchStatus()
if (!isActiveSubscription.value) {
if (!isSubscriptionRequirementMet.value) {
if (!cancellationTracked.value) {
cancellationTracked.value = true
try {

View File

@@ -83,6 +83,7 @@ import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetD
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
@@ -192,28 +193,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
return result
})
// TODO: Derive from types in widgetRegistry
const EXPANDING_TYPES = [
'textarea',
'TEXTAREA',
'multiline',
'customtext',
'markdown',
'MARKDOWN',
'progressText',
'load3D',
'LOAD_3D'
] as const
const gridTemplateRows = computed((): string => {
const widgets = toValue(processedWidgets)
return widgets
.filter((w) => !w.simplified.options?.hidden)
.map((w) =>
EXPANDING_TYPES.includes(w.type as (typeof EXPANDING_TYPES)[number])
? 'auto'
: 'min-content'
)
.map((w) => (shouldExpand(w.type) ? 'auto' : 'min-content'))
.join(' ')
})
</script>

View File

@@ -43,8 +43,8 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
<IconTextButton
v-if="isUploadButtonEnabled && singleFilterOption"
:label="$t('g.import')"
class="ml-auto"
type="secondary"
class="ml-auto text-base-foreground hover:bg-node-component-widget-input-surface"
type="transparent"
@click="showUploadDialog"
>
<template #icon>

View File

@@ -197,3 +197,12 @@ export const isEssential = (type: string): boolean => {
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}
const EXPANDING_TYPES = ['textarea', 'markdown', 'load3D'] as const
export function shouldExpand(type: string): boolean {
const canonicalType = getCanonicalType(type)
return EXPANDING_TYPES.includes(
canonicalType as (typeof EXPANDING_TYPES)[number]
)
}

View File

@@ -676,11 +676,17 @@ export class ComfyApp {
'Payment Required: Please add credits to your account to use this node.'
)
) {
const { isActiveSubscription } = useSubscription()
if (isActiveSubscription.value) {
if (!isCloud) {
useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
} else {
const { isSubscriptionRequirementMet } = useSubscription()
if (isSubscriptionRequirementMet.value) {
useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
}
}
} else {
useDialogService().showExecutionErrorDialog(detail)

View File

@@ -17,7 +17,6 @@ import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { useDialogStore } from '@/stores/dialogStore'
@@ -377,11 +376,16 @@ export const useDialogService = () => {
})
}
function showTopUpCreditsDialog(options?: {
async function showTopUpCreditsDialog(options?: {
isInsufficientCredits?: boolean
}) {
const { isActiveSubscription } = useSubscription()
if (!isActiveSubscription.value) return
if (isCloud) {
const { useSubscription } = await import(
'@/platform/cloud/subscription/composables/useSubscription'
)
const { isSubscriptionRequirementMet } = useSubscription()
if (!isSubscriptionRequirementMet.value) return
}
return dialogStore.showDialog({
key: 'top-up-credits',

View File

@@ -119,11 +119,6 @@ export const useLitegraphService = () => {
return { color: '#0f0' }
}
}
node.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -32,6 +32,7 @@ import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
interface QueuedPrompt {
/**
@@ -534,6 +535,97 @@ export const useExecutionStore = defineStore('execution', () => {
() => runningPromptIds.value.length
)
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
if (!lastNodeErrors.value) return {}
const map: Record<NodeLocatorId, NodeError> = {}
for (const [executionId, nodeError] of Object.entries(
lastNodeErrors.value
)) {
const locatorId = executionIdToNodeLocatorId(executionId)
if (locatorId) {
map[locatorId] = nodeError
}
}
return map
}
)
/** Get node errors by locator ID. */
const getNodeErrors = (
nodeLocatorId: NodeLocatorId
): NodeError | undefined => {
return nodeErrorsByLocatorId.value[nodeLocatorId]
}
/** Check if a specific slot has validation errors. */
const slotHasError = (
nodeLocatorId: NodeLocatorId,
slotName: string
): boolean => {
const nodeError = getNodeErrors(nodeLocatorId)
if (!nodeError) return false
return nodeError.errors.some((e) => e.extra_info?.input_name === slotName)
}
/**
* Update node and slot error flags when validation errors change.
* Propagates errors up subgraph chains.
*/
watch(lastNodeErrors, () => {
if (!app.graph || !app.graph.nodes) return
// Clear all error flags
forEachNode(app.graph, (node) => {
node.has_errors = false
if (node.inputs) {
for (const slot of node.inputs) {
slot.hasErrors = false
}
}
})
if (!lastNodeErrors.value) return
// Set error flags on nodes and slots
for (const [executionId, nodeError] of Object.entries(
lastNodeErrors.value
)) {
const node = getNodeByExecutionId(app.graph, executionId)
if (!node) continue
node.has_errors = true
// Mark input slots with errors
if (node.inputs) {
for (const error of nodeError.errors) {
const slotName = error.extra_info?.input_name
if (!slotName) continue
const slot = node.inputs.find((s) => s.name === slotName)
if (slot) {
slot.hasErrors = true
}
}
}
// Propagate errors to parent subgraph nodes
const parts = executionId.split(':')
for (let i = parts.length - 1; i > 0; i--) {
const parentExecutionId = parts.slice(0, i).join(':')
const parentNode = getNodeByExecutionId(app.graph, parentExecutionId)
if (parentNode) {
parentNode.has_errors = true
}
}
}
})
return {
isIdle,
clientId,
@@ -567,6 +659,9 @@ export const useExecutionStore = defineStore('execution', () => {
// NodeLocatorId conversion helpers
executionIdToNodeLocatorId,
nodeLocatorIdToExecutionId,
promptIdToWorkflowId
promptIdToWorkflowId,
// Node error lookup helpers
getNodeErrors,
slotHasError
}
})

View File

@@ -1,52 +1,38 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
type RightSidePanelTab = 'parameters' | 'settings' | 'info'
export type RightSidePanelTab = 'parameters' | 'settings' | 'info' | 'subgraph'
/**
* Store for managing the right side panel state.
* This panel displays properties and settings for selected nodes.
*/
export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
// Panel visibility state
const isOpen = ref(false)
const isEditingSubgraph = ref(false)
// Active tab in the node properties panel
const activeTab = ref<RightSidePanelTab>('parameters')
const isEditingSubgraph = computed(() => activeTab.value === 'subgraph')
// Actions
function openPanel(tab?: RightSidePanelTab | 'subgraph') {
function openPanel(tab?: RightSidePanelTab) {
isOpen.value = true
if (tab === 'subgraph') {
activeTab.value = 'parameters'
isEditingSubgraph.value = true
} else if (tab) {
if (tab) {
activeTab.value = tab
isEditingSubgraph.value = false
}
}
function closePanel() {
isOpen.value = false
isEditingSubgraph.value = false
}
function togglePanel() {
isOpen.value = !isOpen.value
}
function setActiveTab(tab: RightSidePanelTab) {
activeTab.value = tab
}
return {
isOpen,
activeTab,
isEditingSubgraph,
openPanel,
closePanel,
togglePanel,
setActiveTab
togglePanel
}
})

View File

@@ -60,7 +60,3 @@ export const isResultItemType = (
): value is ResultItemType => {
return value === 'input' || value === 'output' || value === 'temp'
}
export function isStrings(types: unknown[]): types is string[] {
return types.every((t) => typeof t === 'string')
}

View File

@@ -100,7 +100,7 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: vi.fn().mockReturnValue(true),
isSubscriptionRequirementMet: { value: true },
showSubscriptionDialog: vi.fn()
}))
}))

View File

@@ -7,7 +7,7 @@ import SubscriptionPanel from '@/platform/cloud/subscription/components/Subscrip
// Mock composables
const mockSubscriptionData = {
isActiveSubscription: false,
isSubscriptionRequirementMet: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
@@ -120,14 +120,14 @@ describe('SubscriptionPanel', () => {
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isSubscriptionRequirementMet = 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
mockSubscriptionData.isSubscriptionRequirementMet = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
@@ -137,14 +137,14 @@ describe('SubscriptionPanel', () => {
})
it('shows renewal date for active non-cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isSubscriptionRequirementMet = 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.isSubscriptionRequirementMet = true
mockSubscriptionData.isCancelled = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')

View File

@@ -92,7 +92,7 @@ describe('useSubscription', () => {
})
describe('computed properties', () => {
it('should compute isActiveSubscription correctly when subscription is active', async () => {
it('should compute isSubscriptionRequirementMet correctly when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
@@ -103,13 +103,13 @@ describe('useSubscription', () => {
} as Response)
mockIsLoggedIn.value = true
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isSubscriptionRequirementMet, fetchStatus } = useSubscription()
await fetchStatus()
expect(isActiveSubscription.value).toBe(true)
expect(isSubscriptionRequirementMet.value).toBe(true)
})
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
it('should compute isSubscriptionRequirementMet as false when subscription is inactive', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
@@ -120,10 +120,10 @@ describe('useSubscription', () => {
} as Response)
mockIsLoggedIn.value = true
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isSubscriptionRequirementMet, fetchStatus } = useSubscription()
await fetchStatus()
expect(isActiveSubscription.value).toBe(false)
expect(isSubscriptionRequirementMet.value).toBe(false)
})
it('should format renewal date correctly', async () => {

View File

@@ -24,7 +24,7 @@ describe('useSubscriptionCancellationWatcher', () => {
baseStatus
)
const isActive = ref(true)
const isActiveSubscription = computed(() => isActive.value)
const isSubscriptionRequirementMet = computed(() => isActive.value)
let shouldWatch = true
const shouldWatchCancellation = () => shouldWatch
@@ -76,7 +76,7 @@ describe('useSubscriptionCancellationWatcher', () => {
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
isSubscriptionRequirementMet,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
@@ -106,7 +106,7 @@ describe('useSubscriptionCancellationWatcher', () => {
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
isSubscriptionRequirementMet,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
@@ -128,7 +128,7 @@ describe('useSubscriptionCancellationWatcher', () => {
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
isSubscriptionRequirementMet,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
@@ -153,7 +153,7 @@ describe('useSubscriptionCancellationWatcher', () => {
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
isSubscriptionRequirementMet,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation

View File

@@ -129,3 +129,170 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
})
})
})
describe('useExecutionStore - Node Error Lookups', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
store = useExecutionStore()
})
describe('getNodeErrors', () => {
it('should return undefined when no errors exist', () => {
const result = store.getNodeErrors('123')
expect(result).toBeUndefined()
})
it('should return node error by locator ID for root graph node', () => {
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'validation_error',
message: 'Invalid input',
details: 'Width must be positive',
extra_info: { input_name: 'width' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const result = store.getNodeErrors('123')
expect(result).toBeDefined()
expect(result?.errors).toHaveLength(1)
expect(result?.errors[0].message).toBe('Invalid input')
})
it('should return node error by locator ID for subgraph node', () => {
const subgraphUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const mockSubgraph = {
id: subgraphUuid,
_nodes: []
}
const mockNode = {
id: 123,
isSubgraphNode: () => true,
subgraph: mockSubgraph
} as any
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
store.lastNodeErrors = {
'123:456': {
errors: [
{
type: 'validation_error',
message: 'Invalid subgraph input',
details: 'Missing required input',
extra_info: { input_name: 'image' }
}
],
class_type: 'SubgraphNode',
dependent_outputs: []
}
}
const locatorId = `${subgraphUuid}:456`
const result = store.getNodeErrors(locatorId)
expect(result).toBeDefined()
expect(result?.errors[0].message).toBe('Invalid subgraph input')
})
})
describe('slotHasError', () => {
it('should return false when node has no errors', () => {
const result = store.slotHasError('123', 'width')
expect(result).toBe(false)
})
it('should return false when node has errors but slot is not mentioned', () => {
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'validation_error',
message: 'Invalid input',
details: 'Width must be positive',
extra_info: { input_name: 'width' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const result = store.slotHasError('123', 'height')
expect(result).toBe(false)
})
it('should return true when slot has error', () => {
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'validation_error',
message: 'Invalid input',
details: 'Width must be positive',
extra_info: { input_name: 'width' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const result = store.slotHasError('123', 'width')
expect(result).toBe(true)
})
it('should return true when multiple errors exist for the same slot', () => {
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'validation_error',
message: 'Invalid input',
details: 'Width must be positive',
extra_info: { input_name: 'width' }
},
{
type: 'validation_error',
message: 'Invalid range',
details: 'Width must be less than 1000',
extra_info: { input_name: 'width' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const result = store.slotHasError('123', 'width')
expect(result).toBe(true)
})
it('should handle errors without extra_info', () => {
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'validation_error',
message: 'General error',
details: 'Something went wrong'
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const result = store.slotHasError('123', 'width')
expect(result).toBe(false)
})
})
})