Compare commits

..

2 Commits

Author SHA1 Message Date
GitHub Action
3847feb3d6 [automated] Apply ESLint and Oxfmt fixes 2026-06-05 04:33:43 +00:00
mattmillerai
67416f8a21 [chore] Update Ingest API types from cloud@f2a7c8a 2026-06-05 04:30:01 +00:00
113 changed files with 3206 additions and 6111 deletions

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { BadgeVariants } from './badge.variants'
import { badgeVariants } from './badge.variants'
const { variant, class: className } = defineProps<{
variant?: BadgeVariants['variant']
class?: string
}>()
</script>
<template>
<span :class="cn(badgeVariants({ variant }), className)">
<slot />
</span>
</template>

View File

@@ -1,53 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
const {
locale = 'en',
headingKey,
primaryLabelKey,
primaryHref,
secondaryLabelKey,
secondaryHref
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
primaryLabelKey: TranslationKey
primaryHref?: string
secondaryLabelKey?: TranslationKey
secondaryHref?: string
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<BrandButton
:href="primaryHref"
variant="solid"
size="xs"
class="uppercase"
>
{{ t(primaryLabelKey, locale) }}
</BrandButton>
<BrandButton
v-if="secondaryLabelKey"
:href="secondaryHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(secondaryLabelKey, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
descriptionKey,
notifyLabelKey,
notifyHref,
events
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
descriptionKey: TranslationKey
notifyLabelKey: TranslationKey
notifyHref?: string
events: readonly EventItem[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-12">
<div
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
>
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
<div>
<BrandButton
:href="notifyHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(notifyLabelKey, locale) }}
</BrandButton>
</div>
</div>
<div class="flex flex-col">
<a
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</a>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,165 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MaskRevealButton from './MaskRevealButton.vue'
const meta: Meta<typeof MaskRevealButton> = {
title: 'Website/Common/MaskRevealButton',
component: MaskRevealButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { href: '#' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `
<MaskRevealButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
`
})
}
export const LabelVisible: Story = {
args: { href: '#', hideLabel: false },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template:
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { MaskRevealButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
</div>
</div>
</div>
`
})
}

View File

@@ -1,82 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
import {
maskRevealButtonBadgeVariants,
maskRevealButtonVariants,
maskRevealLabelVariants
} from './maskRevealButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = true,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: MaskRevealButtonVariants['variant']
size?: MaskRevealButtonVariants['size']
iconPosition?: MaskRevealButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:data-icon-position="iconPosition ?? 'right'"
:data-hidden="hideLabel ? 'true' : 'false'"
:class="maskRevealLabelVariants()"
>
<slot />
</span>
<span
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -1,165 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PillButton from './PillButton.vue'
const meta: Meta<typeof PillButton> = {
title: 'Website/Common/PillButton',
component: PillButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AsAnchor: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const AsButton: Story = {
args: { type: 'button' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Submit</PillButton>'
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Read More</PillButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `
<PillButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</PillButton>
`
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Go Back</PillButton>'
})
}
export const RevealLabelOnHover: Story = {
args: { href: '#', hideLabel: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Unavailable</PillButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { PillButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
</div>
</div>
</div>
`
})
}

View File

@@ -1,84 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { PillButtonVariants } from './pillButton.variants'
import {
pillButtonBadgeVariants,
pillButtonVariants
} from './pillButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = false,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: PillButtonVariants['variant']
size?: PillButtonVariants['size']
iconPosition?: PillButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:class="
cn(
'relative leading-none transition-all duration-500',
hideLabel && 'opacity-0 group-hover:opacity-100'
)
"
>
<slot />
</span>
<span
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -43,7 +43,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('footer.blog', locale),
href: externalLinks.blog,

View File

@@ -52,7 +52,6 @@ const navLinks: NavLink[] = [
{
label: t('nav.resources', locale),
items: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -1,17 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
}
},
defaultVariants: {
variant: 'default'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,110 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const maskRevealButtonVariants = cva({
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealLabelVariants = cva({
base: [
'relative inline-block align-baseline',
'[will-change:mask-size,-webkit-mask-size]',
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
].join(' ')
})
export type MaskRevealButtonVariants = VariantProps<
typeof maskRevealButtonVariants
>

View File

@@ -1,116 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const pillButtonVariants = cva({
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
},
{
size: 'md',
iconPosition: 'right',
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
},
{
size: 'lg',
iconPosition: 'right',
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
},
{
size: 'sm',
iconPosition: 'left',
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
},
{
size: 'md',
iconPosition: 'left',
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
},
{
size: 'lg',
iconPosition: 'left',
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const pillButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>

View File

@@ -1,67 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const tags = ['Seadance 2.0', 'Image To Video']
const demoVideoSrc =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
const demoVideoPoster =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<div>
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
{{ t('learning.featured.author', locale) }}
</p>
</div>
<p
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>
<div class="flex flex-wrap gap-3">
<BrandButton
variant="outline"
size="xs"
class="uppercase"
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
>
{{ t('cta.tryWorkflow', locale) }}
</BrandButton>
</div>
<ul class="mt-2 flex flex-wrap gap-3">
<li v-for="tag in tags" :key="tag">
<Badge variant="subtle">{{ tag }}</Badge>
</li>
</ul>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<VideoPlayer
:locale
:src="demoVideoSrc"
:poster="demoVideoPoster"
minimal
/>
</div>
</div>
</section>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('learning.heroTitle.after', locale) }}
<br />
{{ t('learning.heroTitle.line2', locale) }}
</h1>
</section>
</template>

View File

@@ -1,96 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const playFromStart = () => {
const video = videoRef.value
if (!video) return
video.currentTime = 0
void video.play().catch(() => {})
}
watch(
() => tutorial.id,
() => {
playFromStart()
}
)
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@close="emit('close')"
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
>
<video
ref="videoRef"
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
controls
autoplay
playsinline
></video>
</div>
<h2
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>
</template>

View File

@@ -1,120 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
<ul
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in learningTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="sm"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -12,7 +12,6 @@ const baseRoutes = {
careers: '/careers',
customers: '/customers',
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',

View File

@@ -1,31 +0,0 @@
import type { EventItem } from '../components/common/EventsSection.vue'
export const learningEvents: readonly EventItem[] = [
{
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
title: {
en: 'Zero to Node: Building Your First Workflow',
'zh-CN': '从零到节点:构建你的第一个工作流'
},
cta: { en: 'Link', 'zh-CN': '链接' },
href: '#'
},
{
label: { en: 'Event 1', 'zh-CN': '活动 1' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
href: '#'
},
{
label: { en: 'Event 2', 'zh-CN': '活动 2' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
href: '#'
}
] as const

View File

@@ -1,84 +0,0 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1,22 +1,6 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// Tags (global, reusable across sections)
'tags.partnerNodes': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
},
'tags.imageToVideo': {
en: 'Image To Video',
'zh-CN': '图像生成视频'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
@@ -1451,62 +1435,6 @@ const translations = {
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
// LearningHeroSection
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
'learning.heroTitle.line2': {
en: 'Build what doesnt exist yet.',
'zh-CN': '构建尚未存在之物。'
},
// LearningFeaturedWorkflowSection
'learning.featured.title': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'learning.featured.author': {
en: 'by Doug Hogan',
'zh-CN': '作者Doug Hogan'
},
'learning.featured.description': {
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
'zh-CN':
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
},
'learning.featured.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// LearningTutorialsSection
'learning.tutorials.heading': {
en: 'Featured Demos',
'zh-CN': '精选演示'
},
'learning.tutorials.titlePrefix': {
en: 'Learn how to:',
'zh-CN': '学习如何:'
},
// LearningCallToActionSection
'learning.cta.heading': {
en: 'Schedule a demo and see how ComfyUI fits your teams creative needs.',
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
},
'learning.cta.contactSales': {
en: 'Contact Sales',
'zh-CN': '联系销售'
},
// LearningEventsSection
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
'learning.events.description': {
en: 'Check out our upcoming live streams and community meetings. Were always open to your questions, ideas, and conversations.',
'zh-CN':
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
},
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
// GalleryHeroSection
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
'gallery.heroTitle.before': {
@@ -1543,13 +1471,9 @@ const translations = {
},
'about.hero.body': {
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
'zh-CN':
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
},
'about.hero.cta': {
en: 'SEE OPEN ROLES',
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
},
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
// AboutStorySection
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
@@ -1819,7 +1743,6 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
@@ -4813,8 +4736,6 @@ const translations = {
type TranslationKey = keyof typeof translations
type LocalizedText = Record<Locale, string>
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
@@ -4825,4 +4746,4 @@ export function hasKey(key: string): boolean {
return key in translations
}
export type { Locale, LocalizedText, TranslationKey }
export type { Locale, TranslationKey }

View File

@@ -1,27 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../components/learning/TutorialsSection.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
// import EventsSection from '../components/common/EventsSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
const routes = getRoutes('en')
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -1,27 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -1,115 +0,0 @@
{
"id": "test-missing-model-promoted-widget",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -54,7 +54,6 @@ export const TestIds = {
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
errorGroupDisplayMessage: 'error-group-display-message',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
@@ -69,6 +68,11 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -1,4 +1,4 @@
import type { Locator, Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
@@ -91,21 +91,3 @@ export async function measureSelectionBounds(
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}
export async function intersection(a: Locator, b: Locator) {
const aBounds = await a.boundingBox()
const bBounds = await b.boundingBox()
if (!aBounds || !bBounds) return undefined
const y = Math.max(aBounds.y, bBounds.y)
const x = Math.max(aBounds.x, bBounds.x)
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
if (y > bot || x > right) return undefined
const width = right - x
const height = bot - y
return { x, y, width, height }
}

View File

@@ -1,63 +0,0 @@
import { expect } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
type Load3dImageInput = {
image: string
mask: string
normal: string
recording: string
}
type PromptBody = {
prompt?: Record<
string,
{ class_type?: string; inputs?: Record<string, unknown> }
>
}
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
const prompt = (body as PromptBody).prompt ?? {}
const node = prompt[nodeId]
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
const input = node!.inputs!.image as Load3dImageInput
expect(typeof input.image).toBe('string')
expect(typeof input.recording).toBe('string')
return input
}
test.describe('Load3D serialize cache', () => {
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
comfyPage,
load3d
}) => {
const exec = new ExecutionHelper(comfyPage)
let firstBody: unknown
await exec.run({
onPromptRequest: (body) => {
firstBody = body
}
})
const firstInput = getLoad3dImageInput(firstBody, '1')
expect(firstInput.recording).toBe('')
await load3d.recordingButton.click()
await expect(load3d.stopRecordingButton).toBeVisible()
let secondBody: unknown
await exec.run({
onPromptRequest: (body) => {
secondBody = body
}
})
const secondInput = getLoad3dImageInput(secondBody, '1')
expect(
secondInput.image,
'after starting a recording, the next queue must re-capture ' +
'(image filename must change) so the recording is not dropped'
).not.toBe(firstInput.image)
})
})

View File

@@ -11,7 +11,6 @@ import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
@@ -39,9 +38,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await comfyPage.setup()
})
test('Should keep execution errors matching the search query', async ({
test('Should filter execution errors by search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('Execution failed')
await searchInput.fill('nonexistent_query_xyz_12345')
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toHaveCount(0)
})
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
).toBeVisible()
})
test('Should show runtime error log in the execution error group', async ({
test('Should show error message in runtime error panel', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText('Error log')
await expect(runtimePanel).toContainText(/\S/)
})
})

View File

@@ -5,12 +5,35 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
@@ -23,24 +46,14 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const overlay = getErrorOverlay(comfyPage)
await expect(overlay).toBeVisible()
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toContainText(/Load Image/)
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await expect(missingMediaGroup).toBeVisible()
await expect(
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
})
test('Shows correct number of missing media rows', async ({
@@ -54,7 +67,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows missing item label and locate action', async ({
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -62,15 +75,32 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
})
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -78,25 +108,63 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
await getMediaRow(comfyPage).getByRole('button').first().click()
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
})
})

View File

@@ -25,13 +25,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeVisible()
})
test('Should display model name with referencing node count', async ({

View File

@@ -23,13 +23,9 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
})
test('Should expand pack group to reveal node type names', async ({

View File

@@ -369,62 +369,6 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
await expect(missingModelGroup).toBeHidden()
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,30 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Textarea widget font size',
{ tag: ['@widget', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
})
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
comfyPage
}) => {
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
await expect(textarea).toBeVisible()
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('14px')
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('22px')
})
}
)

View File

@@ -180,44 +180,4 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
wstest(
'requests lightweight thumbnail URLs for grid cells',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
const gridImages = node.imageGrid.locator('img')
await test.step('Inject a multi-image grid', async () => {
const images = Array.from({ length: 4 }, (_, index) => ({
filename: `grid-${index}.png`,
subfolder: '',
type: 'output'
}))
execution.executed('', '1', { images })
await expect(gridImages).toHaveCount(4)
})
// FE-741: small on-node grid cells must request a server re-encoded
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
// downloading the full-resolution image, while still pointing at the
// real `/api/view` URL for that output. Verifies the full path: WS
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
// rendered grid `<img>`.
for (const cell of await gridImages.all()) {
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
}
}
)
})

View File

@@ -54,35 +54,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
})
}
const advancedButtonOverflowPx = 24
const holdPointCanvasInsetPx = 8
const getAdvancedInputsButton = (node: Locator) =>
node.getByTestId('advanced-inputs-button')
const moveAdvancedButtonRightEdgePastCanvas = async (
comfyPage: ComfyPage,
button: Locator,
overflow: number
) => {
const box = await button.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!box) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const scale = await comfyPage.canvasOps.getScale()
const deltaX = canvasBox.x + canvasBox.width + overflow - box.x - box.width
await comfyPage.page.evaluate(
({ deltaX, scale }) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] += deltaX / scale
canvas.setDirty(true, true)
},
{ deltaX, scale }
)
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -152,7 +123,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = getAdvancedInputsButton(node)
const showButton = node.getByText('Show advanced inputs')
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
@@ -172,83 +143,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(beforePos, afterPos)
})
test(
'should not pan while holding the Advanced button without dragging',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = getAdvancedInputsButton(node)
await expect(showButton).toBeVisible()
await moveAdvancedButtonRightEdgePastCanvas(
comfyPage,
showButton,
advancedButtonOverflowPx
)
const buttonBox = await showButton.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!buttonBox) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const canvasRight = canvasBox.x + canvasBox.width
const buttonRight = buttonBox.x + buttonBox.width
expect(
buttonRight,
'Advanced button should extend past the canvas right edge'
).toBeGreaterThan(canvasRight)
const holdPoint = {
x: canvasRight - holdPointCanvasInsetPx,
y: buttonBox.y + buttonBox.height / 2
}
expect(
holdPoint.x,
'Hold point should stay inside the visible part of the Advanced button'
).toBeGreaterThanOrEqual(buttonBox.x)
expect(
holdPoint.x,
'Hold point should stay inside the visible canvas'
).toBeLessThanOrEqual(canvasRight)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeGreaterThanOrEqual(buttonBox.y)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeLessThanOrEqual(buttonBox.y + buttonBox.height)
const beforeOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(holdPoint.x, holdPoint.y)
await comfyPage.page.mouse.down()
try {
await comfyPage.idleFrames(8)
} finally {
await comfyPage.page.mouse.up()
}
const afterOffset = await comfyPage.canvasOps.getOffset()
expect(afterOffset[0]).toBeCloseTo(beforeOffset[0], 3)
expect(afterOffset[1]).toBeCloseTo(beforeOffset[1], 3)
}
)
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -6,7 +6,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
const FLOAT_SOURCE_POSITION_LEFT_OF_NODE = { x: 100, y: 200 }
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -33,20 +32,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
return getNode(comfyPage).locator('.lg-node-widget')
}
async function getWidgetIndex(comfyPage: ComfyPage, widgetName: string) {
const index = await comfyPage.page.evaluate((name) => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return node?.widgets?.findIndex((widget) => widget.name === name) ?? -1
}, widgetName)
expect(
index,
`${widgetName} widget should exist on ModelSamplingFlux`
).toBeGreaterThanOrEqual(0)
return index
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
@@ -87,47 +72,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
})
test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const maxShiftWidget = node.getByLabel('max_shift', { exact: true })
const baseShiftWidget = node.getByLabel('base_shift', { exact: true })
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeVisible()
const primitive = await comfyPage.nodeOps.addNode(
'PrimitiveFloat',
{},
FLOAT_SOURCE_POSITION_LEFT_OF_NODE
)
const [target] =
await comfyPage.nodeOps.getNodeRefsByType('ModelSamplingFlux')
const maxShiftIndex = await getWidgetIndex(comfyPage, 'max_shift')
await primitive.connectWidget(0, target, maxShiftIndex)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return (
node?.inputs.find((input) => input.widget?.name === 'max_shift')
?.link ?? null
)
})
)
.not.toBeNull()
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeHidden()
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {

View File

@@ -1,12 +1,10 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
import type { Locator } from '@playwright/test'
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
async function openSamplerDropdown(comfyPage: ComfyPage) {
@@ -280,31 +278,4 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
.getByRole('combobox', { name: 'scheduler', exact: true })
await expect(schedulerComboAfterReload).toContainText('karras')
})
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const nodeName = 'Resize Image/Mask'
await comfyPage.searchBoxV2.addNode(nodeName, {
position: { x: 200, y: 630 }
})
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
await node.select()
await expect(comfyPage.selectionToolbox).toBeVisible()
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
await combo.click()
const dropdown = comfyPage.page.getByTestId(
TestIds.widgets.selectDefaultViewport
)
await expect(dropdown).toBeVisible()
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
const cX = bounds.x + bounds.width / 2
const cY = bounds.y + bounds.height / 2
const dropdownBounds = (await dropdown.boundingBox())!
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
await dropdown.click({ position, trial: true })
})
})

