mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-31 02:14:53 +00:00
Compare commits
3 Commits
settings-u
...
pablo_hack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714a11872f | ||
|
|
aa407e7cd4 | ||
|
|
a9dce7aa20 |
@@ -1,6 +1,6 @@
|
||||
<!-- A generalized form item for rendering in a form. -->
|
||||
<template>
|
||||
<div class="flex min-h-10 flex-row items-center gap-2">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span
|
||||
:id="`${props.id}-label`"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<span>
|
||||
<template v-for="(sequence, index) in keySequences" :key="index">
|
||||
<Tag
|
||||
class="min-w-6 justify-center bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground uppercase"
|
||||
class="bg-interface-menu-keybind-surface-default text-base-foreground"
|
||||
:severity="isModified ? 'info' : 'secondary'"
|
||||
>
|
||||
{{ sequence }}
|
||||
</Tag>
|
||||
<span v-if="index < keySequences.length - 1" class="px-0.5" />
|
||||
<span v-if="index < keySequences.length - 1" class="px-2">+</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +23,9 @@ export const buttonVariants = cva({
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
|
||||
outline:
|
||||
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -55,7 +57,8 @@ const variants = [
|
||||
'link',
|
||||
'base',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'gradient',
|
||||
'outline'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
@@ -57,30 +57,26 @@
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="w-full text-sm">
|
||||
<template v-if="activePanel">
|
||||
<Suspense>
|
||||
<component :is="activePanel.component" v-bind="activePanel.props" />
|
||||
<template #fallback>
|
||||
<div>
|
||||
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
<template v-else-if="inSearch">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
</template>
|
||||
<template v-else-if="activeSettingCategory">
|
||||
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
|
||||
<ColorPaletteMessage
|
||||
v-if="activeSettingCategory.label === 'Appearance'"
|
||||
/>
|
||||
<SettingsPanel
|
||||
:setting-groups="sortedGroups(activeSettingCategory)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="activePanel">
|
||||
<Suspense>
|
||||
<component :is="activePanel.component" v-bind="activePanel.props" />
|
||||
<template #fallback>
|
||||
<div>
|
||||
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
<template v-else-if="inSearch">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
</template>
|
||||
<template v-else-if="activeSettingCategory">
|
||||
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
|
||||
<ColorPaletteMessage
|
||||
v-if="activeSettingCategory.label === 'Appearance'"
|
||||
/>
|
||||
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
|
||||
</template>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="setting-group">
|
||||
<div v-if="divider" class="my-8 border-t border-border-default" />
|
||||
<h3 class="text-base">
|
||||
<Divider v-if="divider" />
|
||||
<h3>
|
||||
<span v-if="group.category" class="text-muted">
|
||||
{{
|
||||
$t(
|
||||
@@ -19,7 +19,7 @@
|
||||
v-for="setting in group.settings.filter((s) => !s.deprecated)"
|
||||
:key="setting.id"
|
||||
:data-setting-id="setting.id"
|
||||
class="setting-item mb-2"
|
||||
class="setting-item mb-4"
|
||||
>
|
||||
<SettingItem :setting="setting" />
|
||||
</div>
|
||||
@@ -27,6 +27,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import SettingItem from '@/platform/settings/components/SettingItem.vue'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
|
||||
/**
|
||||
@@ -9,7 +11,13 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
|
||||
featureId: 'node-search',
|
||||
typeformId: 'goZLqjKL',
|
||||
triggerThreshold: 3,
|
||||
delayMs: 5000
|
||||
delayMs: 5000,
|
||||
isFeatureActive: () => {
|
||||
const settingStore = useSettingStore()
|
||||
return (
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl') !== 'litegraph (legacy)'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,17 @@ describe('useSurveyEligibility', () => {
|
||||
|
||||
expect(isEligible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is not eligible when isFeatureActive returns false', () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const { isEligible } = useSurveyEligibility({
|
||||
...defaultConfig,
|
||||
isFeatureActive: () => false
|
||||
})
|
||||
|
||||
expect(isEligible.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface FeatureSurveyConfig {
|
||||
triggerThreshold?: number
|
||||
delayMs?: number
|
||||
enabled?: boolean
|
||||
isFeatureActive?: () => boolean
|
||||
}
|
||||
|
||||
interface SurveyState {
|
||||
@@ -61,8 +62,13 @@ export function useSurveyEligibility(
|
||||
|
||||
const hasOptedOut = computed(() => state.value.optedOut)
|
||||
|
||||
const isFeatureActive = computed(
|
||||
() => resolvedConfig.value.isFeatureActive?.() ?? true
|
||||
)
|
||||
|
||||
const isEligible = computed(() => {
|
||||
if (!isSurveyEnabled.value) return false
|
||||
if (!isFeatureActive.value) return false
|
||||
if (!isNightlyLocalhost.value) return false
|
||||
if (!hasReachedThreshold.value) return false
|
||||
if (hasSeenSurvey.value) return false
|
||||
|
||||
@@ -55,6 +55,33 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
mockMediaAssets: {
|
||||
media: ref([]),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
fetchMediaList: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
},
|
||||
mockResolveOutputAssetItems: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: (...args: unknown[]) =>
|
||||
mockResolveOutputAssetItems(...args)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -484,6 +511,229 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown multi-output jobs', () => {
|
||||
interface MultiOutputInstance extends ComponentPublicInstance {
|
||||
outputItems: FormDropdownItem[]
|
||||
}
|
||||
|
||||
function makeMultiOutputAsset(
|
||||
jobId: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
outputCount: number
|
||||
) {
|
||||
return {
|
||||
id: jobId,
|
||||
name,
|
||||
preview_url: `/api/view?filename=${name}&type=output`,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId,
|
||||
subfolder: '',
|
||||
outputCount,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountMultiOutput(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<MultiOutputInstance> {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: { widget, modelValue, assetKind: 'image' as const },
|
||||
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
|
||||
}) as unknown as VueWrapper<MultiOutputInstance>
|
||||
}
|
||||
|
||||
const defaultWidget = () =>
|
||||
createMockWidget<string | undefined>({
|
||||
value: 'output_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockMediaAssets.media.value = []
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
})
|
||||
|
||||
it('shows all outputs after resolving multi-output jobs', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1-5-output_001.png',
|
||||
name: 'output_001.png',
|
||||
preview_url: '/api/view?filename=output_001.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_002.png',
|
||||
name: 'output_002.png',
|
||||
preview_url: '/api/view?filename=output_002.png&type=output',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-1-5-output_003.png',
|
||||
name: 'output_003.png',
|
||||
preview_url: '/api/view?filename=output_003.png&type=output',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
|
||||
'output_001.png [output]',
|
||||
'output_002.png [output]',
|
||||
'output_003.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows preview output when job has only one output', () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
|
||||
]
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'single.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: [] }
|
||||
})
|
||||
const wrapper = mountMultiOutput(widget, 'single.png')
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves two multi-output jobs independently', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
|
||||
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
|
||||
if (meta.jobId === 'job-A') {
|
||||
return [
|
||||
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toContain('a1.png [output]')
|
||||
expect(names).toContain('a2.png [output]')
|
||||
expect(names).toContain('b1.png [output]')
|
||||
expect(names).toContain('b2.png [output]')
|
||||
})
|
||||
|
||||
it('resolves outputs when allOutputs already contains all items', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
name: 'preview.png',
|
||||
preview_url: '/api/view?filename=preview.png&type=output',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-complete',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 2,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'out1.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'out2.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValue([
|
||||
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
|
||||
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
|
||||
])
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.vm.outputItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-complete' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
const names = wrapper.vm.outputItems.map((i) => i.name)
|
||||
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
|
||||
})
|
||||
|
||||
it('falls back to preview when resolver rejects', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
|
||||
]
|
||||
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const wrapper = mountMultiOutput(defaultWidget(), undefined)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve multi-output job',
|
||||
'job-fail',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.vm.outputItems).toHaveLength(1)
|
||||
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { capitalize } from 'es-toolkit'
|
||||
import { computed, provide, ref, toRef, watch } from 'vue'
|
||||
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
@@ -31,6 +31,9 @@ import type {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -153,24 +156,82 @@ function assetKindToMediaType(kind: AssetKind): string {
|
||||
return kind === 'mesh' ? '3D' : kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-job cache of resolved outputs for multi-output jobs.
|
||||
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
|
||||
* fetches full outputs through getJobDetail (itself LRU-cached).
|
||||
*/
|
||||
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
|
||||
const pendingJobIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => outputMediaAssets.media.value,
|
||||
(assets, _, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
pendingJobIds.clear()
|
||||
|
||||
for (const asset of assets) {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
|
||||
if (
|
||||
outputCount <= 1 ||
|
||||
resolvedByJobId.value.has(meta.jobId) ||
|
||||
pendingJobIds.has(meta.jobId)
|
||||
)
|
||||
continue
|
||||
|
||||
pendingJobIds.add(meta.jobId)
|
||||
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
|
||||
.then((resolved) => {
|
||||
if (cancelled || !resolved.length) return
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
next.set(meta.jobId, resolved)
|
||||
resolvedByJobId.value = next
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to resolve multi-output job', meta.jobId, error)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingJobIds.delete(meta.jobId)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return []
|
||||
|
||||
const targetMediaType = assetKindToMediaType(props.assetKind!)
|
||||
const outputFiles = outputMediaAssets.media.value.filter(
|
||||
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
const items: FormDropdownItem[] = []
|
||||
|
||||
return outputFiles.map((asset) => {
|
||||
const assets = outputMediaAssets.media.value.flatMap((asset) => {
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
|
||||
return resolved ?? [asset]
|
||||
})
|
||||
|
||||
for (const asset of assets) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
seen.add(asset.id)
|
||||
const annotatedPath = `${asset.name} [output]`
|
||||
return {
|
||||
items.push({
|
||||
id: `output-${annotatedPath}`,
|
||||
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(annotatedPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user