View File

@@ -121,7 +121,6 @@
--comfy-topbar-height: 2.5rem;
--workflow-tabs-height: 2.375rem;
--comfy-input-bg: #222;
--comfy-textarea-font-size: 10px;
--input-text: #ddd;
--descrip-text: #999;
--drag-text: #ccc;

View File

@@ -28,13 +28,15 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelAssetSeedData,
CancelAssetSeedResponse,
CancelAssetSeedResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
@@ -57,11 +59,14 @@ export type {
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClientOptions,
CreateAssetData,
CreateAssetDownloadData,
CreateAssetDownloadError,
CreateAssetDownloadErrors,
CreateAssetDownloadResponse,
CreateAssetDownloadResponses,
CreateAssetError,
CreateAssetErrors,
CreateAssetExportData,
CreateAssetExportError,
CreateAssetExportErrors,
@@ -72,6 +77,8 @@ export type {
CreateAssetFromHashErrors,
CreateAssetFromHashResponse,
CreateAssetFromHashResponses,
CreateAssetResponse,
CreateAssetResponses,
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
@@ -208,6 +215,8 @@ export type {
ForkWorkflowRequest,
ForkWorkflowResponse,
ForkWorkflowResponses,
FreeMemoryData,
FreeMemoryResponses,
GetAllSettingsData,
GetAllSettingsError,
GetAllSettingsErrors,
@@ -221,6 +230,9 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
GetAssetTagHistogramData,
GetAssetTagHistogramError,
GetAssetTagHistogramErrors,
@@ -259,6 +271,9 @@ export type {
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetEmbeddingsData,
GetEmbeddingsResponse,
GetEmbeddingsResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
@@ -305,6 +320,18 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetI18nData,
GetI18nResponse,
GetI18nResponses,
GetInternalFolderPathsData,
GetInternalFolderPathsResponse,
GetInternalFolderPathsResponses,
GetInternalLogsData,
GetInternalLogsRawData,
GetInternalLogsRawResponse,
GetInternalLogsRawResponses,
GetInternalLogsResponse,
GetInternalLogsResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -356,10 +383,7 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -389,8 +413,26 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceByPathData,
GetOAuthProtectedResourceByPathError,
GetOAuthProtectedResourceByPathErrors,
GetOAuthProtectedResourceByPathResponse,
GetOAuthProtectedResourceByPathResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -427,11 +469,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -447,6 +489,7 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -534,6 +577,11 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InsertDynamicConfigData,
InsertDynamicConfigError,
InsertDynamicConfigErrors,
InsertDynamicConfigResponse,
InsertDynamicConfigResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
@@ -642,6 +690,17 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -663,6 +722,21 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -687,6 +761,9 @@ export type {
PromptInfo,
PromptRequest,
PromptResponse,
PruneAssetsData,
PruneAssetsResponse,
PruneAssetsResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
@@ -732,6 +809,9 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
SeedAssetsResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -751,6 +831,8 @@ export type {
SubscribeResponse,
SubscribeResponse2,
SubscribeResponses,
SubscribeToLogsData,
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
@@ -771,11 +853,6 @@ export type {
UpdateAssetErrors,
UpdateAssetResponse,
UpdateAssetResponses,
UpdateAssetTagsData,
UpdateAssetTagsError,
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
@@ -799,11 +876,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
@@ -821,11 +898,6 @@ export type {
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,
UploadAssetData,
UploadAssetError,
UploadAssetErrors,
UploadAssetResponse,
UploadAssetResponses,
UploadImageData,
UploadImageError,
UploadImageErrors,

File diff suppressed because it is too large Load Diff

View File

@@ -879,6 +879,155 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']).optional(),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -940,6 +1089,7 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -960,6 +1110,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -971,6 +1122,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1353,7 +1505,8 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1364,14 +1517,14 @@ export const zAsset = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
@@ -1385,7 +1538,8 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1394,13 +1548,15 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
tags: z.array(z.string()).optional(),
mime_type: z.string().optional(),
user_metadata: z.record(z.unknown()).optional(),
job_id: z.string().uuid().nullish(),
updated_at: z.string().datetime()
})
@@ -1753,21 +1909,6 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
})
/**
* Response returned after successfully queuing a workflow prompt.
*/
@@ -1796,7 +1937,8 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1807,13 +1949,13 @@ export const zAssetWritable = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
@@ -1827,7 +1969,8 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1961,21 +2104,6 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2132,9 +2260,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
hash: z.string().optional(),
after: z.string().optional()
})
.optional()
})
@@ -2144,22 +2272,28 @@ export const zListAssetsData = z.object({
*/
export const zListAssetsResponse2 = zListAssetsResponse
export const zUploadAssetData = z.object({
export const zCreateAssetData = z.object({
body: z.object({
url: z.string().url(),
name: z.string(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_id: z.string().uuid().optional()
file: z.string(),
hash: z
.string()
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
.optional(),
tags: z.string().optional(),
id: z.string().uuid().optional(),
preview_id: z.string().uuid().optional(),
name: z.string().optional(),
mime_type: z.string().optional(),
user_metadata: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Asset already exists (returned existing asset)
* Asset created successfully
*/
export const zUploadAssetResponse = zAssetCreated
export const zCreateAssetResponse = zAssetCreated
export const zCreateAssetFromHashData = z.object({
body: z.object({
@@ -2174,7 +2308,7 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference already exists (returned existing)
* Asset reference created successfully
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2214,7 +2348,8 @@ export const zCreateAssetExportData = z.object({
naming_strategy: z
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
include_previews: z.boolean().optional().default(false)
}),
path: z.never().optional(),
query: z.never().optional()
@@ -2247,7 +2382,7 @@ export const zDeleteAssetData = z.object({
})
/**
* Asset deleted successfully
* Asset record deleted successfully
*/
export const zDeleteAssetResponse = z.void()
@@ -2312,22 +2447,6 @@ export const zAddAssetTagsData = z.object({
*/
export const zAddAssetTagsResponse = zTagsModificationResponse
export const zUpdateAssetTagsData = z.object({
body: z.object({
add: z.array(z.string()).optional(),
remove: z.array(z.string()).optional()
}),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* Tags updated successfully
*/
export const zUpdateAssetTagsResponse = zTagsModificationResponse
export const zListTagsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2509,10 +2628,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByKeyData = z.object({
export const zGetSettingByIdData = z.object({
body: z.never().optional(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2520,14 +2639,14 @@ export const zGetSettingByKeyData = z.object({
/**
* Setting value response
*/
export const zGetSettingByKeyResponse = z.object({
export const zGetSettingByIdResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByKeyData = z.object({
export const zUpdateSettingByIdData = z.object({
body: z.unknown(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2535,7 +2654,7 @@ export const zUpdateSettingByKeyData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByKeyResponse = z.object({
export const zUpdateSettingByIdResponse = z.object({
value: z.unknown().optional()
})
@@ -2691,21 +2810,7 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
type: z.string().optional()
})
export const zGetLogsData = z.object({
@@ -2774,6 +2879,115 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthProtectedResourceByPathData = z.object({
body: z.never().optional(),
path: z.object({
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
}),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceByPathResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3078,6 +3292,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Config inserted successfully
*/
export const zInsertDynamicConfigResponse = z.object({
id: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
message: z.string().optional()
})
export const zSyncApiKeyData = z.object({
body: zSyncApiKeyRequest,
path: z.never().optional(),
@@ -3671,12 +3907,6 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3757,6 +3987,16 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3832,3 +4072,150 @@ export const zGetLegacyViewMetadataData = z.object({
}),
query: z.never().optional()
})
export const zGetEmbeddingsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Embedding names
*/
export const zGetEmbeddingsResponse = z.array(z.string())
export const zFreeMemoryData = z.object({
body: z
.object({
unload_models: z.boolean().optional(),
free_memory: z.boolean().optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetI18nData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Nested map of locale to translation key-value pairs
*/
export const zGetI18nResponse = z.record(z.unknown())
export const zGetInternalFolderPathsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Map of folder type name to list of path entries
*/
export const zGetInternalFolderPathsResponse = z.record(
z.array(z.array(z.string()))
)
export const zGetInternalLogsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Log text
*/
export const zGetInternalLogsResponse = z.string()
export const zGetInternalLogsRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Structured log data
*/
export const zGetInternalLogsRawResponse = z.object({
entries: z
.array(
z.object({
t: z.number().optional(),
m: z.string().optional()
})
)
.optional(),
size: z
.object({
cols: z.number().int().optional(),
rows: z.number().int().optional()
})
.optional()
})
export const zSubscribeToLogsData = z.object({
body: z.object({
clientId: z.string(),
enabled: z.boolean()
}),
path: z.never().optional(),
query: z.never().optional()
})
export const zPruneAssetsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Prune result
*/
export const zPruneAssetsResponse = z.object({
status: z.string().optional(),
marked: z.number().int().optional()
})
export const zSeedAssetsData = z.object({
body: z
.object({
roots: z.array(z.string()).optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Seed started
*/
export const zSeedAssetsResponse = z.object({
status: z.string().optional()
})
export const zGetAssetSeedStatusData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan progress details (files scanned, total, status, etc.)
*/
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
export const zCancelAssetSeedData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan cancelled
*/
export const zCancelAssetSeedResponse = z.object({
status: z.string().optional()
})

View File

@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -49,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const resolvedInputs = useResolvedSelectedInputs()

View File

@@ -20,7 +20,6 @@
<InfoButton v-if="canOpenNodeInfo" />
<ColorPickerButton v-if="showColorPicker" />
<ArrangeButton v-if="showArrange" />
<FrameNodes v-if="showFrameNodes" />
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
<ConfigureSubgraph v-if="showSubgraphButtons" />
@@ -50,7 +49,6 @@
import Panel from 'primevue/panel'
import { computed, ref } from 'vue'
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
@@ -112,7 +110,6 @@ const {
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)
const showArrange = computed(() => hasMultipleSelection.value)
const showFrameNodes = computed(() => hasMultipleSelection.value)
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
@@ -131,7 +128,6 @@ const showAnyPrimaryActions = computed(
() =>
showColorPicker.value ||
showConvertToSubgraph.value ||
showArrange.value ||
showFrameNodes.value ||
showSubgraphButtons.value
)

View File

@@ -1,115 +0,0 @@
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<Button
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
variant="muted-textonly"
:aria-label="t('g.arrange')"
>
<div class="flex items-center gap-1 px-0">
<i class="icon-[lucide--layout-grid]" />
<i class="icon-[lucide--chevron-down]" />
</div>
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
:side-offset="5"
:collision-padding="10"
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<div
v-if="activeLayout"
class="flex w-32 flex-row items-center px-2 py-1"
>
<Slider
:model-value="[gap]"
:min="MIN_ARRANGE_GAP"
:max="MAX_ARRANGE_GAP"
:step="1"
:aria-label="t('g.arrangeSpacing')"
@update:model-value="onSliderUpdate"
@value-commit="onSliderCommit"
/>
</div>
<div v-else class="flex flex-row gap-1">
<Button
v-tooltip.top="{
value: t('g.arrangeVertically'),
showDelay: 1000
}"
variant="muted-textonly"
:aria-label="t('g.arrangeVertically')"
@click="start('vertical')"
>
<i class="icon-[lucide--stretch-horizontal]" />
</Button>
<Button
v-tooltip.top="{
value: t('g.arrangeHorizontally'),
showDelay: 1000
}"
variant="muted-textonly"
:aria-label="t('g.arrangeHorizontally')"
@click="start('horizontal')"
>
<i class="icon-[lucide--stretch-vertical]" />
</Button>
<Button
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
variant="muted-textonly"
:aria-label="t('g.arrangeAsGrid')"
@click="start('grid')"
>
<i class="icon-[lucide--grid-3x3]" />
</Button>
</div>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<script setup lang="ts">
import {
PopoverArrow,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import {
MAX_ARRANGE_GAP,
MIN_ARRANGE_GAP
} from '@/composables/graph/useArrangeNodes'
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
const { t } = useI18n()
const { activeLayout, gap, start, previewGap, commitGap, reset } =
useArrangeSession()
const isOpen = ref(false)
watch(isOpen, (open) => {
if (!open) reset()
})
const firstValue = (value: number[] | undefined): number | undefined =>
value?.[0]
const onSliderUpdate = (value: number[] | undefined) => {
const next = firstValue(value)
if (next !== undefined) previewGap(next)
}
const onSliderCommit = (value: number[]) => {
const next = firstValue(value)
if (next !== undefined) commitGap(next)
}
</script>

View File

@@ -2,12 +2,20 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
@@ -97,36 +105,58 @@ const promptOnlyCard: ErrorCardData = {
]
}
export const SingleValidationError: Story = {
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
args: {
card: singleErrorCard
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard
card: multipleErrorsCard,
showNodeIdBadge: true
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard
card: runtimeErrorCard,
showNodeIdBadge: true
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard
card: promptOnlyCard,
showNodeIdBadge: false
}
}

View File

@@ -71,7 +71,6 @@ describe('ErrorNodeCard.vue', () => {
en: {
g: {
copy: 'Copy',
details: 'Details',
findIssues: 'Find Issues',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
@@ -79,7 +78,6 @@ describe('ErrorNodeCard.vue', () => {
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:
'Report this error and we\u0027ll help you resolve it'
@@ -98,9 +96,8 @@ describe('ErrorNodeCard.vue', () => {
) {
const user = userEvent.setup()
const onCopyToClipboard = vi.fn()
const onLocateNode = vi.fn()
render(ErrorNodeCard, {
props: { card, onCopyToClipboard, onLocateNode },
props: { card, onCopyToClipboard },
global: {
plugins: [
PrimeVue,
@@ -134,20 +131,14 @@ describe('ErrorNodeCard.vue', () => {
})
],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
}
}
}
})
return { user, onCopyToClipboard, onLocateNode }
}
async function toggleRuntimeDetails(
user: ReturnType<typeof userEvent.setup>
) {
await user.click(screen.getByRole('button', { name: /Details/ }))
return { user, onCopyToClipboard }
}
let cardIdCounter = 0
@@ -169,67 +160,40 @@ describe('ErrorNodeCard.vue', () => {
}
}
function makePromptErrorCard(): ErrorCardData {
function makeValidationErrorCard(): ErrorCardData {
return {
id: '__prompt__',
title: 'Prompt has no outputs',
id: `node-${++cardIdCounter}`,
title: 'CLIPTextEncode',
nodeId: '6',
nodeTitle: 'CLIP Text Encode',
errors: [
{
message: 'Server Error: No outputs',
details: 'Error details',
displayMessage:
'The workflow does not contain any output nodes to produce a result.'
message: 'Required input is missing',
details: 'Input: text'
}
]
}
}
it('shows runtime details by default and can collapse them', async () => {
it('displays enriched report for runtime errors on mount', async () => {
const reportText =
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
const { user } = renderCard(makeRuntimeErrorCard())
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
})
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
const detailsButton = screen.getByRole('button', { name: /Details/ })
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
expect(detailsButton).toHaveAttribute(
'aria-controls',
detailsRegion.getAttribute('id')
)
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(screen.getByText(/System Information/)).toBeInTheDocument()
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
await toggleRuntimeDetails(user)
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Find on GitHub/ })
).not.toBeInTheDocument()
})
it('locates the node when the runtime node title is clicked', async () => {
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
await user.click(screen.getByRole('button', { name: 'KSampler' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not generate report for non-runtime errors', async () => {
renderCard(makePromptErrorCard())
renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Error details')).toBeInTheDocument()
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
expect(mockGetLogs).not.toHaveBeenCalled()
@@ -237,15 +201,15 @@ describe('ErrorNodeCard.vue', () => {
})
it('displays original details for non-runtime errors', async () => {
renderCard(makePromptErrorCard())
renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Error details')).toBeInTheDocument()
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('hides grouped catalog copy and shows the item label as a list item', async () => {
it('displays catalog-resolved copy when available', async () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
@@ -265,17 +229,17 @@ describe('ErrorNodeCard.vue', () => {
})
await waitFor(() => {
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
expect(screen.getByText('Missing connection')).toBeInTheDocument()
})
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
expect(
screen.queryByText(
'Required input slots have no connection feeding them.'
)
).not.toBeInTheDocument()
screen.getByText('Required input slots have no connection feeding them.')
).toBeInTheDocument()
expect(
screen.queryByText('KSampler is missing a required input: model')
screen.getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
expect(
screen.queryByText('Required input is missing')
).not.toBeInTheDocument()
})
@@ -286,9 +250,8 @@ describe('ErrorNodeCard.vue', () => {
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
})
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Copy/ }))
@@ -298,6 +261,21 @@ describe('ErrorNodeCard.vue', () => {
)
})
it('copies original details when copy button is clicked for validation error', async () => {
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
'Required input is missing\n\nInput: text'
)
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
@@ -322,9 +300,8 @@ describe('ErrorNodeCard.vue', () => {
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
@@ -333,7 +310,9 @@ describe('ErrorNodeCard.vue', () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
@@ -356,7 +335,9 @@ describe('ErrorNodeCard.vue', () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(
screen.getByRole('button', { name: /Get Help/ })
).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /Get Help/ }))
@@ -417,7 +398,9 @@ describe('ErrorNodeCard.vue', () => {
}
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})

View File

@@ -1,19 +1,18 @@
<template>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
>
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ card.nodeTitle || card.title }}
</button>
<span
v-else-if="card.nodeTitle || card.title"
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ card.nodeId }}
</span>
<span
v-if="card.nodeTitle || card.title"
class="flex-1 truncate text-sm font-medium text-muted-foreground"
>
{{ card.nodeTitle || card.title }}
@@ -28,24 +27,6 @@
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="t('g.details')"
:aria-controls="runtimeDetailsControlIds || undefined"
:aria-expanded="runtimeDetailsExpanded"
@click.stop="toggleRuntimeDetails"
>
<i class="icon-[lucide--monitor-x] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
@@ -58,143 +39,120 @@
</div>
</div>
<!-- Multiple Errors within one Card -->
<div
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
>
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex min-h-0 flex-col gap-3"
:class="
cn(
'flex min-h-0 flex-col gap-3',
fullHeight && error.isRuntimeError && 'flex-1'
)
"
>
<!-- Human-friendly category/title when resolved by the error catalog. -->
<p
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
v-if="error.displayTitle"
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
>
{{ getInlineMessage(error) }}
{{ error.displayTitle }}
</p>
<ul
v-if="getInlineItemLabel(error)"
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
<!-- Error Message -->
<p
v-if="getDisplayMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ getInlineItemLabel(error) }}
</button>
<span v-else>
{{ getInlineItemLabel(error) }}
</span>
</li>
</ul>
{{ getDisplayMessage(error) }}
</p>
<!-- Traceback / Details (enriched with full report for runtime errors) -->
<div
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
v-if="displayedDetailsMap[idx]"
:class="
cn(
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
'max-h-[6lh]'
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
error.isRuntimeError
? fullHeight
? 'min-h-0 flex-1'
: 'max-h-[15lh]'
: 'max-h-[6lh]'
)
"
>
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
{{ displayedDetailsMap[idx] }}
</p>
</div>
<TransitionCollapse>
<div
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-3"
>
<div
v-if="getInlineDetails(error, idx)"
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<div
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
>
<span
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
<div class="mx-3 flex items-center justify-between gap-2 py-2">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-3.5" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-3.5" />
{{ t('g.findOnGithub') }}
</Button>
</div>
</div>
{{ t('g.findOnGithub') }}
<i class="icon-[lucide--github] size-3.5" />
</Button>
<Button
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-3.5" />
</Button>
</div>
</TransitionCollapse>
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="secondary"
size="sm"
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
@click="handleGetHelp"
>
{{ t('g.getHelpAction') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const { card, compact = false } = defineProps<{
const {
card,
showNodeIdBadge = false,
compact = false,
fullHeight = false
} = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
/** Allow runtime error details to fill available height (used in dedicated panel) */
fullHeight?: boolean
}>()
const emit = defineEmits<{
@@ -206,23 +164,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const isRuntimeDisclosureExpanded = computed(
() => compact || runtimeDetailsExpanded.value
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
.filter(Boolean)
.join(' ')
)
function toggleRuntimeDetails() {
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
}
function handleLocateNode() {
if (card.nodeId) {
@@ -238,7 +179,7 @@ function handleEnterSubgraph() {
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getCopyMessage(card.errors[idx])
const message = getDisplayMessage(card.errors[idx])
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
@@ -246,26 +187,7 @@ function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
}
function getCopyMessage(error: ErrorItem | undefined) {
function getDisplayMessage(error: ErrorItem | undefined) {
return error?.displayMessage ?? error?.message
}
function getInlineMessage(error: ErrorItem | undefined) {
if (!error || error.displayMessage) return undefined
return error.message
}
function getInlineItemLabel(error: ErrorItem | undefined) {
if (!error || error.isRuntimeError) return undefined
return error.displayItemLabel
}
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
if (getInlineItemLabel(error)) return undefined
return displayedDetailsMap.value[idx]
}
function getRuntimeDetailsId(idx: number) {
return `${card.id}-runtime-details-${idx}`
}
</script>

View File

@@ -90,6 +90,8 @@ const i18n = createI18n({
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
ossManagerDisabledHint:
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
applyChanges: 'Apply Changes'
@@ -157,6 +159,21 @@ describe('MissingNodeCard', () => {
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
renderCard()
expect(
screen.getByText('Unsupported node packs detected.')
).toBeInTheDocument()
})
it('renders OSS message when isCloud is false', () => {
renderCard()
expect(
screen.getByText('Missing node packs detected. Install them.')
).toBeInTheDocument()
})
it('renders correct number of MissingPackGroupRow components', () => {
renderCard({ missingPackGroups: makePackGroups(3) })
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)

View File

@@ -36,6 +36,19 @@
</div>
</div>
</div>
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p
class="m-0 text-sm/relaxed text-muted-foreground"
:class="showManagerHint ? 'pb-3' : 'pb-5'"
>
{{
isCloud
? t('rightSidePanel.missingNodePacks.cloudMessage')
: t('rightSidePanel.missingNodePacks.ossMessage')
}}
</p>
<!-- Manager disabled hint: shown on OSS when manager is not active -->
<i18n-t
v-if="showManagerHint"

View File

@@ -1,17 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
@@ -41,28 +36,10 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
}),
isModelDownloadable: vi.fn(() => true),
toBrowsableUrl: vi.fn((url: string) => url)
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
i18n = createI18n({
legacy: false,
locale: 'en',
@@ -70,30 +47,16 @@ describe('TabErrors.vue', () => {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy',
details: 'Details',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
copy: 'Copy'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorHelp: 'Error help',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues',
getHelpTooltip: 'Get help',
info: 'Info',
infoFor: 'Info for {item}',
locateNode: 'Locate node',
locateNodeFor: 'Locate {item}',
missingModels: {
missingModelsTitle: 'Missing Models',
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs'
}
}
}
@@ -157,111 +120,29 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})
it('renders node validation errors grouped by catalog copy', async () => {
it('renders node validation errors grouped by class_type', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'1': 'KSampler',
'2': 'CLIP Text Encode'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
const { user } = renderComponent({
renderComponent({
executionError: {
lastNodeErrors: {
'2': {
'6': {
class_type: 'CLIPTextEncode',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: {
input_name: 'clip'
}
}
]
},
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: {
input_name: 'positive'
}
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: {
input_name: 'model'
}
}
{ message: 'Required input is missing', details: 'Input: text' }
]
}
}
}
})
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(screen.getByText('(3)')).toBeInTheDocument()
expect(
screen.getAllByText(
'Required input slots have no connection feeding them.'
)
).toHaveLength(1)
expect(screen.queryByText('#1')).not.toBeInTheDocument()
expect(screen.queryByText('#2')).not.toBeInTheDocument()
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
expect(screen.queryByText('CLIP Text Encode')).not.toBeInTheDocument()
const itemRows = screen.getAllByRole('listitem')
expect(itemRows).toHaveLength(3)
expect(itemRows[0]).toHaveTextContent('KSampler - model')
expect(itemRows[1]).toHaveTextContent('KSampler - positive')
expect(itemRows[2]).toHaveTextContent('CLIP Text Encode - clip')
const infoButton = within(itemRows[1]).getByRole('button', {
name: 'Info for KSampler - positive'
})
await user.click(infoButton)
const itemDetail = screen.getByText(
'KSampler is missing a required input: positive'
)
expect(infoButton).toHaveAttribute(
'aria-controls',
itemDetail.getAttribute('id')
)
const labelLocateButton = within(itemRows[1]).getByRole('button', {
name: 'KSampler - positive'
})
await user.click(labelLocateButton)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('1')
const iconLocateButton = within(itemRows[2]).getByRole('button', {
name: 'Locate CLIP Text Encode - clip'
})
await user.click(iconLocateButton)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('2')
expect(
screen.queryByText('Required input is missing')
).not.toBeInTheDocument()
expect(screen.queryByText('Input: model')).not.toBeInTheDocument()
expect(screen.queryByText('Input: positive')).not.toBeInTheDocument()
expect(screen.queryByText('Input: clip')).not.toBeInTheDocument()
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
expect(screen.getByText('#6')).toBeInTheDocument()
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
})
it('renders runtime execution errors from WebSocket', async () => {
@@ -270,7 +151,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const { user } = renderComponent({
renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -285,16 +166,12 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Details' }))
expect(screen.queryByText(/Line 1/)).not.toBeInTheDocument()
})
it('filters errors based on search query', async () => {
@@ -329,7 +206,7 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
})
it('calls copyToClipboard when a runtime error copy button is clicked', async () => {
it('calls copyToClipboard when copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
@@ -337,26 +214,21 @@ describe('TabErrors.vue', () => {
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
}
}
})
await user.click(screen.getByTestId('error-card-copy'))
expect(mockCopy).toHaveBeenCalledWith(
'Node threw an error during execution.\n\nTest details'
)
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
it('renders a single runtime error in the normal execution group', async () => {
it('renders single runtime error outside accordion in full-height panel', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
@@ -378,11 +250,7 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
within(screen.getByTestId('error-group-execution')).getByTestId(
'runtime-error-panel'
)
).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})
@@ -414,129 +282,6 @@ describe('TabErrors.vue', () => {
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
})
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
})
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.getByText('Download a model, or open the node to replace it.')
).toBeInTheDocument()
})
it('renders missing media display message below the section title', () => {
const missingMedia = {
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'portrait.png',
isMissing: true
} satisfies MissingMediaCandidate
renderComponent({
missingMedia: {
missingMediaCandidates: [missingMedia]
}
})
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
expect(
screen.getByText('A required media input has no file selected.')
).toBeInTheDocument()
})
it('renders one missing media item per referencing node and locates the selected node', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'3': 'First Loader',
'4': 'Second Loader'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',
nodeId: '1',
isReplaceable: true,
replacement: {
old_node_id: 'OldSampler',
new_node_id: 'KSampler',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
} satisfies MissingNodeType
renderComponent({
missingNodesError: {
missingNodesError: {
message: 'Missing Node Packs',
nodeTypes: [swapNode]
}
}
})
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: '1',

View File

@@ -11,7 +11,32 @@
/>
</div>
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<!-- Runtime error: full-height panel outside accordion -->
<div
v-if="singleRuntimeErrorCard"
data-testid="runtime-error-panel"
aria-live="polite"
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
>
<div
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
>
{{ singleRuntimeErrorGroup?.displayTitle }}
</div>
<ErrorNodeCard
:key="singleRuntimeErrorCard.id"
:card="singleRuntimeErrorCard"
:show-node-id-badge="showNodeIdBadge"
full-height
class="min-h-0 flex-1"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Scrollable content (non-runtime or mixed errors) -->
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -45,13 +70,10 @@
{{ group.displayTitle }}
</span>
<span
v-if="
group.type === 'execution' &&
getExecutionGroupCount(group) > 1
"
v-if="group.type === 'execution' && group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ getExecutionGroupCount(group) }})
({{ group.cards.length }})
</span>
</span>
<Button
@@ -132,18 +154,6 @@
</div>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
<p
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
@@ -156,7 +166,7 @@
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
v-else-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@@ -164,79 +174,12 @@
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-4">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@@ -246,7 +189,7 @@
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
@@ -254,8 +197,9 @@
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
v-else-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>
@@ -299,7 +243,6 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
@@ -311,7 +254,6 @@ import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
@@ -331,13 +273,6 @@ import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
nodeId: string
label: string
displayDetails?: string
}
const ErrorPanelSurveyCta =
isNightly && !isCloud && !isDesktop
? defineAsyncComponent(
@@ -360,7 +295,6 @@ const { isInstalling: isInstallingAll, installAllPacks: installAll } =
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
@@ -379,78 +313,6 @@ const showNodeIdBadge = computed(
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
group.cards.length > 0 &&
group.cards.every(
(card) =>
card.nodeId &&
card.errors.length > 0 &&
card.errors.every(
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
)
)
)
}
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function getExecutionGroupCount(group: ErrorGroup) {
if (group.type !== 'execution') return 0
if (isExecutionItemListGroup(group)) {
return group.cards.reduce((count, card) => count + card.errors.length, 0)
}
return group.cards.length
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
tabErrorGroups,
@@ -482,6 +344,20 @@ function handleMissingModelRefresh() {
void missingModelStore.refreshMissingModels()
}
const singleRuntimeErrorGroup = computed(() => {
if (filteredGroups.value.length !== 1) return null
const group = filteredGroups.value[0]
const isSoleRuntimeError =
group.type === 'execution' &&
group.cards.length === 1 &&
group.cards[0].errors.every((e) => e.isRuntimeError)
return isSoleRuntimeError ? group : null
})
const singleRuntimeErrorCard = computed(
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
)
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))

View File

@@ -23,9 +23,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const unknownValidationMessage = vi.hoisted(
() => 'A node returned a validation error ComfyUI does not recognize.'
)
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
@@ -46,18 +43,6 @@ vi.mock('@/i18n', () => {
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.unknown_validation_error.title':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.message':
unknownValidationMessage,
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
'{nodeName}',
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
'{nodeName} returned an unrecognized validation error.',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
@@ -317,24 +302,7 @@ describe('useErrorGroups', () => {
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Install missing packs to use this workflow.'
)
})
it('uses Cloud copy for missing_node group in Cloud', async () => {
mockIsCloud.value = true
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup?.displayMessage).toBe(
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
'Some nodes are missing and need to be installed'
)
})
@@ -399,7 +367,7 @@ describe('useErrorGroups', () => {
expect(swapIdx).toBeLessThan(missingIdx)
})
it('uses fallback catalog grouping for unknown node validation errors', async () => {
it('includes execution error groups from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -420,8 +388,8 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
expect(execGroups[0].displayTitle).toBe('Validation failed')
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
})
it('resolves required_input_missing item display copy', async () => {
@@ -470,55 +438,6 @@ describe('useErrorGroups', () => {
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'clip',
extra_info: {
input_name: 'clip'
}
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups).toHaveLength(1)
const [group] = execGroups
expect(group.groupKey).toBe('execution:missing_connection')
expect(group.displayTitle).toBe('Missing connection')
expect(group.cards.map((card) => card.title)).toEqual([
'KSampler',
'CLIPLoader'
])
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
})
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
@@ -780,7 +699,7 @@ describe('useErrorGroups', () => {
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique display messages from node errors', async () => {
it('collects unique error messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -800,7 +719,10 @@ describe('useErrorGroups', () => {
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toEqual([unknownValidationMessage])
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group display message', async () => {

View File

@@ -30,7 +30,6 @@ import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
@@ -44,6 +43,7 @@ import {
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -66,7 +66,6 @@ export interface SwapNodeGroup {
interface GroupEntry {
type: 'execution'
displayTitle: string
displayMessage?: string
priority: number
cards: Map<string, ErrorCardData>
}
@@ -76,14 +75,10 @@ interface ErrorSearchItem {
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableRawMessage: string
searchableRawDetails: string
searchableMessage: string
searchableDetails: string
}
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
@@ -111,21 +106,17 @@ function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
groupKey: string,
displayTitle = groupKey,
priority = 1,
displayMessage?: string
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(groupKey)
if (!entry) {
entry = {
type: 'execution',
displayTitle,
displayMessage,
priority,
cards: new Map()
}
groupsMap.set(groupKey, entry)
} else if (!entry.displayMessage && displayMessage) {
entry.displayMessage = displayMessage
}
return entry.cards
}
@@ -147,6 +138,44 @@ function createErrorCard(
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
) {
const displayTitle =
error.displayTitle ?? error.displayMessage ?? error.message
const groupKey = error.catalogId ?? displayTitle
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
group.get(card.id)?.errors.push(error)
}
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
@@ -157,7 +186,6 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
type: 'execution' as const,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
displayMessage: groupData.displayMessage,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
priority: groupData.priority
}))
@@ -181,8 +209,6 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
searchableMessage: card.errors
.map((e) =>
[e.displayTitle, e.displayMessage, e.message]
@@ -199,11 +225,9 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableRawMessage', weight: 0.3 },
{ name: 'searchableNodeId', weight: 0.2 },
{ name: 'searchableNodeTitle', weight: 0.2 },
{ name: 'searchableMessage', weight: 0.2 },
{ name: 'searchableRawDetails', weight: 0.1 },
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
@@ -309,23 +333,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
nodeId: string,
classType: string,
idPrefix: string,
error: CataloguedErrorItem,
errors: ErrorItem[],
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const cards = getOrCreateGroup(
groupsMap,
error.catalogId,
error.displayTitle ?? classType,
1,
error.displayMessage
)
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push(error)
card.errors.push(...errors)
}
function processPromptError(
@@ -349,8 +368,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap,
`prompt:${error.type}`,
groupDisplayTitle,
0,
resolvedDisplay.displayMessage
0
)
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
@@ -377,13 +395,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
)) {
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
for (const e of nodeError.errors) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
{
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => {
return {
message: e.message,
details: e.details ?? undefined,
...resolveRunErrorMessage({
@@ -391,10 +409,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
error: e,
nodeDisplayName
})
},
filterBySelection
)
}
}
}),
filterBySelection
)
}
}
@@ -410,18 +428,20 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
String(e.node_id),
e.node_type,
'exec',
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
},
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
filterBySelection
)
}
@@ -703,6 +723,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
isCloud
})
}
@@ -819,6 +840,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
mediaTypes: filteredMissingMediaGroups.value.map(
(group) => group.mediaType
),
isCloud
})
}
@@ -847,6 +871,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const executionGroups = isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
@@ -859,7 +887,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...toSortedGroups(groupsMap)
...executionGroups
]
})

View File

@@ -1,182 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
interface MockNodeSpec {
id: number | string
pos: [number, number]
size: [number, number]
title_mode?: TitleMode
}
const makeNode = (spec: MockNodeSpec): LGraphNode =>
({
id: spec.id,
pos: spec.pos,
size: spec.size,
title_mode: spec.title_mode
}) as unknown as LGraphNode
const GAP = 12
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
describe('computeArrangement', () => {
it('returns no updates when fewer than 2 nodes are selected', () => {
expect(computeArrangement([], 'vertical')).toEqual([])
expect(
computeArrangement(
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
'grid'
)
).toEqual([])
})
describe('vertical', () => {
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
const nodes = [
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
]
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
// Each node's pos.y = visualTop + its titleHeight (30).
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
// c: pos.y = 234+30 = 264
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 'b', position: { x: 10, y: 100 } },
{ nodeId: 'a', position: { x: 10, y: 172 } },
{ nodeId: 'c', position: { x: 10, y: 264 } }
])
})
it('omits the title-height contribution for NO_TITLE nodes', () => {
const nodes = [
makeNode({
id: 1,
pos: [0, 0],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
}),
makeNode({
id: 2,
pos: [0, 200],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
})
]
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
// 2: pos.y = 112.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
])
})
it('preserves heterogeneous heights when computing gaps', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
]
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
// 2: pos.y = 212+30 = 242.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
])
})
})
describe('horizontal', () => {
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
const nodes = [
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
]
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
// Sort by X: b(0), a(100), c(300)
// Lay out from (100, 50):
// b at (100, 50)
// a at (100 + 60 + 12, 50) = (172, 50)
// c at (172 + 80 + 12, 50) = (264, 50)
const result = computeArrangement(nodes, 'horizontal')
expect(result).toEqual([
{ nodeId: 'b', position: { x: 100, y: 50 } },
{ nodeId: 'a', position: { x: 172, y: 50 } },
{ nodeId: 'c', position: { x: 264, y: 50 } }
])
})
})
describe('grid', () => {
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
]
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
// pos.y = rowVisualTop + 30 (titleHeight).
const result = computeArrangement(nodes, 'grid')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 132, y: 0 } },
{ nodeId: 3, position: { x: 0, y: 102 } },
{ nodeId: 4, position: { x: 132, y: 102 } }
])
})
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
const nodes = Array.from({ length: 5 }, (_, i) =>
makeNode({
id: i + 1,
pos: [i * 50, i * 50],
size: [40, 40]
})
)
const result = computeArrangement(nodes, 'grid')
expect(result).toHaveLength(5)
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
expect(result[0].position).toEqual({ x: 0, y: 0 })
expect(result[1].position).toEqual({ x: 52, y: 0 })
expect(result[2].position).toEqual({ x: 104, y: 0 })
expect(result[3].position).toEqual({ x: 0, y: 82 })
expect(result[4].position).toEqual({ x: 52, y: 82 })
})
})
describe('anchor selection', () => {
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
const nodes = [
// min y but large x: x+y = 1000
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
// min x but large y: x+y = 1000
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
// smallest x+y: 600
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
]
const result = computeArrangement(nodes, 'vertical')
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
expect(result[0]).toEqual({
nodeId: 'minY',
position: { x: 300, y: 300 }
})
expect(result.every((u) => u.position.x === 300)).toBe(true)
})
})
})

View File

@@ -1,186 +0,0 @@
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
export const DEFAULT_ARRANGE_GAP = 12
export const MIN_ARRANGE_GAP = 0
export const MAX_ARRANGE_GAP = 48
interface NodeBox {
id: NodeId
posX: number
posY: number
visualWidth: number
visualHeight: number
titleHeight: number
}
interface ArrangeUpdate {
nodeId: NodeId
position: Point
}
const titleHeightOf = (node: LGraphNode): number => {
const mode = node.title_mode
if (mode === TitleMode.TRANSPARENT_TITLE || mode === TitleMode.NO_TITLE) {
return 0
}
return LiteGraph.NODE_TITLE_HEIGHT
}
const toBox = (node: LGraphNode): NodeBox => {
const titleHeight = titleHeightOf(node)
return {
id: node.id,
posX: node.pos[0],
posY: node.pos[1],
visualWidth: node.size[0],
visualHeight: node.size[1] + titleHeight,
titleHeight
}
}
const byTopDown = (a: NodeBox, b: NodeBox) => a.posY - b.posY || a.posX - b.posX
const byLeftRight = (a: NodeBox, b: NodeBox) =>
a.posX - b.posX || a.posY - b.posY
const findAnchor = (boxes: NodeBox[]): NodeBox =>
boxes.reduce((best, box) =>
box.posX + box.posY < best.posX + best.posY ? box : best
)
const cumulativeOffsets = (
sizes: number[],
origin: number,
gap: number
): number[] => {
const offsets: number[] = [origin]
for (let i = 1; i < sizes.length; i++) {
offsets.push(offsets[i - 1] + sizes[i - 1] + gap)
}
return offsets
}
const arrangeVertical = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byTopDown)
let visualTop = anchor.posY - anchor.titleHeight
return sorted.map((box) => {
const update: ArrangeUpdate = {
nodeId: box.id,
position: { x: anchor.posX, y: visualTop + box.titleHeight }
}
visualTop += box.visualHeight + gap
return update
})
}
const arrangeHorizontal = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byLeftRight)
const visualTop = anchor.posY - anchor.titleHeight
let cursorX = anchor.posX
return sorted.map((box) => {
const update: ArrangeUpdate = {
nodeId: box.id,
position: { x: cursorX, y: visualTop + box.titleHeight }
}
cursorX += box.visualWidth + gap
return update
})
}
const arrangeGrid = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byTopDown)
const cols = Math.ceil(Math.sqrt(sorted.length))
const rows = Math.ceil(sorted.length / cols)
const colWidths = new Array<number>(cols).fill(0)
const rowHeights = new Array<number>(rows).fill(0)
sorted.forEach((box, i) => {
const col = i % cols
const row = Math.floor(i / cols)
if (box.visualWidth > colWidths[col]) colWidths[col] = box.visualWidth
if (box.visualHeight > rowHeights[row]) rowHeights[row] = box.visualHeight
})
const colX = cumulativeOffsets(colWidths, anchor.posX, gap)
const rowVisualTop = cumulativeOffsets(
rowHeights,
anchor.posY - anchor.titleHeight,
gap
)
return sorted.map((box, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return {
nodeId: box.id,
position: {
x: colX[col],
y: rowVisualTop[row] + box.titleHeight
}
}
})
}
export function computeArrangement(
nodes: LGraphNode[],
layout: ArrangeLayout,
gap: number = DEFAULT_ARRANGE_GAP
): ArrangeUpdate[] {
if (nodes.length < 2) return []
const boxes = nodes.map(toBox)
const anchor = findAnchor(boxes)
if (layout === 'vertical') return arrangeVertical(boxes, anchor, gap)
if (layout === 'horizontal') return arrangeHorizontal(boxes, anchor, gap)
return arrangeGrid(boxes, anchor, gap)
}
interface ArrangeOptions {
gap?: number
captureUndo?: boolean
}
export function useArrangeNodes() {
const { selectedNodes, hasMultipleSelection } = useSelectionState()
const mutations = useLayoutMutations()
const workflowStore = useWorkflowStore()
const arrangeNodes = (
layout: ArrangeLayout,
{ gap = DEFAULT_ARRANGE_GAP, captureUndo = true }: ArrangeOptions = {}
) => {
if (!hasMultipleSelection.value) return
const updates = computeArrangement(selectedNodes.value, layout, gap)
if (updates.length === 0) return
mutations.setSource(LayoutSource.Canvas)
mutations.batchMoveNodes(updates)
app.canvas?.setDirty(true, true)
if (captureUndo) {
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
}
return { arrangeNodes, canArrange: hasMultipleSelection }
}

View File

@@ -1,115 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ArrangeNodesModule from '@/composables/graph/useArrangeNodes'
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
const mockArrangeNodes = vi.fn()
vi.mock('@/composables/graph/useArrangeNodes', async () => {
const actual = await vi.importActual<typeof ArrangeNodesModule>(
'@/composables/graph/useArrangeNodes'
)
return {
...actual,
useArrangeNodes: () => ({ arrangeNodes: mockArrangeNodes })
}
})
describe('useArrangeSession', () => {
let frameCallbacks: Array<FrameRequestCallback>
let nextHandle: number
beforeEach(() => {
mockArrangeNodes.mockReset()
frameCallbacks = []
nextHandle = 1
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(
(cb: FrameRequestCallback) => {
frameCallbacks.push(cb)
return nextHandle++
}
)
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
frameCallbacks[id - 1] = () => {}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
const flushFrames = () => {
const callbacks = frameCallbacks
frameCallbacks = []
callbacks.forEach((cb) => cb(performance.now()))
}
it('start() applies layout immediately and captures undo', () => {
const session = useArrangeSession()
session.start('vertical')
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('vertical', {
gap: 12,
captureUndo: true
})
expect(session.activeLayout.value).toBe('vertical')
expect(session.gap.value).toBe(12)
})
it('previewGap() throttles repeated calls into a single frame', () => {
const session = useArrangeSession()
session.start('grid')
mockArrangeNodes.mockClear()
session.previewGap(20)
session.previewGap(30)
session.previewGap(40)
expect(mockArrangeNodes).not.toHaveBeenCalled()
flushFrames()
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('grid', {
gap: 40,
captureUndo: false
})
})
it('previewGap() is a no-op outside an active session', () => {
const session = useArrangeSession()
session.previewGap(20)
flushFrames()
expect(mockArrangeNodes).not.toHaveBeenCalled()
})
it('commitGap() cancels any pending preview frame', () => {
const session = useArrangeSession()
session.start('horizontal')
mockArrangeNodes.mockClear()
session.previewGap(25)
session.commitGap(36)
flushFrames()
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('horizontal', {
gap: 36,
captureUndo: true
})
})
it('reset() ends the session and prevents pending frames from arranging', () => {
const session = useArrangeSession()
session.start('vertical')
mockArrangeNodes.mockClear()
session.previewGap(40)
session.reset()
flushFrames()
expect(mockArrangeNodes).not.toHaveBeenCalled()
expect(session.activeLayout.value).toBeNull()
expect(session.gap.value).toBe(12)
})
})

View File

@@ -1,60 +0,0 @@
import { readonly, ref } from 'vue'
import {
DEFAULT_ARRANGE_GAP,
useArrangeNodes
} from '@/composables/graph/useArrangeNodes'
import type { ArrangeLayout } from '@/composables/graph/useArrangeNodes'
export function useArrangeSession() {
const { arrangeNodes } = useArrangeNodes()
const activeLayout = ref<ArrangeLayout | null>(null)
const gap = ref(DEFAULT_ARRANGE_GAP)
let pendingFrame: number | null = null
const cancelPendingFrame = () => {
if (pendingFrame === null) return
cancelAnimationFrame(pendingFrame)
pendingFrame = null
}
const reset = () => {
cancelPendingFrame()
activeLayout.value = null
gap.value = DEFAULT_ARRANGE_GAP
}
const start = (layout: ArrangeLayout) => {
gap.value = DEFAULT_ARRANGE_GAP
activeLayout.value = layout
arrangeNodes(layout, { gap: gap.value, captureUndo: true })
}
const previewGap = (nextGap: number) => {
if (activeLayout.value === null) return
gap.value = nextGap
cancelPendingFrame()
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
if (activeLayout.value === null) return
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: false })
})
}
const commitGap = (nextGap: number) => {
if (activeLayout.value === null) return
cancelPendingFrame()
gap.value = nextGap
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: true })
}
return {
activeLayout: readonly(activeLayout),
gap: readonly(gap),
start,
previewGap,
commitGap,
reset
}
}

View File

@@ -5,11 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -290,7 +285,14 @@ describe('Widget change error clearing via onWidgetChanged', () => {
)
expect(promotedWidget).toBeDefined()
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedRequiredInputMissingNodeError(
store,
interiorExecId,
promotedWidget!.name
)
subgraphNode.onWidgetChanged!.call(
subgraphNode,
@@ -302,227 +304,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {

View File

@@ -7,7 +7,6 @@
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -46,128 +45,22 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
function resolvePromotedExecId(
rootGraph: LGraph,
node: LGraphNode,
widget: IBaseWidget,
hostExecId: string
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
): string {
if (!isPromotedWidgetView(widget)) return hostExecId
const result = resolveConcretePromotedWidget(
hostNode,
node,
widget.sourceNodeId,
widget.sourceWidgetName
)
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
if (result.status === 'resolved' && result.resolved.node) {
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
}
return hostExecId
}
const hookedNodes = new WeakSet<LGraphNode>()
@@ -210,14 +103,23 @@ function installNodeHooks(node: LGraphNode): void {
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const targets = resolveWidgetErrorTargets(
const execId = resolvePromotedExecId(
app.rootGraph,
node,
widget,
hostExecId,
newValue
hostExecId
)
const widgetName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
execId,
widget.name,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
)
clearWidgetErrorTargets(targets, newValue)
}
)
}

View File

@@ -3,14 +3,7 @@ import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'
import {
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
@@ -193,7 +186,6 @@ describe('useLoad3d', () => {
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: vi.fn(),
centerCameraOnModel: vi.fn(),
getGizmoTransform: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
@@ -1750,184 +1742,4 @@ describe('useLoad3d', () => {
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
})
})
describe('scene dirty tracking', () => {
const fakeCache = {
image: 'threed/scene-1.png [temp]',
mask: 'threed/scene_mask-1.png [temp]',
normal: 'threed/scene_normal-1.png [temp]',
camera_info: null,
recording: '',
model_3d_info: []
}
it('treats an unseen node as dirty by default', () => {
const fresh = createMockLGraphNode({ properties: {} })
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('markLoad3dSceneDirty sets the node dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
markLoad3dSceneDirty(fresh)
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('setLoad3dOutputCache stores the output and clears dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(getLoad3dOutputCache(fresh)).toBe(fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
})
it('two nodes keep independent dirty state', () => {
const a = createMockLGraphNode({ properties: {} })
const b = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(a, fakeCache)
expect(isLoad3dSceneDirty(a)).toBe(false)
expect(isLoad3dSceneDirty(b)).toBe(true)
markLoad3dSceneDirty(a)
expect(isLoad3dSceneDirty(a)).toBe(true)
expect(isLoad3dSceneDirty(b)).toBe(true)
})
it('markLoad3dSceneDirty on null is a no-op', () => {
expect(() => markLoad3dSceneDirty(null)).not.toThrow()
})
it('sceneConfig changes flip the node dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.sceneConfig.value.backgroundColor = '#ffffff'
await nextTick()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('cameraChanged event marks the node dirty', async () => {
let cameraChangedHandler: ((state: unknown) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'cameraChanged') {
cameraChangedHandler = handler as (state: unknown) => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording marks dirty when a recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(5)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording leaves dirty alone when no recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(0)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
})
it('handleClearRecording marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleClearRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStartRecording marks dirty so an in-progress recording forces a re-capture', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
await composable.handleStartRecording()
expect(mockLoad3d.startRecording).toHaveBeenCalledTimes(1)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleCenterCameraOnModel marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.handleCenterCameraOnModel()
expect(mockLoad3d.centerCameraOnModel).toHaveBeenCalledTimes(1)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleSeek marks dirty when the animation has a duration', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
const calls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const match = calls.find(([event]) => event === 'animationProgressChange')
const animationProgressHandler = match![1] as (d: {
progress: number
currentTime: number
duration: number
}) => void
animationProgressHandler({ progress: 0, currentTime: 0, duration: 10 })
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleSeek(50)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
})
})

View File

@@ -22,7 +22,6 @@ import type {
GizmoMode,
LightConfig,
MaterialMode,
Model3DInfo,
ModelConfig,
SceneConfig,
UpDirection
@@ -39,38 +38,6 @@ import { useLoad3dService } from '@/services/load3dService'
type Load3dReadyCallback = (load3d: Load3d) => void
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
export type Load3dCachedOutput = {
image: string
mask: string
normal: string
camera_info: CameraState | null
recording: string
model_3d_info: Model3DInfo
}
const load3dSceneDirty = new WeakMap<LGraphNode, boolean>()
const load3dOutputCache = new WeakMap<LGraphNode, Load3dCachedOutput>()
export const markLoad3dSceneDirty = (node: LGraphNode | null): void => {
if (!node) return
load3dSceneDirty.set(node, true)
}
export const isLoad3dSceneDirty = (node: LGraphNode): boolean =>
load3dSceneDirty.get(node) !== false
export const getLoad3dOutputCache = (
node: LGraphNode
): Load3dCachedOutput | undefined => load3dOutputCache.get(node)
export const setLoad3dOutputCache = (
node: LGraphNode,
output: Load3dCachedOutput
): void => {
load3dOutputCache.set(node, output)
load3dSceneDirty.set(node, false)
}
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
@@ -102,11 +69,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
let load3d: Load3d | null = null
let isFirstModelLoad = true
const markDirty = () => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) markLoad3dSceneDirty(rawNode as LGraphNode)
}
const debouncedHandleResize = useDebounceFn(() => {
load3d?.handleResize()
}, 150)
@@ -409,7 +371,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (n) {
n.properties['Light Config'] = lightConfig.value
}
markDirty()
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
@@ -454,7 +415,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -495,7 +455,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -529,7 +488,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
}
markDirty()
},
{ deep: true }
)
@@ -589,21 +547,18 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
load3d.toggleAnimation(newValue)
}
markDirty()
})
watch(selectedSpeed, (newValue) => {
if (load3d && newValue) {
load3d.setAnimationSpeed(newValue)
}
markDirty()
})
watch(selectedAnimation, (newValue) => {
if (load3d && newValue !== undefined) {
load3d.updateSelectedAnimation(newValue)
}
markDirty()
})
const handleMouseEnter = () => {
@@ -618,7 +573,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
await load3d.startRecording()
isRecording.value = true
markDirty()
}
}
@@ -628,7 +582,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isRecording.value = false
recordingDuration.value = load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
if (hasRecording.value) markDirty()
}
}
@@ -645,7 +598,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
markDirty()
}
}
@@ -653,7 +605,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
markDirty()
}
}
@@ -985,7 +936,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
state: cameraState
}
}
markLoad3dSceneDirty(node)
}
},
gizmoTransformChange: (data: GizmoConfig) => {
@@ -1026,9 +976,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const handleCenterCameraOnModel = () => {
if (!load3d) return
load3d.centerCameraOnModel()
markDirty()
load3d?.centerCameraOnModel()
}
const handleResetGizmoTransform = () => {

View File

@@ -0,0 +1,55 @@
import type { HintedString } from '@primevue/core'
import type { InjectionKey } from 'vue'
import { computed, inject } from 'vue'
/**
* Options for configuring transform-compatible overlay props
*/
interface TransformCompatOverlayOptions {
/**
* Where to append the overlay. 'self' keeps overlay within component
* for proper transform inheritance, 'body' teleports to document body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
// Future: other props needed for transform compatibility
// scrollTarget?: string | HTMLElement
// autoZIndex?: boolean
}
export const OverlayAppendToKey: InjectionKey<
HintedString<'body' | 'self'> | undefined | HTMLElement
> = Symbol('OverlayAppendTo')
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
*
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
* body by default, breaking transform inheritance. This composable provides
* the necessary props to keep overlays within their component elements.
*
* @param overrides - Optional overrides for specific use cases
* @returns Computed props object to spread on PrimeVue overlay components
*
* @example
* ```vue
* <template>
* <Select v-bind="overlayProps" />
* </template>
*
* <script setup>
* const overlayProps = useTransformCompatOverlayProps()
* </script>
* ```
*/
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
return computed(() => ({
appendTo: injectedAppendTo ?? ('self' as const),
...overrides
}))
}

View File

@@ -38,27 +38,13 @@ vi.mock('@/services/load3dService', () => ({
})
}))
vi.mock('@/composables/useLoad3d', () => {
const sceneDirty = new WeakMap<LGraphNode, boolean>()
const outputCache = new WeakMap<LGraphNode, unknown>()
return {
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap,
markLoad3dSceneDirty: (node: LGraphNode | null) => {
if (!node) return
sceneDirty.set(node, true)
},
isLoad3dSceneDirty: (node: LGraphNode) => sceneDirty.get(node) !== false,
getLoad3dOutputCache: (node: LGraphNode) => outputCache.get(node),
setLoad3dOutputCache: (node: LGraphNode, value: unknown) => {
outputCache.set(node, value)
sceneDirty.set(node, false)
}
}
})
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
@@ -187,7 +173,7 @@ function makePreview3DAdvancedNode(
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'viewport_state', value: '' }],
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
@@ -496,16 +482,13 @@ describe('Comfy.Load3D.nodeCreated', () => {
await load3DExt.nodeCreated(node)
expect(configureMock).toHaveBeenCalledWith(
expect.objectContaining({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2],
onSceneInvalidated: expect.any(Function)
})
)
expect(configureMock).toHaveBeenCalledWith({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2]
})
})
it('attaches a serializeValue function to the scene widget', async () => {
@@ -742,7 +725,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
await preview3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'temp',
'output',
'prev/model.glb',
{ silentOnNotFound: true }
)
@@ -800,9 +783,9 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
})
it('attaches a camera-only serializeValue to the viewport_state widget', async () => {
it('attaches a camera-only serializeValue to the image widget', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
await preview3DAdvancedExt.nodeCreated(node)
@@ -812,7 +795,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
@@ -836,7 +819,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
@@ -868,7 +851,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'temp',
'output',
'sub/nested/mesh.glb',
{ silentOnNotFound: true }
)
@@ -1031,95 +1014,3 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
])
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)
function makeFullFakeLoad3d() {
return {
getCurrentCameraType: vi.fn(() => 'perspective'),
cameraManager: { perspectiveCamera: { fov: 35 } },
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
stopRecording: vi.fn(),
captureScene: vi.fn(async () => ({
scene: 'scene-data',
mask: 'mask-data',
normal: 'normal-data'
})),
handleResize: vi.fn(),
getModelInfo: vi.fn(() => null),
getRecordingData: vi.fn(() => null)
}
}
async function setup() {
const { load3DExt } = await loadExtensionsFresh()
const useLoad3dModule = await import('@/composables/useLoad3d')
const utilsModule = await import('@/extensions/core/load3d/Load3dUtils')
const uploadTempImage = utilsModule.default.uploadTempImage as ReturnType<
typeof vi.fn
>
let counter = 0
uploadTempImage.mockImplementation(
async (_data: unknown, kind: string) => ({
name: `${kind}-${++counter}.png`
})
)
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets, properties: {} })
useLoad3dModule.nodeToLoad3dMap.set(node, makeFullFakeLoad3d() as never)
await load3DExt.nodeCreated(node)
const serialize = widgets[3].serializeValue! as () => Promise<{
image: string
} | null>
return { node, serialize, uploadTempImage, useLoad3dModule }
}
it('reuses the cached output when the scene has not been dirtied', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
const first = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(first?.image).toBe('threed/scene-1.png [temp]')
expect(useLoad3dModule.isLoad3dSceneDirty(node)).toBe(false)
expect(useLoad3dModule.getLoad3dOutputCache(node)).toBe(first)
const second = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(second).toBe(first)
})
it('re-captures after the scene is marked dirty', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
useLoad3dModule.markLoad3dSceneDirty(node)
const refreshed = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(6)
expect(refreshed?.image).toBe('threed/scene-4.png [temp]')
})
it('returns null when no load3d instance is registered for the node', async () => {
const { load3DExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets })
await load3DExt.nodeCreated(node)
expect(await widgets[3].serializeValue!()).toBeNull()
})
})

View File

@@ -2,15 +2,7 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import {
type Load3dCachedOutput,
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
@@ -104,8 +96,6 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
modelWidget.value = uploadPath
}
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Model upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
@@ -123,7 +113,6 @@ async function handleResourcesUpload(files: FileList, node: LGraphNode) {
: '3d'
await Load3dUtils.uploadMultipleFiles(files, subfolder)
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Extra resources upload failed:', error)
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
@@ -281,16 +270,8 @@ useExtensionService().registerExtension({
}
],
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
])
return {
LOAD_3D(node) {
const inputName = VIEWPORT_STATE_NODES.has(node.constructor.comfyClass)
? 'viewport_state'
: 'image'
const hasModelFileWidget = node.widgets?.some(
(w) => w.name === 'model_file'
)
@@ -330,15 +311,14 @@ useExtensionService().registerExtension({
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
markLoad3dSceneDirty(node)
})
}
const widget = new ComponentWidgetImpl({
node: node,
name: inputName,
name: 'image',
component: Load3D,
inputSpec: { ...inputSpecLoad3D, name: inputName },
inputSpec: inputSpecLoad3D,
options: {}
})
@@ -389,8 +369,7 @@ useExtensionService().registerExtension({
modelWidget,
cameraState,
width,
height,
onSceneInvalidated: () => markLoad3dSceneDirty(node)
height
})
})
@@ -408,11 +387,6 @@ useExtensionService().registerExtension({
return null
}
if (!isLoad3dSceneDirty(node)) {
const cached = getLoad3dOutputCache(node)
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
@@ -444,7 +418,7 @@ useExtensionService().registerExtension({
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
@@ -461,11 +435,9 @@ useExtensionService().registerExtension({
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal.recording = `threed/${recording.name} [temp]`
returnVal['recording'] = `threed/${recording.name} [temp]`
}
setLoad3dOutputCache(node, returnVal)
return returnVal
}
}
@@ -716,7 +688,7 @@ useExtensionService().registerExtension({
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
config.configureForSaveMesh('output', lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -743,7 +715,7 @@ useExtensionService().registerExtension({
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
@@ -810,7 +782,7 @@ useExtensionService().registerExtension({
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
config.configureForSaveMesh('output', normalizedPath, {
silentOnNotFound: true
})

View File

@@ -682,138 +682,3 @@ describe('Load3DConfiguration "none" model handling', () => {
})
})
})
describe('Load3DConfiguration.onSceneInvalidated', () => {
function makeLoad3dMock(): Load3d {
return {
loadModel: vi.fn().mockResolvedValue(undefined),
clearModel: vi.fn(),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
it('width.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
width.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('height.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
height.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('model_file widget callback invokes onSceneInvalidated after the model loads', async () => {
const onSceneInvalidated = vi.fn()
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('preserves any pre-existing model widget callback alongside the invalidation hook', async () => {
const onSceneInvalidated = vi.fn()
const original = vi.fn()
const modelWidget = {
value: 'none',
callback: original
} as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(original).toHaveBeenCalledWith('model.glb')
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('callbacks remain safe when onSceneInvalidated is omitted', async () => {
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
width,
height
})
await flush()
expect(() => width.callback!(2048)).not.toThrow()
expect(() => height.callback!(2048)).not.toThrow()
expect(() => {
modelWidget.value = 'model.glb'
}).not.toThrow()
})
})

View File

@@ -23,14 +23,6 @@ type Load3DConfigurationSettings = {
height?: IBaseWidget
bgImagePath?: string
silentOnNotFound?: boolean
/**
* Called when a user-driven change to one of the wired widgets
* (model_file, width, height) makes the previously captured scene stale.
* Backend caching covers these inputs by themselves; this hook lets the
* caller invalidate any frontend-side capture cache so the next serialize
* re-renders at the new state.
*/
onSceneInvalidated?: () => void
}
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
@@ -54,7 +46,7 @@ class Load3DConfiguration {
) {}
configureForSaveMesh(
loadFolder: 'input' | 'output' | 'temp',
loadFolder: 'input' | 'output',
filePath: string,
options?: { silentOnNotFound?: boolean }
) {
@@ -71,33 +63,22 @@ class Load3DConfiguration {
setting.modelWidget,
setting.loadFolder,
setting.cameraState,
setting.silentOnNotFound ?? false,
setting.onSceneInvalidated
)
this.setupTargetSize(
setting.width,
setting.height,
setting.onSceneInvalidated
setting.silentOnNotFound ?? false
)
this.setupTargetSize(setting.width, setting.height)
this.setupDefaultProperties(setting.bgImagePath)
}
private setupTargetSize(
width?: IBaseWidget,
height?: IBaseWidget,
onSceneInvalidated?: () => void
) {
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
width.callback = (value: number) => {
this.load3d.setTargetSize(value, height.value as number)
onSceneInvalidated?.()
}
height.callback = (value: number) => {
this.load3d.setTargetSize(width.value as number, value)
onSceneInvalidated?.()
}
}
}
@@ -122,8 +103,7 @@ class Load3DConfiguration {
modelWidget: IBaseWidget,
loadFolder: string,
cameraState?: CameraState,
silentOnNotFound: boolean = false,
onSceneInvalidated?: () => void
silentOnNotFound: boolean = false
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
@@ -157,8 +137,6 @@ class Load3DConfiguration {
if (originalCallback) {
originalCallback(value)
}
onSceneInvalidated?.()
}
}

View File

@@ -191,11 +191,7 @@ export class LoaderManager implements LoaderManagerInterface {
return null
}
const requestedType = params.get('type')
const loadRootFolder =
requestedType === 'output' || requestedType === 'temp'
? requestedType
: 'input'
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
const subfolder = params.get('subfolder') ?? ''
const path =
'api/view?type=' +

View File

@@ -186,7 +186,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
expect(node.properties['Last Time Model File']).toBe('scene.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'temp',
'output',
'scene.ply',
expect.objectContaining({ silentOnNotFound: true })
)
@@ -231,7 +231,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
const node = makePreviewNode({
widgets: [
{ name: 'model_file', value: '' },
{ name: 'viewport_state', value: '' },
{ name: 'image', value: '' },
widthWidget,
heightWidget
]
@@ -262,7 +262,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
)
const sceneWidget: FakeWidget & {
serializeValue?: () => Promise<unknown>
} = { name: 'viewport_state', value: '' }
} = { name: 'image', value: '' }
const node = makePreviewNode({
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
})
@@ -318,7 +318,7 @@ describe('Comfy.PreviewPointCloud.nodeCreated', () => {
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'temp',
'output',
'pointcloud.ply',
expect.objectContaining({ silentOnNotFound: true })
)

View File

@@ -46,7 +46,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
config.configureForSaveMesh('output', normalizedPath, {
silentOnNotFound: true
})
@@ -119,7 +119,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
config.configureForSaveMesh('output', lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -136,9 +136,7 @@ function createPreview3DExtension(
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')

View File

@@ -268,7 +268,6 @@
"title": "Title",
"edit": "Edit",
"copy": "Copy",
"details": "Details",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"relativeTime": {
@@ -351,11 +350,6 @@
"nodeSlotsError": "Node Slots Error",
"nodeWidgetsError": "Node Widgets Error",
"frameNodes": "Frame Nodes",
"arrange": "Arrange",
"arrangeVertically": "Arrange vertically",
"arrangeHorizontally": "Arrange horizontally",
"arrangeAsGrid": "Arrange as grid",
"arrangeSpacing": "Arrangement spacing",
"listening": "Listening...",
"ready": "Ready",
"play": "Play",
@@ -3538,6 +3532,7 @@
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
"willBeReplacedBy": "This node will be replaced by:",
"replaceNode": "Replace Node",
"replaceAll": "Replace All",
@@ -3555,7 +3550,6 @@
"parameters": "Parameters",
"nodes": "Nodes",
"info": "Info",
"infoFor": "Info for {item}",
"color": "Node color",
"pinned": "Pinned",
"bypass": "Bypass",
@@ -3575,7 +3569,6 @@
"hideInput": "Hide input",
"showInput": "Show input",
"locateNode": "Locate node on canvas",
"locateNodeFor": "Locate {item}",
"favorites": "FAVORITED INPUTS",
"favoritesNone": "NO FAVORITED INPUTS",
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
@@ -3609,7 +3602,6 @@
"errors": "Errors",
"noErrors": "No errors",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
@@ -3622,6 +3614,8 @@
"missingNodePacks": {
"title": "Missing Node Packs",
"unsupportedTitle": "Unsupported Node Packs",
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
"installAll": "Install All",
"installNodePack": "Install node pack",
@@ -3667,7 +3661,21 @@
"refreshFailed": "Failed to refresh missing models. Please try again."
},
"missingMedia": {
"missingMediaTitle": "Missing Inputs"
"missingMediaTitle": "Missing Inputs",
"image": "Images",
"video": "Videos",
"audio": "Audio",
"locateNode": "Locate node",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"uploadFile": "Upload {type}",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"selectedFromLibrary": "Selected from library",
"useFromLibrary": "Use from Library",
"confirmSelection": "Confirm selection",
"cancelSelection": "Cancel selection",
"or": "OR"
}
},
"errorOverlay": {
@@ -3677,6 +3685,7 @@
"viewDetails": "View details",
"missingNodes": "Some nodes are missing and need to be installed",
"missingModels": "{count} required model is missing | {count} required models are missing",
"swapNodes": "Some nodes can be replaced with alternatives",
"missingMedia": "Some nodes are missing required inputs"
},
"errorCatalog": {
@@ -3684,47 +3693,6 @@
"nodeName": "This node",
"inputName": "unknown input"
},
"missingErrors": {
"missing_node": {
"displayMessageCloud": "Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
"displayMessageOss": "Install missing packs to use this workflow.",
"toastTitleOneCloud": "{nodeType} isn't available on Cloud",
"toastTitleOneOss": "Missing node: {nodeType}",
"toastTitleManyCloud": "Nodes aren't available on Cloud",
"toastTitleManyOss": "Missing nodes",
"toastMessageOneCloud": "This node isn't supported on Cloud.",
"toastMessageOneOss": "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node.",
"toastMessageManyCloud": "This workflow uses nodes that aren't supported on Cloud.",
"toastMessageManyOss": "{count} nodes require missing node packs."
},
"swap_nodes": {
"displayMessage": "Some nodes can be replaced with alternatives",
"toastTitleOne": "{nodeType} can be replaced",
"toastTitleMany": "Nodes can be replaced",
"toastMessageOne": "Replace it with {replacementNodeType} from the error panel.",
"toastMessageMany": "{count} node types can be replaced with compatible alternatives."
},
"missing_model": {
"displayMessageCloud": "Import a model, or open the node to replace it.",
"displayMessageOss": "Download a model, or open the node to replace it.",
"toastTitleOneCloud": "{modelName} isn't available on Cloud",
"toastTitleOneOss": "{modelName} is missing",
"toastTitleMany": "Missing models",
"toastTitleManyCloud": "Models aren't available on Cloud",
"toastMessageOneCloud": "This model isn't supported. Choose a different one.",
"toastMessageOneOss": "{nodeName} is missing a required model file.",
"toastMessageManyCloud": "Some models aren't supported. Choose different ones.",
"toastMessageManyOss": "{count} model files are missing."
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",
"toastMessageMany": "Please select the missing media inputs before running this workflow."
}
},
"validationErrors": {
"required_input_missing": {
"title": "Missing connection",
@@ -3801,15 +3769,6 @@
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} rejected the value for {inputName}."
},
"unknown_validation_error": {
"title": "Validation failed",
"message": "A node returned a validation error ComfyUI does not recognize.",
"details": "{nodeName} returned an unrecognized validation error: {errorType}",
"detailsWithRawDetails": "{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} returned an unrecognized validation error."
},
"exception_during_inner_validation": {
"title": "Validation failed",
"message": "The workflow couldn't validate a connected node.",

View File

@@ -2,7 +2,6 @@
// 1:1 to an API error type. Simple validation mappings stay with the validation
// resolver.
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
export const UNKNOWN_VALIDATION_ERROR_CATALOG_ID = 'unknown_validation_error'
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'

View File

@@ -2,14 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel,
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import { i18n } from '@/i18n'
function nodeValidationError(
@@ -59,59 +55,6 @@ function executionError(
}
}
function missingNodeType(
type: string,
nodeId: string,
cnrId?: string
): MissingNodeType {
return {
type,
nodeId,
cnrId,
isReplaceable: false
}
}
function replaceableNodeType(
type: string,
nodeId: string,
replacementNodeType: string
): MissingNodeType {
return {
type,
nodeId,
isReplaceable: true,
replacement: {
old_node_id: type,
new_node_id: replacementNodeType,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
}
}
function missingModelGroups(...names: string[]): MissingModelGroup[] {
return [
{
directory: 'checkpoints',
isAssetSupported: true,
models: names.map((name) => ({
name,
representative: {
name,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: []
}))
}
]
}
describe('errorMessageResolver', () => {
it('resolves required_input_missing to missing connection display copy', () => {
const result = resolveRunErrorMessage({
@@ -145,26 +88,6 @@ describe('errorMessageResolver', () => {
})
})
it('resolves unknown validation errors to fallback catalog copy', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError('value_not_valid', undefined, 'some detail'),
nodeDisplayName: 'KSampler'
})
).toEqual({
catalogId: 'unknown_validation_error',
displayTitle: 'Validation failed',
displayMessage:
'A node returned a validation error ComfyUI does not recognize.',
displayDetails:
'KSampler returned an unrecognized validation error (value_not_valid): some detail',
displayItemLabel: 'KSampler',
toastTitle: 'Validation failed',
toastMessage: 'KSampler returned an unrecognized validation error.'
})
})
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
const originalLocale = i18n.global.locale.value
const originalKoMessages = i18n.global.getLocaleMessage('ko')
@@ -1384,368 +1307,17 @@ describe('errorMessageResolver', () => {
})
it('resolves missing error group display copy', () => {
const missingNodeTypes = [missingNodeType('FooNode', '7', 'foo-pack')]
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: missingNodeTypes,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Missing Node Packs (1)',
displayMessage: 'Install missing packs to use this workflow.',
toastTitle: 'Missing node: FooNode',
toastMessage:
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: missingNodeTypes,
count: 1,
isCloud: true
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Unsupported Node Packs (1)',
displayMessage:
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
toastTitle: "FooNode isn't available on Cloud",
toastMessage: "This node isn't supported on Cloud."
})
const multipleMissingNodeTypes = [
missingNodeType('FooNode', '7', 'foo-pack'),
missingNodeType('BarNode', '9', 'bar-pack')
]
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: multipleMissingNodeTypes,
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing nodes',
toastMessage: '2 nodes require missing node packs.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: multipleMissingNodeTypes,
count: 2,
isCloud: true
})
).toMatchObject({
toastTitle: "Nodes aren't available on Cloud",
toastMessage: "This workflow uses nodes that aren't supported on Cloud."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: [
missingNodeType('FooNode', '7', 'foo-pack'),
missingNodeType('FooNode', '8', 'foo-pack')
],
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing node: FooNode',
toastMessage:
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
})
const swapNodeTypes = [replaceableNodeType('OldNode', '8', 'NewNode')]
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: swapNodeTypes,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'swap_nodes',
displayTitle: 'Swap Nodes (1)',
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
})
const multipleSwapNodeTypes = [
replaceableNodeType('OldNodeA', '8', 'NewNodeA'),
replaceableNodeType('OldNodeB', '9', 'NewNodeB')
]
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: multipleSwapNodeTypes,
count: 2,
isCloud: false
})
).toMatchObject({
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'Nodes can be replaced',
toastMessage: '2 node types can be replaced with compatible alternatives.'
})
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: [
replaceableNodeType('OldNode', '8', 'NewNode'),
replaceableNodeType('OldNode', '9', 'NewNode')
],
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
})
const groups = missingModelGroups('sdxl.safetensors')
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups,
groups: [],
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayMessage: 'Download a model, or open the node to replace it.',
toastTitle: 'sdxl.safetensors is missing',
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups,
count: 1,
isCloud: true
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: "sdxl.safetensors isn't available on Cloud",
toastMessage: "This model isn't supported. Choose a different one."
})
})
it('resolves missing media group display and toast copy', () => {
const groups: MissingMediaGroup[] = [
{
mediaType: 'image',
items: [
{
name: 'portrait.png',
mediaType: 'image',
representative: {
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'portrait.png',
isMissing: true
},
referencingNodes: [{ nodeId: '4', widgetName: 'image' }]
}
]
}
]
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_media',
displayTitle: 'Missing Inputs (1)',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.'
})
})
it.for([
{
source: { nodeType: 'LoadImage', widgetName: 'image' },
displayItemLabel: 'Load Image - image'
},
{
source: {
nodeDisplayName: 'Custom Loader',
nodeType: 'LoadImage',
widgetName: 'image'
},
displayItemLabel: 'Custom Loader - image'
},
{
source: { nodeType: '', widgetName: '' },
displayItemLabel: 'This node - unknown input'
}
] as const)(
'resolves missing media item labels from $source',
({ source, displayItemLabel }) => {
expect(resolveMissingMediaItemLabel(source)).toEqual({
displayItemLabel
})
}
)
it.for([
[
'image',
'LoadImage',
'image',
'portrait.png',
'Media input missing',
'Load Image is missing a required media file.'
],
[
'video',
'LoadVideo',
'file',
'clip.mp4',
'Media input missing',
'Load Video is missing a required media file.'
],
[
'audio',
'LoadAudio',
'audio',
'voice.wav',
'Media input missing',
'Load Audio is missing a required media file.'
]
] as const)(
'resolves missing %s toast copy from media type and node type',
([
mediaType,
nodeType,
widgetName,
mediaName,
toastTitle,
toastMessage
]) => {
const groups: MissingMediaGroup[] = [
{
mediaType,
items: [
{
name: mediaName,
mediaType,
representative: {
nodeId: '4',
nodeType,
widgetName,
mediaType,
name: mediaName,
isMissing: true
},
referencingNodes: [{ nodeId: '4', widgetName }]
}
]
}
]
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups,
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle,
toastMessage
})
}
)
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: modelGroups,
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing models',
toastMessage: '2 model files are missing.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: modelGroups,
count: 2,
isCloud: true
})
).toMatchObject({
toastTitle: "Models aren't available on Cloud",
toastMessage: "Some models aren't supported. Choose different ones."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'a.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'image' }]
},
{
name: 'b.png',
mediaType: 'image',
representative: {
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
},
referencingNodes: [{ nodeId: '2', widgetName: 'image' }]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
displayMessage: '1 required model is missing'
})
})
})

View File

@@ -1,29 +1,14 @@
import type {
ResolvedCatalogErrorMessage,
ResolvedErrorMessage,
RunErrorMessageSource
} from './types'
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel
} from './missingErrorResolver'
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'execution' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage {

View File

@@ -1,7 +1,4 @@
import type {
ResolvedCatalogErrorMessage,
RunErrorMessageSource
} from './types'
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
@@ -14,7 +11,7 @@ type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ExecutionErrorResolveContext
): ResolvedCatalogErrorMessage {
): ResolvedErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,

View File

@@ -2,339 +2,19 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { st } from '@/i18n'
import { st, t } from '@/i18n'
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
// are grouped catalog messages rather than individual execution error items.
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function formatNodeTypeName(nodeType: string): string | null {
const trimmed = nodeType.trim()
if (!trimmed) return null
return trimmed
.replace(/[_-]+/g, ' ')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
}
type NodeTypeErrorSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_node' | 'swap_nodes' }
>
type NodeTypeErrorItem = NodeTypeErrorSource['nodeTypes'][number]
function getNodeTypeLabel(nodeType: NodeTypeErrorItem): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function getDistinctNodeTypeLabels(nodeTypes: NodeTypeErrorItem[]): string[] {
const labels = new Set<string>()
for (const nodeType of nodeTypes) labels.add(getNodeTypeLabel(nodeType))
return Array.from(labels)
}
type MissingNodeSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_node' }
>
function isMissingNodeType(nodeType: NodeTypeErrorItem): boolean {
return typeof nodeType === 'string' || !nodeType.isReplaceable
}
function resolveMissingNodeDisplayMessage(source: MissingNodeSource): string {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.displayMessageCloud'
: 'errorCatalog.missingErrors.missing_node.displayMessageOss'
const fallback = source.isCloud
? "Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
: 'Install missing packs to use this workflow.'
return translateCatalogMessage(key, fallback)
}
function resolveMissingNodeToastTitle(source: MissingNodeSource): string {
const labels = getDistinctNodeTypeLabels(
source.nodeTypes.filter(isMissingNodeType)
)
const [firstLabel] = labels
if (labels.length === 1 && firstLabel) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastTitleOneCloud'
: 'errorCatalog.missingErrors.missing_node.toastTitleOneOss'
const fallback = source.isCloud
? "{nodeType} isn't available on Cloud"
: 'Missing node: {nodeType}'
return translateCatalogMessage(key, fallback, { nodeType: firstLabel })
}
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastTitleManyCloud'
: 'errorCatalog.missingErrors.missing_node.toastTitleManyOss'
const fallback = source.isCloud
? "Nodes aren't available on Cloud"
: 'Missing nodes'
return translateCatalogMessage(key, fallback)
}
function resolveMissingNodeToastMessage(source: MissingNodeSource): string {
const labels = getDistinctNodeTypeLabels(
source.nodeTypes.filter(isMissingNodeType)
)
const count = labels.length || source.count
if (count === 1) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastMessageOneCloud'
: 'errorCatalog.missingErrors.missing_node.toastMessageOneOss'
const fallback = source.isCloud
? "This node isn't supported on Cloud."
: "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
return translateCatalogMessage(key, fallback)
}
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastMessageManyCloud'
: 'errorCatalog.missingErrors.missing_node.toastMessageManyOss'
const fallback = source.isCloud
? "This workflow uses nodes that aren't supported on Cloud."
: '{count} nodes require missing node packs.'
return translateCatalogMessage(key, fallback, { count })
}
type SwapNodeSource = Extract<MissingErrorMessageSource, { kind: 'swap_nodes' }>
function isSwapNodeType(nodeType: NodeTypeErrorItem): nodeType is Exclude<
NodeTypeErrorItem,
string
> & {
isReplaceable: true
} {
return typeof nodeType !== 'string' && nodeType.isReplaceable === true
}
function getSwapNodeTypes(source: SwapNodeSource) {
return source.nodeTypes.filter(isSwapNodeType)
}
function resolveSwapNodeToastTitle(source: SwapNodeSource): string {
const nodeTypes = getSwapNodeTypes(source)
const labels = getDistinctNodeTypeLabels(nodeTypes)
const [firstLabel] = labels
if (labels.length === 1 && firstLabel) {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastTitleOne',
'{nodeType} can be replaced',
{ nodeType: firstLabel }
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastTitleMany',
'Nodes can be replaced'
)
}
function resolveSwapNodeToastMessage(source: SwapNodeSource): string {
const nodeTypes = getSwapNodeTypes(source)
const labels = getDistinctNodeTypeLabels(nodeTypes)
const [firstNodeType] = nodeTypes
if (labels.length === 1 && firstNodeType?.replacement?.new_node_id) {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastMessageOne',
'Replace it with {replacementNodeType} from the error panel.',
{ replacementNodeType: firstNodeType.replacement.new_node_id }
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastMessageMany',
'{count} node types can be replaced with compatible alternatives.',
{ count: labels.length || source.count }
)
}
function resolveSwapNodeDisplayMessage(): string {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.displayMessage',
'Some nodes can be replaced with alternatives'
)
}
type MissingModelSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_model' }
>
function getMissingModelCount(source: MissingModelSource): number {
const count = source.groups.reduce(
(total, group) => total + group.models.length,
0
)
return count || source.count
}
function resolveMissingModelDisplayMessage(source: MissingModelSource): string {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.displayMessageCloud'
: 'errorCatalog.missingErrors.missing_model.displayMessageOss'
const fallback = source.isCloud
? 'Import a model, or open the node to replace it.'
: 'Download a model, or open the node to replace it.'
return translateCatalogMessage(key, fallback)
}
function resolveMissingModelToastTitle(source: MissingModelSource): string {
const [firstModel] = source.groups.flatMap((group) => group.models)
const count = getMissingModelCount(source)
if (count === 1 && firstModel) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.toastTitleOneCloud'
: 'errorCatalog.missingErrors.missing_model.toastTitleOneOss'
const fallback = source.isCloud
? "{modelName} isn't available on Cloud"
: '{modelName} is missing'
return translateCatalogMessage(key, fallback, {
modelName: firstModel.name
})
}
const useCloudPluralTitle = source.isCloud && count > 1
const key = useCloudPluralTitle
? 'errorCatalog.missingErrors.missing_model.toastTitleManyCloud'
: 'errorCatalog.missingErrors.missing_model.toastTitleMany'
const fallback = useCloudPluralTitle
? "Models aren't available on Cloud"
: 'Missing models'
return translateCatalogMessage(key, fallback)
}
function getMissingModelNodeName(
model: MissingModelSource['groups'][number]['models'][number]
): string {
return (
formatNodeTypeName(model.representative.nodeType) ??
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}
function resolveMissingModelToastMessage(source: MissingModelSource): string {
const [firstModel] = source.groups.flatMap((group) => group.models)
const count = getMissingModelCount(source)
if (!firstModel || count !== 1) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.toastMessageManyCloud'
: 'errorCatalog.missingErrors.missing_model.toastMessageManyOss'
const fallback = source.isCloud
? "Some models aren't supported. Choose different ones."
: '{count} model files are missing.'
return translateCatalogMessage(key, fallback, { count })
}
if (source.isCloud) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_model.toastMessageOneCloud',
"This model isn't supported. Choose a different one."
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_model.toastMessageOneOss',
'{nodeName} is missing a required model file.',
{ nodeName: getMissingModelNodeName(firstModel) }
)
}
type MissingMediaSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_media' }
>
interface MissingMediaItemLabelSource {
nodeDisplayName?: string
nodeType?: string
widgetName?: string
}
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
function getMissingMediaNodeName(
item: ReturnType<typeof getMissingMediaItems>[number]
): string | null {
return formatNodeTypeName(item.representative.nodeType)
}
function resolveMissingMediaDisplayMessage(): string {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.displayMessage',
'A required media input has no file selected.'
)
}
export function resolveMissingMediaItemLabel(
source: MissingMediaItemLabelSource
): { displayItemLabel: string } {
const nodeName = normalizeNodeName(
source.nodeDisplayName ||
formatNodeTypeName(source.nodeType ?? '') ||
undefined
)
const inputName =
source.widgetName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
return {
displayItemLabel: translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.itemLabel',
'{nodeName} - {inputName}',
{ nodeName, inputName }
)
}
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleOne',
'Media input missing'
)
}
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'
)
}
const nodeName = getMissingMediaNodeName(firstItem)
const displayNodeName =
nodeName ??
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageWithNode',
'{nodeName} is missing a required media file.',
{
nodeName: displayNodeName
}
)
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
@@ -353,9 +33,10 @@ export function resolveMissingErrorMessage(
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: resolveMissingNodeDisplayMessage(source),
toastTitle: resolveMissingNodeToastTitle(source),
toastMessage: resolveMissingNodeToastMessage(source)
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
@@ -364,9 +45,10 @@ export function resolveMissingErrorMessage(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: resolveSwapNodeDisplayMessage(),
toastTitle: resolveSwapNodeToastTitle(source),
toastMessage: resolveSwapNodeToastMessage(source)
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
@@ -378,9 +60,7 @@ export function resolveMissingErrorMessage(
),
source.count
),
displayMessage: resolveMissingModelDisplayMessage(source),
toastTitle: resolveMissingModelToastTitle(source),
toastMessage: resolveMissingModelToastMessage(source)
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
@@ -389,9 +69,10 @@ export function resolveMissingErrorMessage(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: resolveMissingMediaDisplayMessage(),
toastTitle: resolveMissingMediaToastTitle(source),
toastMessage: resolveMissingMediaToastMessage(source)
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}

View File

@@ -1,4 +1,4 @@
import type { ResolvedCatalogErrorMessage } from './types'
import type { ResolvedErrorMessage } from './types'
import {
normalizeNodeName,
@@ -19,7 +19,7 @@ export function resolveRuntimeCatalogCopy(
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedCatalogErrorMessage {
): ResolvedErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
@@ -27,7 +27,7 @@ export function resolveRuntimeCatalogCopy(
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const displayMessage = resolveMessage('message')
const result: ResolvedCatalogErrorMessage = {
const result: ResolvedErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage

View File

@@ -3,7 +3,10 @@ import type {
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
@@ -25,10 +28,6 @@ export interface ResolvedErrorMessage {
toastMessage?: string
}
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
catalogId: string
}
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
displayTitle: string
displayMessage: string
@@ -74,5 +73,6 @@ export type MissingErrorMessageSource =
kind: 'missing_media'
groups: MissingMediaGroup[]
count: number
mediaTypes: MediaType[]
isCloud: boolean
}

View File

@@ -1,9 +1,8 @@
import type { NodeValidationError, ResolvedCatalogErrorMessage } from './types'
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID,
UNKNOWN_VALIDATION_ERROR_CATALOG_ID
MISSING_CONNECTION_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
@@ -118,11 +117,6 @@ const IMAGE_NOT_LOADED_VALIDATION_RULE = {
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
const UNKNOWN_VALIDATION_ERROR_RULE = {
catalogId: UNKNOWN_VALIDATION_ERROR_CATALOG_ID,
itemLabel: 'node'
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
@@ -234,7 +228,7 @@ function getValueSpecificCopyKeys(
}
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
return error.details?.trim()
return error.details.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
@@ -243,7 +237,7 @@ function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
if (!error.details?.trim()) return DEFAULT_COPY_KEYS
if (!error.details.trim()) return DEFAULT_COPY_KEYS
return {
detailsKey: 'detailsWithRawDetails',
@@ -278,17 +272,16 @@ function resolveValidationCatalogCopy(
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedCatalogErrorMessage {
): ResolvedErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details?.trim() ?? ''
const trimmedDetails = error.details.trim()
const rawDetails =
error.type === 'dependency_cycle'
? formatDependencyCycleDetails(trimmedDetails)
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
errorType: error.type || 'unknown',
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
@@ -313,7 +306,7 @@ function resolveValidationCatalogCopy(
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details ?? '',
error.details,
params
),
displayItemLabel: translateCatalogMessage(
@@ -337,7 +330,7 @@ function resolveValidationCatalogCopy(
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedCatalogErrorMessage {
): ResolvedErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
@@ -348,17 +341,7 @@ export function resolveNodeValidationErrorMessage(
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) {
return resolveValidationCatalogCopy(
error,
context,
'unknown_validation_error',
{
...UNKNOWN_VALIDATION_ERROR_RULE,
copyKeys: getRawDetailsOnlyCopyKeys(error)
}
)
}
if (!rule) return {}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}

View File

@@ -1,60 +1,50 @@
<template>
<div class="px-4 pb-2">
<TransitionGroup
tag="ul"
name="list-scale"
class="relative m-0 list-none space-y-1 p-0"
<div
v-for="group in missingMediaGroups"
:key="group.mediaType"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<li
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
</button>
</span>
<Button
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel
})
"
@click.stop="emit('locateNode', item.nodeId)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</TransitionGroup>
<!-- Media type header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
>
<i
aria-hidden="true"
:class="MEDIA_TYPE_ICONS[group.mediaType]"
class="mr-1 size-3.5 align-text-bottom"
/>
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
({{ group.items.length }})
</p>
</div>
<!-- Media file rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingMediaRow
v-for="item in group.items"
:key="item.name"
:item="item"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { app } from '@/scripts/app'
import { st } from '@/i18n'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -63,64 +53,9 @@ const emit = defineEmits<{
const { t } = useI18n()
interface MissingMediaItemEntry {
key: string
nodeId: string
displayItemLabel: string
}
const missingMediaItems = computed(() => {
const items: MissingMediaItemEntry[] = []
for (const group of missingMediaGroups) {
for (const mediaItem of group.items) {
for (const nodeRef of mediaItem.referencingNodes) {
const nodeId = String(nodeRef.nodeId)
items.push({
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
nodeId,
displayItemLabel: getDisplayItemLabel(
nodeId,
nodeRef.nodeType ?? mediaItem.representative.nodeType,
nodeRef.widgetName
)
})
}
}
}
return items.sort(compareMissingMediaItems)
})
function getDisplayItemLabel(
nodeId: string,
nodeType: string,
widgetName: string
) {
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
return resolveMissingMediaItemLabel({
nodeDisplayName,
nodeType,
widgetName
}).displayItemLabel
}
function compareMissingMediaItems(
a: MissingMediaItemEntry,
b: MissingMediaItemEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.displayItemLabel.localeCompare(b.displayItemLabel)
)
}
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, nodeId)
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
image: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
audio: 'icon-[lucide--music]'
}
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingMedia.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent class="max-h-72">
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
<div class="flex items-center gap-2">
<img
v-if="mediaType === 'image'"
:src="getPreviewUrl(option.value)"
alt=""
class="size-8 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<video
v-else-if="mediaType === 'video'"
aria-hidden="true"
:src="getPreviewUrl(option.value)"
class="size-8 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
/>
<span class="min-w-0 truncate">{{ option.name }}</span>
</div>
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import type { MediaType } from '@/platform/missingMedia/types'
import { api } from '@/scripts/api'
const {
options,
showDivider = false,
mediaType
} = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
mediaType: MediaType
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const SEARCH_THRESHOLD = 4
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function getPreviewUrl(filename: string): string {
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
}
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -0,0 +1,318 @@
<template>
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
<!-- File header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file] size-4 shrink-0"
/>
<!-- Single node: show node display name instead of filename -->
<template v-if="isSingleNode">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ item.referencingNodes[0].nodeId }}
</span>
<p
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="singleNodeLabel"
>
{{ singleNodeLabel }}
</p>
</template>
<!-- Multiple nodes: show filename with count -->
<p
v-else
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="displayName"
>
{{ displayName }}
({{ item.referencingNodes.length }})
</p>
<!-- Confirm button (visible when pending selection exists) -->
<Button
data-testid="missing-media-confirm-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
:disabled="!isPending"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="confirmSelection(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="isPending ? 'text-primary' : 'text-foreground'"
/>
</Button>
<!-- Locate button (single node only) -->
<Button
v-if="isSingleNode"
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
<!-- Expand button (multiple nodes only) -->
<Button
v-if="!isSingleNode"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingMedia.collapseNodes')
: t('rightSidePanel.missingMedia.expandNodes')
"
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleExpand(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Referencing nodes (expandable) -->
<TransitionCollapse>
<div
v-if="expanded && item.referencingNodes.length > 1"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="nodeRef in item.referencingNodes"
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ nodeRef.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(nodeRef.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card (uploading, uploaded, or library select) -->
<TransitionCollapse>
<div
v-if="isPending || isUploading"
data-testid="missing-media-status-card"
role="status"
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="currentUpload?.status === 'uploading'"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ pendingDisplayName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="currentUpload?.status === 'uploading'">
{{ t('rightSidePanel.missingMedia.uploading') }}
</template>
<template v-else-if="currentUpload?.status === 'uploaded'">
{{ t('rightSidePanel.missingMedia.uploaded') }}
</template>
<template v-else>
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
</template>
</span>
</div>
<Button
data-testid="missing-media-cancel-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="cancelSelection(item.name)"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Upload + Library (when no pending selection) -->
<TransitionCollapse>
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
<!-- Upload dropzone -->
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
<button
data-testid="missing-media-upload-dropzone"
type="button"
:class="
cn(
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
isOverDropZone && 'border-primary text-primary'
)
"
@click="openFilePicker()"
>
{{
t('rightSidePanel.missingMedia.uploadFile', {
type: extensionHint
})
}}
</button>
</div>
<!-- OR separator + Use from Library -->
<MissingMediaLibrarySelect
data-testid="missing-media-library-select"
:model-value="undefined"
:options="libraryOptions"
:show-divider="true"
:media-type="item.mediaType"
@select="handleLibrarySelect(item.name, $event)"
/>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDropZone, useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
useMissingMediaInteractions,
getNodeDisplayLabel,
getMediaDisplayName
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
const { item, showNodeIdBadge } = defineProps<{
item: MissingMediaViewModel
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
}>()
const { t } = useI18n()
const store = useMissingMediaStore()
const { uploadState, pendingSelection } = storeToRefs(store)
const {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
} = useMissingMediaInteractions()
const displayName = getMediaDisplayName(item.name)
const isSingleNode = item.referencingNodes.length === 1
const singleNodeLabel = isSingleNode
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
: ''
const acceptType = getAcceptType(item.mediaType)
const extensionHint = getExtensionHint(item.mediaType)
const expanded = computed(() => isExpanded(item.name))
const matchingCandidate = computed(() => {
const candidates = store.missingMediaCandidates
if (!candidates?.length) return null
return candidates.find((c) => c.name === item.name) ?? null
})
const libraryOptions = computed(() => {
const candidate = matchingCandidate.value
if (!candidate) return []
return getLibraryOptions(candidate)
})
const isPending = computed(() => hasPendingSelection(item.name))
const isUploading = computed(
() => uploadState.value[item.name]?.status === 'uploading'
)
const currentUpload = computed(() => uploadState.value[item.name])
const pendingDisplayName = computed(() => {
if (currentUpload.value) return currentUpload.value.fileName
const pending = pendingSelection.value[item.name]
return pending ? getMediaDisplayName(pending) : ''
})
const dropZoneRef = ref<HTMLElement | null>(null)
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (_files, event) => {
event?.stopPropagation()
const file = _files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
}
})
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
accept: acceptType,
multiple: false
})
onFileSelected((files) => {
const file = files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
})
</script>

View File

@@ -0,0 +1,224 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
MissingMediaCandidate,
MediaType
} from '@/platform/missingMedia/types'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isCloud } from '@/platform/distribution/types'
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
image: ACCEPTED_IMAGE_TYPES,
video: ACCEPTED_VIDEO_TYPES,
audio: 'audio/*'
}
function getMediaComboWidget(
candidate: MissingMediaCandidate
): { node: LGraphNode; widget: IComboWidget } | null {
const graph = app.rootGraph
if (!graph || candidate.nodeId == null) return null
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
if (!node) return null
const widget = node.widgets?.find(
(w) => w.name === candidate.widgetName && w.type === 'combo'
) as IComboWidget | undefined
if (!widget) return null
return { node, widget }
}
function resolveLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
const result = getMediaComboWidget(candidate)
if (!result) return []
return resolveComboValues(result.widget)
.filter((v) => v !== candidate.name)
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
}
function applyValueToNodes(
candidates: MissingMediaCandidate[],
name: string,
newValue: string
) {
const matching = candidates.filter((c) => c.name === name)
for (const c of matching) {
const result = getMediaComboWidget(c)
if (!result) continue
addToComboValues(result.widget, newValue)
result.widget.value = newValue
result.widget.callback?.(newValue)
result.node.graph?.setDirtyCanvas(true, true)
}
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
/**
* Resolve display name for a media file.
* Cloud widgets store asset hashes as values; this resolves them to
* human-readable names via assetsStore.getInputName().
*/
export function getMediaDisplayName(name: string): string {
if (!isCloud) return name
return useAssetsStore().getInputName(name)
}
export function useMissingMediaInteractions() {
const store = useMissingMediaStore()
const assetsStore = useAssetsStore()
function isExpanded(key: string): boolean {
return store.expandState[key] ?? false
}
function toggleExpand(key: string) {
store.expandState[key] = !isExpanded(key)
}
function getAcceptType(mediaType: MediaType): string {
return MEDIA_ACCEPT_MAP[mediaType]
}
function getExtensionHint(mediaType: MediaType): string {
if (mediaType === 'audio') return 'audio'
const exts = MEDIA_ACCEPT_MAP[mediaType]
.split(',')
.map((mime) => mime.split('/')[1])
.join(', ')
return `${exts}, ...`
}
function getLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
return resolveLibraryOptions(candidate)
}
/** Step 1: Store selection from library (does not apply yet). */
function handleLibrarySelect(name: string, value: string) {
store.pendingSelection[name] = value
}
/** Step 1: Upload file and store result as pending (does not apply yet). */
async function handleUpload(file: File, name: string, mediaType: MediaType) {
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
useToastStore().addAlert(
st(
'toastMessages.unsupportedFileType',
'Unsupported file type. Please select a valid file.'
)
)
return
}
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
try {
const body = new FormData()
body.append('image', file)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
return
}
const data = await resp.json()
const uploadedPath: string = data.subfolder
? `${data.subfolder}/${data.name}`
: data.name
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
store.pendingSelection[name] = uploadedPath
// Refresh assets store (non-critical — upload already succeeded)
try {
await assetsStore.updateInputs()
} catch {
// Asset list refresh failed but upload is valid; selection can proceed
}
} catch {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
}
}
/** Step 2: Apply pending selection to widgets and remove from missing list. */
function confirmSelection(name: string) {
const value = store.pendingSelection[name]
if (!value || !store.missingMediaCandidates) return
applyValueToNodes(store.missingMediaCandidates, name, value)
store.removeMissingMediaByName(name)
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function cancelSelection(name: string) {
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function hasPendingSelection(name: string): boolean {
return name in store.pendingSelection
}
return {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
}
}

View File

@@ -421,13 +421,7 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.referencingNodes[0]).toMatchObject({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
})
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
const otherGroup = result.find((g) => g.name === 'other.png')
expect(otherGroup?.referencingNodes).toHaveLength(1)

View File

@@ -256,17 +256,13 @@ export function groupCandidatesByName(
if (existing) {
existing.referencingNodes.push({
nodeId: c.nodeId,
nodeType: c.nodeType,
widgetName: c.widgetName
})
} else {
map.set(c.name, {
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
]
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
})
}
}

View File

@@ -67,12 +67,18 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('clearMissingMedia resets candidates and aborts verification', () => {
it('clearMissingMedia resets all state including interaction state', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
const controller = store.createVerificationAbortController()
store.clearMissingMedia()
@@ -81,6 +87,9 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
expect(store.missingMediaCount).toBe(0)
expect(controller.signal.aborted).toBe(true)
expect(store.expandState).toEqual({})
expect(store.uploadState).toEqual({})
expect(store.pendingSelection).toEqual({})
})
it('missingMediaNodeIds tracks unique node IDs', () => {
@@ -136,6 +145,47 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('removeMissingMediaByName clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByName('photo.png')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('createVerificationAbortController aborts previous controller', () => {
const store = useMissingMediaStore()
const first = store.createVerificationAbortController()
@@ -214,6 +264,40 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('cleans interaction state for removed names', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByNodeId('1')
@@ -313,5 +397,21 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'shared.png'),
makeCandidate('65:80:5', 'shared.png'),
makeCandidate('65:70:64', 'only-interior.png')
])
store.pendingSelection['shared.png'] = 'library/shared.png'
store.pendingSelection['only-interior.png'] = 'library/interior.png'
store.removeMissingMediaByPrefix('65:70:')
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
})
})
})

View File

@@ -56,6 +56,14 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
)
})
// Interaction state — persists across component re-mounts
const expandState = ref<Record<string, boolean>>({})
const uploadState = ref<
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
>({})
/** Pending selection: value to apply on confirm. */
const pendingSelection = ref<Record<string, string>>({})
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
@@ -76,20 +84,58 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
function clearInteractionStateForName(name: string) {
delete expandState.value[name]
delete uploadState.value[name]
delete pendingSelection.value[name]
}
function removeMissingMediaByName(name: string) {
if (!missingMediaCandidates.value) return
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => m.name !== name
)
clearInteractionStateForName(name)
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter(
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByNodeId(nodeId: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
@@ -104,6 +150,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
*/
function removeMissingMediaByPrefix(prefix: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingMediaCandidate[] = []
for (const m of missingMediaCandidates.value) {
// Preserve candidates without a nodeId; they cannot belong to any
@@ -113,12 +160,19 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
remaining.push(m)
continue
}
if (!String(m.nodeId).startsWith(prefix)) {
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
remaining.push(m)
}
}
if (remaining.length === missingMediaCandidates.value.length) return
if (removedNames.size === 0) return
missingMediaCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingMedia(media: MissingMediaCandidate[]) {
@@ -139,6 +193,9 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
@@ -151,6 +208,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
setMissingMedia,
addMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
@@ -158,6 +216,10 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
}
})

View File

@@ -27,10 +27,8 @@ export interface MissingMediaCandidate {
export interface MissingMediaViewModel {
name: string
mediaType: MediaType
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
nodeType?: string
widgetName: string
}>
}

View File

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
@@ -18,6 +19,14 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
import SwapNodesCard from './SwapNodesCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function makeGroups(count = 2): SwapNodeGroup[] {
return Array.from({ length: count }, (_, i) => ({
type: `Type${i}`,
@@ -47,13 +56,19 @@ function mountCard(
...(callbacks?.onReplace ? { onReplace: callbacks.onReplace } : {})
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue]
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n]
}
})
}
describe('SwapNodesCard', () => {
describe('Rendering', () => {
it('renders guidance message', () => {
const { container } = mountCard()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('p')).not.toBeNull()
})
it('renders correct number of SwapNodeGroupRow components', () => {
const { container } = mountCard({ swapNodeGroups: makeGroups(3) })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access

View File

@@ -1,5 +1,15 @@
<template>
<div class="mt-2 px-4 pb-2">
<!-- Sub-label: guidance message shown above all swap groups -->
<p class="m-0 pb-5 text-sm/relaxed text-muted-foreground">
{{
t(
'nodeReplacement.swapNodesGuide',
'The following nodes can be automatically replaced with compatible alternatives.'
)
}}
</p>
<!-- Group Rows -->
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"
@@ -12,9 +22,12 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean

View File

@@ -1,9 +1,8 @@
<template>
<Dialog v-model:open="visible">
<DialogPortal>
<DialogOverlay v-reka-z-index />
<DialogOverlay />
<DialogContent
v-reka-z-index
size="md"
:aria-labelledby="titleId"
@pointer-down-outside.prevent
@@ -126,7 +125,6 @@ import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'

View File

@@ -1,77 +0,0 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { cleanup, render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SecretFormDialog from './SecretFormDialog.vue'
vi.mock('../composables/useSecretForm', () => ({
useSecretForm: () => ({
form: { provider: '', name: '', secretValue: '' },
errors: {},
loading: false,
apiError: '',
providerOptions: [],
handleSubmit: vi.fn()
})
}))
vi.mock('primevue/inputtext', () => ({
default: { name: 'InputText', template: '<input />' }
}))
vi.mock('primevue/password', () => ({
default: { name: 'Password', template: '<input type="password" />' }
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: { name: 'Button', template: '<button><slot /></button>' }
}))
vi.mock('@/components/ui/select/Select.vue', () => ({
default: { name: 'Select', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectContent.vue', () => ({
default: { name: 'SelectContent', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: { name: 'SelectItem', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('SecretFormDialog z-index stacking', () => {
afterEach(() => {
cleanup()
})
let openModalZIndex: number
beforeEach(() => {
const openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
openModalZIndex = Number(openModal.style.zIndex)
})
it('renders above a modal that is already open', async () => {
render(SecretFormDialog, {
global: { plugins: [PrimeVue, i18n] },
props: { visible: true }
})
const content = await screen.findByRole('dialog')
expect(Number(content.style.zIndex)).toBeGreaterThan(openModalZIndex)
})
})

View File

@@ -379,16 +379,6 @@ describe('ImagePreview', () => {
expect(gridThumbnails).toHaveLength(2)
})
it('requests lightweight thumbnails for grid cells instead of full-resolution images', () => {
renderImagePreview()
const gridImages = screen.getAllByRole('img')
expect(gridImages).toHaveLength(2)
for (const img of gridImages) {
expect(img.getAttribute('src')).toMatch(/[?&]preview=/)
}
})
it('defaults to gallery mode for single image', () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]

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