Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
436798d23d fix: designer QA polish for V2 Node Search (#8987)
- Category sidebar: nested expanded subfolders wrap in bg-secondary-background container
- Chevrons appear on sidebar hover for categories with children
- Filter chips: three visual states (active/applied/default) with applied count
- Preview card pricing badge truncation for overflow
- Pass appliedFilters prop to NodeSearchFilterBar
- Fix paddingLeft to preserve depth hierarchy when chevrons are visible
- Deduplicate button template in NodeSearchCategoryTreeNode
- Restore draftTypes.ts removed by merge conflict with #8993/#8519

Amp-Thread-ID: https://ampcode.com/threads/T-019c87f4-aa28-7290-bdf0-ea5f86aacde3
2026-02-22 16:54:34 -08:00
55 changed files with 253 additions and 1832 deletions

View File

@@ -1,118 +0,0 @@
name: 'CI: OSS Assets Validation'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
push:
branches: [main, dev*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
validate-fonts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
env:
DISTRIBUTION: localhost
- name: Check for proprietary fonts in dist
run: |
set -euo pipefail
echo '🔍 Checking dist for proprietary ABCROM fonts...'
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
echo '❌ ERROR: dist/ directory missing or empty!'
exit 1
fi
# Check for ABCROM font files
if find dist/ -type f -iname '*abcrom*' \
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \) \
-print -quit | grep -q .; then
echo ''
echo '❌ ERROR: Found proprietary ABCROM font files in dist!'
echo ''
find dist/ -type f -iname '*abcrom*' \
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \)
echo ''
echo 'ABCROM fonts are proprietary and should not ship to OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use conditional font loading based on isCloud'
echo '2. Ensure fonts are dynamically imported, not bundled'
echo '3. Check vite config for font handling'
exit 1
fi
echo '✅ No proprietary fonts found in dist'
validate-licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate production dependency licenses
run: |
set -euo pipefail
echo '🔍 Checking production dependency licenses...'
# Use license-checker-rseidelsohn (actively maintained fork, handles monorepos)
# Exclude internal @comfyorg packages from license check
# Run in if condition to capture exit code
if npx license-checker-rseidelsohn@4 \
--production \
--summary \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
echo ''
echo '✅ All production dependency licenses are approved!'
else
echo ''
echo '❌ ERROR: Found dependencies with non-approved licenses!'
echo ''
echo 'To fix this:'
echo '1. Check the license of the problematic package'
echo '2. Find an alternative package with an approved license'
echo '3. If the license is safe and OSI-approved, add it to the --onlyAllow list'
echo ''
echo 'For more info on OSI-approved licenses:'
echo 'https://opensource.org/licenses'
exit 1
fi

1
.gitignore vendored
View File

@@ -64,7 +64,6 @@ browser_tests/local/
dist.zip
/temp/
/tmp/
# Generated JSON Schemas
/schemas/

View File

@@ -1,33 +0,0 @@
# Widget Serialization: `widget.serialize` vs `widget.options.serialize`
Two properties named `serialize` exist at different levels of a widget object. They control different serialization layers and are checked by completely different code paths.
**`widget.serialize`** — Controls **workflow persistence**. Checked by `LGraphNode.serialize()` and `configure()` when reading/writing `widgets_values` in the workflow JSON. When `false`, the widget is skipped in both serialization and deserialization. Used for UI-only widgets (image previews, progress text, audio players). Typed as `IBaseWidget.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
**`widget.options.serialize`** — Controls **prompt/API serialization**. Checked by `executionUtil.ts` when building the API payload sent to the backend. When `false`, the widget is excluded from prompt inputs. Used for client-side-only controls (`control_after_generate`, combo filter lists) that the server doesn't need. Typed as `IWidgetOptions.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
These correspond to the two data formats in `ComfyMetadata` embedded in output files (PNG, GLTF, WebM, AVIF, etc.): `widget.serialize``ComfyMetadataTags.WORKFLOW`, `widget.options.serialize``ComfyMetadataTags.PROMPT`.
## Permutation table
| `widget.serialize` | `widget.options.serialize` | In workflow? | In prompt? | Examples |
| ------------------ | -------------------------- | ------------ | ---------- | -------------------------------------------------------------------- |
| ✅ default | ✅ default | Yes | Yes | seed, cfg, sampler_name |
| ✅ default | ❌ false | Yes | No | control_after_generate, combo filter list |
| ❌ false | ✅ default | No | Yes | No current usage (would be a transient value computed at queue time) |
| ❌ false | ❌ false | No | No | Image/video previews, audio players, progress text |
## Gotchas
- `addWidget('combo', name, value, cb, { serialize: false })` puts `serialize` into `widget.options`, **not** onto `widget` directly. These are different properties consumed by different systems.
- `LGraphNode.serialize()` checks `widget.serialize === false` (line 967). It does **not** check `widget.options.serialize`. A widget with `options.serialize = false` is still included in `widgets_values`.
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
## Code references
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
- `widget.options.serialize` checked: `src/utils/executionUtil.ts`
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
- Metadata types: `src/types/metadataTypes.ts`

View File

@@ -39,9 +39,7 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml'
'src/scripts/ui/components/splitButton.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.3",
"version": "1.41.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1,189 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import VirtualGrid from './VirtualGrid.vue'
type TestItem = { key: string; name: string }
let mockedWidth: Ref<number>
let mockedHeight: Ref<number>
let mockedScrollY: Ref<number>
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
return {
...actual,
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
useScroll: () => ({ y: mockedScrollY })
}
})
beforeEach(() => {
mockedWidth = ref(400)
mockedHeight = ref(200)
mockedScrollY = ref(0)
})
function createItems(count: number): TestItem[] {
return Array.from({ length: count }, (_, i) => ({
key: `item-${i}`,
name: `Item ${i}`
}))
}
describe('VirtualGrid', () => {
const defaultGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem'
}
it('renders items within the visible range', async () => {
const items = createItems(100)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
wrapper.unmount()
})
it('provides correct index in slot props', async () => {
const items = createItems(20)
const receivedIndices: number[] = []
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 100,
maxColumns: 1,
bufferRows: 0
},
slots: {
item: ({ index }: { index: number }) => {
receivedIndices.push(index)
return null
}
},
attachTo: document.body
})
await nextTick()
expect(receivedIndices.length).toBeGreaterThan(0)
expect(receivedIndices[0]).toBe(0)
for (let i = 1; i < receivedIndices.length; i++) {
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
}
wrapper.unmount()
})
it('respects maxColumns prop', async () => {
const items = createItems(10)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
maxColumns: 2
},
attachTo: document.body
})
await nextTick()
const gridElement = wrapper.find('[style*="display: grid"]')
expect(gridElement.exists()).toBe(true)
const gridEl = gridElement.element as HTMLElement
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
wrapper.unmount()
})
it('renders empty when no items provided', async () => {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items: [],
gridStyle: defaultGridStyle
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
}
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBe(0)
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200
mockedScrollY.value = 0
const items = createItems(20)
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 200,
maxColumns: 4,
bufferRows: 0
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length % 4).toBe(0)
wrapper.unmount()
})
})

View File

@@ -1,16 +1,17 @@
<template>
<div
ref="container"
class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="(item, i) in renderedItems"
v-for="item in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<slot name="item" :item :index="state.start + i" />
<slot name="item" :item="item" />
</div>
</div>
<div :style="bottomSpacerStyle" />
@@ -65,10 +66,9 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() => {
if (maxColumns !== Infinity) return maxColumns
return Math.floor(width.value / itemWidth.value) || 1
})
const cols = computed(() =>
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
)
const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle
@@ -101,9 +101,8 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
function rowsToHeight(itemsCount: number): string {
const rows = Math.ceil(itemsCount / cols.value)
return `${rows * itemHeight.value}px`
function rowsToHeight(rows: number): string {
return `${(rows / cols.value) * itemHeight.value}px`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
@@ -119,10 +118,11 @@ whenever(
}
)
function updateItemSize(): void {
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
// Don't update item size if the first item is not rendered yet
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {

View File

@@ -24,8 +24,8 @@
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<div class="flex flex-wrap gap-2 empty:hidden overflow-hidden">
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>

View File

@@ -1,85 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ jobMenuEntries: [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
fn: T
) => fn
})
}))
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
const QueueOverlayHeaderStub = {
template: '<div />'
}
const JobFiltersBarStub = {
template: '<div />'
}
const JobAssetsListStub = {
name: 'JobAssetsList',
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'pending'
})
const mountComponent = () =>
mount(QueueOverlayExpanded, {
props: {
headerTitle: 'Jobs',
queuedCount: 1,
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
displayedJobGroups: [],
hasFailedJobs: false
},
global: {
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
}
}
})
describe('QueueOverlayExpanded', () => {
it('renders JobAssetsList', () => {
const wrapper = mountComponent()
expect(wrapper.find('.job-assets-list-stub').exists()).toBe(true)
})
it('re-emits list item actions from JobAssetsList', async () => {
const wrapper = mountComponent()
const job = createJob()
const jobAssetsList = wrapper.findComponent({ name: 'JobAssetsList' })
jobAssetsList.vm.$emit('cancel-item', job)
jobAssetsList.vm.$emit('delete-item', job)
jobAssetsList.vm.$emit('view-item', job)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('cancelItem')?.[0]).toEqual([job])
expect(wrapper.emitted('deleteItem')?.[0]).toEqual([job])
expect(wrapper.emitted('viewItem')?.[0]).toEqual([job])
})
})

View File

@@ -2,6 +2,8 @@
<div class="flex w-full flex-col gap-4">
<QueueOverlayHeader
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
@clear-history="$emit('clearHistory')"
@clear-queued="$emit('clearQueued')"
@@ -21,7 +23,7 @@
/>
<div class="flex-1 min-h-0 overflow-y-auto">
<JobAssetsList
<JobGroupsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@@ -53,11 +55,13 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobAssetsList from './job/JobAssetsList.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
import JobGroupsList from './job/JobGroupsList.vue'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'

View File

@@ -51,10 +51,9 @@ const i18n = createI18n({
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
running: 'running',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
clearQueueTooltip: 'Clear queue',
clearAllJobsTooltip: 'Cancel all running jobs',
moreOptions: 'More options',
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
@@ -68,6 +67,8 @@ const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
headerTitle: 'Job queue',
showConcurrentIndicator: true,
concurrentWorkflowCount: 2,
queuedCount: 3,
...props
},
@@ -83,28 +84,40 @@ describe('QueueOverlayHeader', () => {
mockSetSetting.mockClear()
})
it('renders header title', () => {
const wrapper = mountHeader()
it('renders header title and concurrent indicator when enabled', () => {
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
expect(wrapper.text()).toContain('Job queue')
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
expect(indicator.exists()).toBe(true)
expect(indicator.text()).toContain('3')
expect(indicator.text()).toContain('running')
})
it('shows clear queue text and emits clear queued', async () => {
it('hides concurrent indicator when flag is false', () => {
const wrapper = mountHeader({ showConcurrentIndicator: false })
expect(wrapper.text()).toContain('Job queue')
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('shows queued summary and emits clear queued', async () => {
const wrapper = mountHeader({ queuedCount: 4 })
expect(wrapper.text()).toContain('Clear queue')
expect(wrapper.text()).not.toContain('4 queued')
expect(wrapper.text()).toContain('4')
expect(wrapper.text()).toContain('queued')
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
})
it('disables clear queued button when queued count is zero', () => {
it('hides clear queued button when queued count is zero', () => {
const wrapper = mountHeader({ queuedCount: 0 })
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('Clear queue')
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
false
)
})
it('emits clear history from the menu', async () => {

View File

@@ -4,19 +4,34 @@
>
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
<span>{{ headerTitle }}</span>
<span
v-if="showConcurrentIndicator"
class="ml-4 inline-flex items-center gap-1 text-blue-100"
>
<span class="inline-block size-2 rounded-full bg-blue-100" />
<span>
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
</span>
</div>
<div
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
>
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
}}</span>
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<Button
v-if="queuedCount > 0"
v-tooltip.top="clearAllJobsTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
:disabled="queuedCount === 0"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
@@ -36,6 +51,8 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
}>()

View File

@@ -17,6 +17,8 @@
v-model:selected-sort-mode="selectedSortMode"
class="flex-1 min-h-0"
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@@ -181,6 +183,13 @@ const headerTitle = computed(() => {
})
})
const concurrentWorkflowCount = computed(
() => executionStore.runningWorkflowCount
)
const showConcurrentIndicator = computed(
() => concurrentWorkflowCount.value > 1
)
const {
selectedJobTab,
selectedWorkflowFilter,

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<div ref="sidebarRef" class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<!-- Preset categories -->
<div class="flex flex-col px-1">
<button
@@ -38,6 +38,7 @@
:node="category"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
:show-chevrons="isHovered"
@select="selectCategory"
/>
</div>
@@ -45,6 +46,7 @@
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -64,6 +66,9 @@ const selectedCategory = defineModel<string>('selectedCategory', {
required: true
})
const sidebarRef = ref<HTMLElement>()
const isHovered = useElementHover(sidebarRef)
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()

View File

@@ -1,32 +1,47 @@
<template>
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
<div
:class="
cn(
'w-full cursor-pointer rounded border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
isExpanded &&
hasChildren &&
'flex flex-col rounded-lg bg-secondary-background'
)
"
@click="$emit('select', node.key)"
>
{{ node.label }}
</button>
<template v-if="isExpanded && node.children?.length">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="$emit('select', $event)"
/>
</template>
<Button
type="button"
variant="muted-textonly"
size="unset"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:class="categoryButtonClass"
:style="{ paddingLeft: paddingLeft }"
@click="$emit('select', node.key)"
>
<i
v-if="showChevrons && hasChildren"
:class="
cn(
'pi pi-chevron-down shrink-0 text-[10px] transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
{{ node.label }}
</Button>
<template v-if="isExpanded && hasChildren">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
:show-chevrons="showChevrons"
@select="$emit('select', $event)"
/>
</template>
</div>
</template>
<script lang="ts">
@@ -45,26 +60,43 @@ export const CATEGORY_UNSELECTED_CLASS =
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
node,
depth = 0,
selectedCategory,
selectedCollapsed = false
selectedCollapsed = false,
showChevrons = false
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
selectedCollapsed?: boolean
showChevrons?: boolean
}>()
defineEmits<{
select: [key: string]
}>()
const hasChildren = computed(() => (node.children?.length ?? 0) > 0)
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
const paddingLeft = computed(() => {
if (hasChildren.value && showChevrons) return `${0.5 + depth * 1.25}rem`
return `${0.75 + depth * 1.25}rem`
})
const categoryButtonClass = computed(() =>
cn(
'w-full justify-start rounded py-3 pr-3 font-normal hover:text-foreground',
selectedCategory === node.key && CATEGORY_SELECTED_CLASS
)
)
</script>

View File

@@ -25,6 +25,7 @@
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
:applied-filters="filters"
@select-chip="onSelectFilterChip"
/>
</div>

View File

@@ -1,27 +1,28 @@
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<button
<Button
v-for="chip in chips"
:key="chip.key"
type="button"
:variant="chipVariant(chip.key)"
size="unset"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
activeChipKey === chip.key
? 'bg-secondary-background text-foreground'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
:class="chipExtraClass(chip.key)"
@click="emit('selectChip', chip)"
>
{{ chip.label }}
</button>
<span
v-if="appliedFilterCounts[chip.key]"
class="ml-0.5 text-xs opacity-80"
>
({{ appliedFilterCounts[chip.key] }})
</span>
</Button>
</div>
</template>
<script lang="ts">
import type { FuseFilter } from '@/utils/fuseUtil'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export interface FilterChip {
@@ -35,11 +36,14 @@ export interface FilterChip {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { cn } from '@/utils/tailwindUtil'
const { activeChipKey = null } = defineProps<{
const { activeChipKey = null, appliedFilters = [] } = defineProps<{
activeChipKey?: string | null
appliedFilters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
}>()
const emit = defineEmits<{
@@ -69,4 +73,29 @@ const chips = computed<FilterChip[]>(() => {
}
]
})
const appliedFilterCounts = computed(() => {
const counts: Record<string, number> = {}
for (const filter of appliedFilters) {
counts[filter.filterDef.id] = (counts[filter.filterDef.id] ?? 0) + 1
}
return counts
})
function chipVariant(chipKey: string): ButtonVariants['variant'] {
return activeChipKey === chipKey ? 'inverted' : 'outline'
}
function chipExtraClass(chipKey: string) {
const isActive = activeChipKey === chipKey
const hasApplied = (appliedFilterCounts.value[chipKey] ?? 0) > 0
return cn(
'flex-auto px-3 py-1',
isActive && 'border border-solid border-base-foreground',
!isActive &&
hasApplied &&
'bg-base-foreground/10 border-base-foreground text-base-foreground'
)
}
</script>

View File

@@ -154,7 +154,6 @@ import {
render
} from 'vue'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchBox from '@/components/common/SearchBox.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
@@ -277,9 +276,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
return {
key: node.key,
label: node.leaf
? (resolveEssentialsDisplayName(node.data) ?? node.data.display_name)
: node.label,
label: node.leaf ? node.data.display_name : node.label,
leaf: node.leaf,
data: node.data,
getIcon() {

View File

@@ -114,7 +114,6 @@ import {
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
@@ -231,23 +230,16 @@ function findFirstLeaf(node: TreeNode): TreeNode | undefined {
}
function fillNodeInfo(
node: TreeNode,
{ useEssentialsLabels = false }: { useEssentialsLabels?: boolean } = {}
node: TreeNode
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
const children = node.children?.map((child) =>
fillNodeInfo(child, { useEssentialsLabels })
)
const children = node.children?.map(fillNodeInfo)
const totalLeaves = node.leaf
? 1
: (children?.reduce((acc, child) => acc + child.totalLeaves, 0) ?? 0)
return {
key: node.key,
label: node.leaf
? useEssentialsLabels
? (resolveEssentialsDisplayName(node.data) ?? node.data?.display_name)
: node.data?.display_name
: node.label,
label: node.leaf ? node.data?.display_name : node.label,
leaf: node.leaf,
data: node.data,
icon: node.leaf ? 'icon-[comfy--node]' : getFolderIcon(node),
@@ -282,7 +274,7 @@ const essentialSections = computed(() => {
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
? fillNodeInfo(applySorting(section.tree))
: fillNodeInfo({ key: 'root', label: '', children: [] })
})

View File

@@ -44,7 +44,7 @@ describe('EssentialNodeCard', () => {
return {
key: 'test-key',
label: data.display_name,
label: 'Test Node',
icon: 'icon-[comfy--node]',
type: 'node',
totalLeaves: 1,

View File

@@ -1,7 +1,7 @@
<template>
<div
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
:data-node-name="node.label"
:data-node-name="node.data?.display_name"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@@ -16,7 +16,7 @@
<TextTickerMultiLine
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
>
{{ node.label }}
{{ node.data?.display_name }}
</TextTickerMultiLine>
</div>

View File

@@ -19,7 +19,9 @@ export const buttonVariants = cva({
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
outline:
'border border-solid border-muted-foreground bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -47,7 +49,8 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'overlay-white'
'overlay-white',
'outline'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
const { mockGetSetting, mockUnseenAddedAssetsCount } = vi.hoisted(() => ({
const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockUnseenAddedAssetsCount: { value: 0 }
mockActiveJobsCount: { value: 0 }
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -17,16 +17,16 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
default: {}
}))
vi.mock('@/stores/workspace/assetsSidebarBadgeStore', () => ({
useAssetsSidebarBadgeStore: () => ({
unseenAddedAssetsCount: mockUnseenAddedAssetsCount.value
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
activeJobsCount: mockActiveJobsCount.value
})
}))
describe('useAssetsSidebarTab', () => {
it('hides icon badge when QPO V2 is disabled', () => {
mockGetSetting.mockReturnValue(false)
mockUnseenAddedAssetsCount.value = 3
mockActiveJobsCount.value = 3
const sidebarTab = useAssetsSidebarTab()
@@ -34,9 +34,9 @@ describe('useAssetsSidebarTab', () => {
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('shows unseen added assets count when QPO V2 is enabled', () => {
it('shows active job count when QPO V2 is enabled', () => {
mockGetSetting.mockReturnValue(true)
mockUnseenAddedAssetsCount.value = 3
mockActiveJobsCount.value = 3
const sidebarTab = useAssetsSidebarTab()
@@ -44,9 +44,9 @@ describe('useAssetsSidebarTab', () => {
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
})
it('hides badge when there are no unseen added assets', () => {
it('hides badge when no active jobs', () => {
mockGetSetting.mockReturnValue(true)
mockUnseenAddedAssetsCount.value = 0
mockActiveJobsCount.value = 0
const sidebarTab = useAssetsSidebarTab()

View File

@@ -2,7 +2,7 @@ import { markRaw } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore'
import { useQueueStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useAssetsSidebarTab = (): SidebarTabExtension => {
@@ -21,8 +21,8 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
return null
}
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
const count = assetsSidebarBadgeStore.unseenAddedAssetsCount
const queueStore = useQueueStore()
const count = queueStore.activeJobsCount
return count > 0 ? count.toString() : null
}
}

View File

@@ -1,59 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab'
const { mockActiveJobsCount, mockActiveSidebarTabId } = vi.hoisted(() => ({
mockActiveJobsCount: { value: 0 },
mockActiveSidebarTabId: { value: null as string | null }
}))
vi.mock('@/components/sidebar/tabs/JobHistorySidebarTab.vue', () => ({
default: {}
}))
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
activeJobsCount: mockActiveJobsCount.value
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
activeSidebarTabId: mockActiveSidebarTabId.value
})
}))
describe('useJobHistorySidebarTab', () => {
beforeEach(() => {
mockActiveSidebarTabId.value = null
mockActiveJobsCount.value = 0
})
it('shows active jobs count while the panel is closed', () => {
mockActiveSidebarTabId.value = 'assets'
mockActiveJobsCount.value = 3
const sidebarTab = useJobHistorySidebarTab()
expect(typeof sidebarTab.iconBadge).toBe('function')
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
})
it('hides badge while the job history panel is open', () => {
mockActiveSidebarTabId.value = 'job-history'
mockActiveJobsCount.value = 3
const sidebarTab = useJobHistorySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('hides badge when there are no active jobs', () => {
mockActiveSidebarTabId.value = null
mockActiveJobsCount.value = 0
const sidebarTab = useJobHistorySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
})

View File

@@ -1,8 +1,6 @@
import { markRaw } from 'vue'
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
@@ -13,16 +11,6 @@ export const useJobHistorySidebarTab = (): SidebarTabExtension => {
tooltip: 'queue.jobHistory',
label: 'queue.jobHistory',
component: markRaw(JobHistorySidebarTab),
type: 'vue',
iconBadge: () => {
const sidebarTabStore = useSidebarTabStore()
if (sidebarTabStore.activeSidebarTabId === 'job-history') {
return null
}
const queueStore = useQueueStore()
const count = queueStore.activeJobsCount
return count > 0 ? count.toString() : null
}
type: 'vue'
}
}

View File

@@ -1,105 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
describe('resolveEssentialsDisplayName', () => {
describe('exact name matches', () => {
it.each([
['LoadImage', 'essentials.loadImage'],
['SaveImage', 'essentials.saveImage'],
['PrimitiveStringMultiline', 'essentials.text'],
['ImageScale', 'essentials.resizeImage'],
['LoraLoader', 'essentials.loadStyleLora'],
['OpenAIChatNode', 'essentials.textGenerationLLM'],
['RecraftRemoveBackgroundNode', 'essentials.removeBackground'],
['ImageCompare', 'essentials.imageCompare'],
['StabilityTextToAudio', 'essentials.musicGeneration'],
['BatchImagesNode', 'essentials.batchImage'],
['Video Slice', 'essentials.extractFrame'],
['KlingLipSyncAudioToVideoNode', 'essentials.lipsync'],
['KlingLipSyncTextToVideoNode', 'essentials.lipsync']
])('%s -> %s', (name, expected) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('3D API node alternatives', () => {
it.each([
['TencentTextToModelNode', 'essentials.textTo3DModel'],
['MeshyTextToModelNode', 'essentials.textTo3DModel'],
['TripoTextToModelNode', 'essentials.textTo3DModel'],
['TencentImageToModelNode', 'essentials.imageTo3DModel'],
['MeshyImageToModelNode', 'essentials.imageTo3DModel'],
['TripoImageToModelNode', 'essentials.imageTo3DModel']
])('%s -> %s', (name, expected) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('blueprint prefix matches', () => {
it.each([
[
'SubgraphBlueprint.text_to_image_flux_schnell.json',
'essentials.textToImage'
],
['SubgraphBlueprint.text_to_image_sd15.json', 'essentials.textToImage'],
[
'SubgraphBlueprint.image_edit_something.json',
'essentials.imageToImage'
],
['SubgraphBlueprint.pose_to_image_v2.json', 'essentials.poseToImage'],
[
'SubgraphBlueprint.canny_to_image_z_image_turbo.json',
'essentials.cannyToImage'
],
[
'SubgraphBlueprint.depth_to_image_z_image_turbo.json',
'essentials.depthToImage'
],
['SubgraphBlueprint.text_to_video_ltx.json', 'essentials.textToVideo'],
['SubgraphBlueprint.image_to_video_wan.json', 'essentials.imageToVideo'],
[
'SubgraphBlueprint.pose_to_video_ltx_2_0.json',
'essentials.poseToVideo'
],
[
'SubgraphBlueprint.canny_to_video_ltx_2_0.json',
'essentials.cannyToVideo'
],
[
'SubgraphBlueprint.depth_to_video_ltx_2_0.json',
'essentials.depthToVideo'
],
[
'SubgraphBlueprint.image_inpainting_qwen_image_instantx.json',
'essentials.inpaintImage'
],
[
'SubgraphBlueprint.image_outpainting_qwen_image_instantx.json',
'essentials.outpaintImage'
]
])('%s -> %s', (name, expected) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('unmapped nodes', () => {
it('returns undefined for unknown node names', () => {
expect(resolveEssentialsDisplayName({ name: 'SomeRandomNode' })).toBe(
undefined
)
})
it('returns undefined for unknown blueprint prefixes', () => {
expect(
resolveEssentialsDisplayName({
name: 'SubgraphBlueprint.unknown_workflow.json'
})
).toBe(undefined)
})
})
})

View File

@@ -1,108 +0,0 @@
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const BLUEPRINT_PREFIX = 'SubgraphBlueprint.'
/**
* Static mapping of node names to their Essentials tab display name i18n keys.
*/
const EXACT_NAME_MAP: Record<string, string> = {
// Basics
LoadImage: 'essentials.loadImage',
SaveImage: 'essentials.saveImage',
LoadVideo: 'essentials.loadVideo',
SaveVideo: 'essentials.saveVideo',
Load3D: 'essentials.load3DModel',
SaveGLB: 'essentials.save3DModel',
PrimitiveStringMultiline: 'essentials.text',
// Image Tools
BatchImagesNode: 'essentials.batchImage',
ImageCrop: 'essentials.cropImage',
ImageScale: 'essentials.resizeImage',
ImageRotate: 'essentials.rotate',
ImageInvert: 'essentials.invert',
Canny: 'essentials.canny',
RecraftRemoveBackgroundNode: 'essentials.removeBackground',
ImageCompare: 'essentials.imageCompare',
// Video Tools
'Video Slice': 'essentials.extractFrame',
// Image Generation
LoraLoader: 'essentials.loadStyleLora',
// Video Generation
KlingLipSyncAudioToVideoNode: 'essentials.lipsync',
KlingLipSyncTextToVideoNode: 'essentials.lipsync',
// Text Generation
OpenAIChatNode: 'essentials.textGenerationLLM',
// 3D
TencentTextToModelNode: 'essentials.textTo3DModel',
TencentImageToModelNode: 'essentials.imageTo3DModel',
MeshyTextToModelNode: 'essentials.textTo3DModel',
MeshyImageToModelNode: 'essentials.imageTo3DModel',
TripoTextToModelNode: 'essentials.textTo3DModel',
TripoImageToModelNode: 'essentials.imageTo3DModel',
// Audio
StabilityTextToAudio: 'essentials.musicGeneration',
LoadAudio: 'essentials.loadAudio',
SaveAudio: 'essentials.saveAudio'
}
/**
* Blueprint prefix patterns mapped to display name i18n keys.
* Entries are matched by checking if the blueprint filename
* (after removing the SubgraphBlueprint. prefix) starts with the key.
* Ordered longest-first so more specific prefixes match before shorter ones.
*/
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
// Image Generation
['image_inpainting_', 'essentials.inpaintImage'],
['image_outpainting_', 'essentials.outpaintImage'],
['image_edit', 'essentials.imageToImage'],
['text_to_image', 'essentials.textToImage'],
['pose_to_image', 'essentials.poseToImage'],
['canny_to_image', 'essentials.cannyToImage'],
['depth_to_image', 'essentials.depthToImage'],
// Video Generation
['text_to_video', 'essentials.textToVideo'],
['image_to_video', 'essentials.imageToVideo'],
['pose_to_video', 'essentials.poseToVideo'],
['canny_to_video', 'essentials.cannyToVideo'],
['depth_to_video', 'essentials.depthToVideo']
]
function resolveBlueprintDisplayName(
blueprintName: string
): string | undefined {
for (const [prefix, displayNameKey] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
return t(displayNameKey)
}
}
return undefined
}
/**
* Resolves the Essentials tab display name for a given node definition.
* Returns `undefined` if the node has no Essentials display name mapping.
*/
export function resolveEssentialsDisplayName(
nodeDef: Pick<ComfyNodeDefImpl, 'name'> | undefined
): string | undefined {
if (!nodeDef) return undefined
const { name } = nodeDef
if (name.startsWith(BLUEPRINT_PREFIX)) {
const blueprintName = name.slice(BLUEPRINT_PREFIX.length)
return resolveBlueprintDisplayName(blueprintName)
}
const key = EXACT_NAME_MAP[name]
return key ? t(key) : undefined
}

View File

@@ -6,10 +6,6 @@
- Avoid repetition where possible, but not at expense of legibility
- Prefer running single tests, not the whole suite, for performance
## Widget Serialization
See `docs/WIDGET_SERIALIZATION.md` for the distinction between `widget.serialize` (workflow persistence) and `widget.options.serialize` (API prompt). These are different properties checked by different code paths — a common source of confusion.
## Code Style
- Prefer single line `if` syntax for concise expressions

View File

@@ -47,19 +47,6 @@ export interface IWidgetOptions<TValues = unknown> {
/** Used as a temporary override for determining the asset type in vue mode*/
nodeType?: string
/**
* Whether the widget value should be included in the API prompt sent to
* the backend for execution. Checked by {@link executionUtil} when
* building the prompt payload.
*
* This is distinct from {@link IBaseWidget.serialize}, which controls
* whether the value is persisted in the workflow JSON file.
*
* @default true
* @see IBaseWidget.serialize — workflow persistence
*/
serialize?: boolean
values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */
getOptionLabel?: (value?: string | null) => string
@@ -362,15 +349,8 @@ export interface IBaseWidget<
vueTrack?: () => void
/**
* Whether the widget value is persisted in the workflow JSON
* (`widgets_values`). Checked by {@link LGraphNode.serialize} and
* {@link LGraphNode.configure}.
*
* This is distinct from {@link IWidgetOptions.serialize}, which controls
* whether the value is included in the API prompt sent for execution.
*
* Whether the widget value should be serialized on node serialization.
* @default true
* @see IWidgetOptions.serialize — API prompt inclusion
*/
serialize?: boolean

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} أخطاء | {count} خطأ | {count} أخطاء",
"seeErrors": "عرض الأخطاء"
},
"essentials": {
"batchImage": "معالجة صور دفعة واحدة",
"canny": "كانّي",
"cannyToImage": "تحويل كانّي إلى صورة",
"cannyToVideo": "تحويل كانّي إلى فيديو",
"cropImage": "قص الصورة",
"depthToImage": "تحويل العمق إلى صورة",
"depthToVideo": "تحويل العمق إلى فيديو",
"extractFrame": "استخراج إطار",
"imageCompare": "مقارنة الصور",
"imageTo3DModel": "تحويل الصورة إلى نموذج ثلاثي الأبعاد",
"imageToImage": "تحويل صورة إلى صورة",
"imageToVideo": "تحويل صورة إلى فيديو",
"inpaintImage": "ترميم الصورة",
"invert": "عكس",
"lipsync": "مزامنة الشفاه",
"load3DModel": "تحميل نموذج ثلاثي الأبعاد",
"loadAudio": "تحميل صوت",
"loadImage": "تحميل صورة",
"loadStyleLora": "تحميل نمط (LoRA)",
"loadVideo": "تحميل فيديو",
"musicGeneration": "توليد موسيقى",
"outpaintImage": "توسيع الصورة",
"poseToImage": "تحويل وضعية إلى صورة",
"poseToVideo": "تحويل وضعية إلى فيديو",
"removeBackground": "إزالة الخلفية",
"resizeImage": "تغيير حجم الصورة",
"rotate": "تدوير",
"save3DModel": "حفظ نموذج ثلاثي الأبعاد",
"saveAudio": "حفظ صوت",
"saveImage": "حفظ صورة",
"saveVideo": "حفظ فيديو",
"text": "نص",
"textGenerationLLM": "توليد نص (LLM)",
"textTo3DModel": "تحويل النص إلى نموذج ثلاثي الأبعاد",
"textToImage": "تحويل نص إلى صورة",
"textToVideo": "تحويل نص إلى فيديو"
},
"exportToast": {
"allExportsCompleted": "اكتملت جميع عمليات التصدير",
"downloadExport": "تحميل التصدير",
@@ -2985,7 +2947,6 @@
"node2only": "فقط Node 2.0",
"selectModel": "اختر نموذج",
"uploadSelect": {
"maxSelectionReached": "تم الوصول إلى الحد الأقصى للاختيار",
"placeholder": "اختر...",
"placeholderAudio": "اختر صوت...",
"placeholderImage": "اختر صورة...",

View File

@@ -1604,6 +1604,7 @@
"MiniMax": "MiniMax",
"model_specific": "model_specific",
"Moonvalley Marey": "Moonvalley Marey",
"": "",
"OpenAI": "OpenAI",
"Sora": "Sora",
"cond pair": "cond pair",
@@ -1625,7 +1626,6 @@
"style_model": "style_model",
"Tencent": "Tencent",
"textgen": "textgen",
"": "",
"Topaz": "Topaz",
"Tripo": "Tripo",
"Veo": "Veo",
@@ -2459,8 +2459,7 @@
"placeholderVideo": "Select video...",
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
"placeholderUnknown": "Select media..."
},
"valueControl": {
"header": {
@@ -3152,43 +3151,5 @@
"duplicateName": "A secret with this name already exists",
"duplicateProvider": "A secret for this provider already exists"
}
},
"essentials": {
"loadImage": "Load Image",
"saveImage": "Save Image",
"loadVideo": "Load Video",
"saveVideo": "Save Video",
"load3DModel": "Load 3D model",
"save3DModel": "Save 3D Model",
"text": "Text",
"batchImage": "Batch Image",
"cropImage": "Crop Image",
"resizeImage": "Resize Image",
"rotate": "Rotate",
"invert": "Invert",
"canny": "Canny",
"removeBackground": "Remove Background",
"imageCompare": "Image compare",
"extractFrame": "Extract frame",
"loadStyleLora": "Load style (LoRA)",
"lipsync": "Lipsync",
"textGenerationLLM": "Text generation (LLM)",
"textTo3DModel": "Text to 3D model",
"imageTo3DModel": "Image to 3D Model",
"musicGeneration": "Music generation",
"loadAudio": "Load Audio",
"saveAudio": "Save Audio",
"inpaintImage": "Inpaint image",
"outpaintImage": "Outpaint image",
"imageToImage": "Image to image",
"textToImage": "Text to image",
"poseToImage": "Pose to image",
"cannyToImage": "Canny to image",
"depthToImage": "Depth to image",
"textToVideo": "Text to video",
"imageToVideo": "Image to video",
"poseToVideo": "Pose to video",
"cannyToVideo": "Canny to video",
"depthToVideo": "Depth to video"
}
}

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} ERRORES | {count} ERROR | {count} ERRORES",
"seeErrors": "Ver errores"
},
"essentials": {
"batchImage": "Procesar imágenes por lotes",
"canny": "Canny",
"cannyToImage": "Canny a imagen",
"cannyToVideo": "Canny a video",
"cropImage": "Recortar imagen",
"depthToImage": "Profundidad a imagen",
"depthToVideo": "Profundidad a video",
"extractFrame": "Extraer fotograma",
"imageCompare": "Comparar imágenes",
"imageTo3DModel": "Imagen a modelo 3D",
"imageToImage": "Imagen a imagen",
"imageToVideo": "Imagen a video",
"inpaintImage": "Rellenar imagen",
"invert": "Invertir",
"lipsync": "Lipsync",
"load3DModel": "Cargar modelo 3D",
"loadAudio": "Cargar audio",
"loadImage": "Cargar imagen",
"loadStyleLora": "Cargar estilo (LoRA)",
"loadVideo": "Cargar video",
"musicGeneration": "Generación de música",
"outpaintImage": "Expandir imagen",
"poseToImage": "Pose a imagen",
"poseToVideo": "Pose a video",
"removeBackground": "Eliminar fondo",
"resizeImage": "Redimensionar imagen",
"rotate": "Rotar",
"save3DModel": "Guardar modelo 3D",
"saveAudio": "Guardar audio",
"saveImage": "Guardar imagen",
"saveVideo": "Guardar video",
"text": "Texto",
"textGenerationLLM": "Generación de texto (LLM)",
"textTo3DModel": "Texto a modelo 3D",
"textToImage": "Texto a imagen",
"textToVideo": "Texto a video"
},
"exportToast": {
"allExportsCompleted": "Todas las exportaciones completadas",
"downloadExport": "Descargar exportación",
@@ -2985,7 +2947,6 @@
"node2only": "Solo Node 2.0",
"selectModel": "Seleccionar modelo",
"uploadSelect": {
"maxSelectionReached": "Se alcanzó el límite máximo de selección",
"placeholder": "Seleccionar...",
"placeholderAudio": "Seleccionar audio...",
"placeholderImage": "Seleccionar imagen...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} خطا",
"seeErrors": "مشاهده خطاها"
},
"essentials": {
"batchImage": "پردازش دسته‌ای تصویر",
"canny": "لبه‌یابی Canny",
"cannyToImage": "تبدیل Canny به تصویر",
"cannyToVideo": "تبدیل Canny به ویدیو",
"cropImage": "برش تصویر",
"depthToImage": "تبدیل عمق به تصویر",
"depthToVideo": "تبدیل عمق به ویدیو",
"extractFrame": "استخراج فریم",
"imageCompare": "مقایسه تصویر",
"imageTo3DModel": "تبدیل تصویر به مدل سه‌بعدی",
"imageToImage": "تبدیل تصویر به تصویر",
"imageToVideo": "تبدیل تصویر به ویدیو",
"inpaintImage": "بازسازی تصویر (Inpaint)",
"invert": "معکوس",
"lipsync": "همگام‌سازی لب",
"load3DModel": "بارگذاری مدل سه‌بعدی",
"loadAudio": "بارگذاری صوت",
"loadImage": "بارگذاری تصویر",
"loadStyleLora": "بارگذاری سبک (LoRA)",
"loadVideo": "بارگذاری ویدیو",
"musicGeneration": "تولید موسیقی",
"outpaintImage": "گسترش تصویر (Outpaint)",
"poseToImage": "تبدیل ژست به تصویر",
"poseToVideo": "تبدیل ژست به ویدیو",
"removeBackground": "حذف پس‌زمینه",
"resizeImage": "تغییر اندازه تصویر",
"rotate": "چرخش",
"save3DModel": "ذخیره مدل سه‌بعدی",
"saveAudio": "ذخیره صوت",
"saveImage": "ذخیره تصویر",
"saveVideo": "ذخیره ویدیو",
"text": "متن",
"textGenerationLLM": "تولید متن (LLM)",
"textTo3DModel": "تبدیل متن به مدل سه‌بعدی",
"textToImage": "تبدیل متن به تصویر",
"textToVideo": "تبدیل متن به ویدیو"
},
"exportToast": {
"allExportsCompleted": "همه خروجی‌ها تکمیل شد",
"downloadExport": "دانلود خروجی",
@@ -2997,7 +2959,6 @@
"node2only": "فقط Node 2.0",
"selectModel": "انتخاب مدل",
"uploadSelect": {
"maxSelectionReached": "حداکثر تعداد انتخاب مجاز رسید",
"placeholder": "انتخاب...",
"placeholderAudio": "انتخاب صوت...",
"placeholderImage": "انتخاب تصویر...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} ERREURS | {count} ERREUR | {count} ERREURS",
"seeErrors": "Voir les erreurs"
},
"essentials": {
"batchImage": "Traitement par lot d'images",
"canny": "Canny",
"cannyToImage": "Canny vers image",
"cannyToVideo": "Canny vers vidéo",
"cropImage": "Rogner l'image",
"depthToImage": "Profondeur vers image",
"depthToVideo": "Profondeur vers vidéo",
"extractFrame": "Extraire une image",
"imageCompare": "Comparer les images",
"imageTo3DModel": "Image vers modèle 3D",
"imageToImage": "Image vers image",
"imageToVideo": "Image vers vidéo",
"inpaintImage": "Inpainting d'image",
"invert": "Inverser",
"lipsync": "Synchronisation labiale",
"load3DModel": "Charger un modèle 3D",
"loadAudio": "Charger un audio",
"loadImage": "Charger une image",
"loadStyleLora": "Charger un style (LoRA)",
"loadVideo": "Charger une vidéo",
"musicGeneration": "Génération de musique",
"outpaintImage": "Outpainting d'image",
"poseToImage": "Pose vers image",
"poseToVideo": "Pose vers vidéo",
"removeBackground": "Supprimer l'arrière-plan",
"resizeImage": "Redimensionner l'image",
"rotate": "Faire pivoter",
"save3DModel": "Enregistrer le modèle 3D",
"saveAudio": "Enregistrer l'audio",
"saveImage": "Enregistrer l'image",
"saveVideo": "Enregistrer la vidéo",
"text": "Texte",
"textGenerationLLM": "Génération de texte (LLM)",
"textTo3DModel": "Texte vers modèle 3D",
"textToImage": "Texte vers image",
"textToVideo": "Texte vers vidéo"
},
"exportToast": {
"allExportsCompleted": "Toutes les exportations sont terminées",
"downloadExport": "Télécharger lexport",
@@ -2985,7 +2947,6 @@
"node2only": "Node 2.0 uniquement",
"selectModel": "Sélectionner un modèle",
"uploadSelect": {
"maxSelectionReached": "Limite maximale de sélection atteinte",
"placeholder": "Sélectionner...",
"placeholderAudio": "Sélectionner un audio...",
"placeholderImage": "Sélectionner une image...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} 件のエラー",
"seeErrors": "エラーを表示"
},
"essentials": {
"batchImage": "バッチ画像処理",
"canny": "Canny",
"cannyToImage": "Cannyから画像へ",
"cannyToVideo": "Cannyから動画へ",
"cropImage": "画像を切り抜く",
"depthToImage": "深度から画像へ",
"depthToVideo": "深度から動画へ",
"extractFrame": "フレームを抽出",
"imageCompare": "画像比較",
"imageTo3DModel": "画像から3Dモデル",
"imageToImage": "画像から画像へ",
"imageToVideo": "画像から動画へ",
"inpaintImage": "画像のインペイント",
"invert": "反転",
"lipsync": "リップシンク",
"load3DModel": "3Dモデルを読み込む",
"loadAudio": "音声を読み込む",
"loadImage": "画像を読み込む",
"loadStyleLora": "スタイルを読み込むLoRA",
"loadVideo": "動画を読み込む",
"musicGeneration": "音楽生成",
"outpaintImage": "画像のアウトペイント",
"poseToImage": "ポーズから画像へ",
"poseToVideo": "ポーズから動画へ",
"removeBackground": "背景を削除",
"resizeImage": "画像のサイズ変更",
"rotate": "回転",
"save3DModel": "3Dモデルを保存",
"saveAudio": "音声を保存",
"saveImage": "画像を保存",
"saveVideo": "動画を保存",
"text": "テキスト",
"textGenerationLLM": "テキスト生成LLM",
"textTo3DModel": "テキストから3Dモデル",
"textToImage": "テキストから画像へ",
"textToVideo": "テキストから動画へ"
},
"exportToast": {
"allExportsCompleted": "すべてのエクスポートが完了しました",
"downloadExport": "エクスポートをダウンロード",
@@ -2985,7 +2947,6 @@
"node2only": "Node 2.0専用",
"selectModel": "モデルを選択",
"uploadSelect": {
"maxSelectionReached": "選択の上限に達しました",
"placeholder": "選択...",
"placeholderAudio": "音声を選択...",
"placeholderImage": "画像を選択...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count}개 오류",
"seeErrors": "오류 보기"
},
"essentials": {
"batchImage": "이미지 일괄 처리",
"canny": "Canny",
"cannyToImage": "Canny → 이미지",
"cannyToVideo": "Canny → 비디오",
"cropImage": "이미지 자르기",
"depthToImage": "깊이 → 이미지",
"depthToVideo": "깊이 → 비디오",
"extractFrame": "프레임 추출",
"imageCompare": "이미지 비교",
"imageTo3DModel": "이미지 → 3D 모델",
"imageToImage": "이미지 → 이미지",
"imageToVideo": "이미지 → 비디오",
"inpaintImage": "이미지 인페인팅",
"invert": "반전",
"lipsync": "립싱크",
"load3DModel": "3D 모델 불러오기",
"loadAudio": "오디오 불러오기",
"loadImage": "이미지 불러오기",
"loadStyleLora": "스타일 불러오기 (LoRA)",
"loadVideo": "비디오 불러오기",
"musicGeneration": "음악 생성",
"outpaintImage": "이미지 아웃페인팅",
"poseToImage": "포즈 → 이미지",
"poseToVideo": "포즈 → 비디오",
"removeBackground": "배경 제거",
"resizeImage": "이미지 크기 조정",
"rotate": "회전",
"save3DModel": "3D 모델 저장",
"saveAudio": "오디오 저장",
"saveImage": "이미지 저장",
"saveVideo": "비디오 저장",
"text": "텍스트",
"textGenerationLLM": "텍스트 생성 (LLM)",
"textTo3DModel": "텍스트 → 3D 모델",
"textToImage": "텍스트 → 이미지",
"textToVideo": "텍스트 → 비디오"
},
"exportToast": {
"allExportsCompleted": "모든 내보내기 완료",
"downloadExport": "내보내기 다운로드",
@@ -2985,7 +2947,6 @@
"node2only": "Node 2.0 전용",
"selectModel": "모델 선택",
"uploadSelect": {
"maxSelectionReached": "최대 선택 한도에 도달했습니다",
"placeholder": "선택...",
"placeholderAudio": "오디오 선택...",
"placeholderImage": "이미지 선택...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} ERROS | {count} ERRO | {count} ERROS",
"seeErrors": "Ver erros"
},
"essentials": {
"batchImage": "Imagem em lote",
"canny": "Canny",
"cannyToImage": "Canny para imagem",
"cannyToVideo": "Canny para vídeo",
"cropImage": "Cortar imagem",
"depthToImage": "Profundidade para imagem",
"depthToVideo": "Profundidade para vídeo",
"extractFrame": "Extrair quadro",
"imageCompare": "Comparar imagens",
"imageTo3DModel": "Imagem para modelo 3D",
"imageToImage": "Imagem para imagem",
"imageToVideo": "Imagem para vídeo",
"inpaintImage": "Inpaint na imagem",
"invert": "Inverter",
"lipsync": "Lipsync",
"load3DModel": "Carregar modelo 3D",
"loadAudio": "Carregar áudio",
"loadImage": "Carregar imagem",
"loadStyleLora": "Carregar estilo (LoRA)",
"loadVideo": "Carregar vídeo",
"musicGeneration": "Geração de música",
"outpaintImage": "Outpaint na imagem",
"poseToImage": "Pose para imagem",
"poseToVideo": "Pose para vídeo",
"removeBackground": "Remover fundo",
"resizeImage": "Redimensionar imagem",
"rotate": "Girar",
"save3DModel": "Salvar modelo 3D",
"saveAudio": "Salvar áudio",
"saveImage": "Salvar imagem",
"saveVideo": "Salvar vídeo",
"text": "Texto",
"textGenerationLLM": "Geração de texto (LLM)",
"textTo3DModel": "Texto para modelo 3D",
"textToImage": "Texto para imagem",
"textToVideo": "Texto para vídeo"
},
"exportToast": {
"allExportsCompleted": "Todas as exportações concluídas",
"downloadExport": "Baixar exportação",
@@ -2997,7 +2959,6 @@
"node2only": "Apenas Node 2.0",
"selectModel": "Selecionar modelo",
"uploadSelect": {
"maxSelectionReached": "Limite máximo de seleção atingido",
"placeholder": "Selecionar...",
"placeholderAudio": "Selecionar áudio...",
"placeholderImage": "Selecionar imagem...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
"seeErrors": "Посмотреть ошибки"
},
"essentials": {
"batchImage": "Пакетная обработка изображений",
"canny": "Canny",
"cannyToImage": "Canny в изображение",
"cannyToVideo": "Canny в видео",
"cropImage": "Обрезать изображение",
"depthToImage": "Глубина в изображение",
"depthToVideo": "Глубина в видео",
"extractFrame": "Извлечь кадр",
"imageCompare": "Сравнение изображений",
"imageTo3DModel": "Изображение в 3D-модель",
"imageToImage": "Изображение в изображение",
"imageToVideo": "Изображение в видео",
"inpaintImage": "Инпейтинг изображения",
"invert": "Инвертировать",
"lipsync": "Синхронизация губ",
"load3DModel": "Загрузить 3D-модель",
"loadAudio": "Загрузить аудио",
"loadImage": "Загрузить изображение",
"loadStyleLora": "Загрузить стиль (LoRA)",
"loadVideo": "Загрузить видео",
"musicGeneration": "Генерация музыки",
"outpaintImage": "Аутпейтинг изображения",
"poseToImage": "Поза в изображение",
"poseToVideo": "Поза в видео",
"removeBackground": "Удалить фон",
"resizeImage": "Изменить размер изображения",
"rotate": "Повернуть",
"save3DModel": "Сохранить 3D-модель",
"saveAudio": "Сохранить аудио",
"saveImage": "Сохранить изображение",
"saveVideo": "Сохранить видео",
"text": "Текст",
"textGenerationLLM": "Генерация текста (LLM)",
"textTo3DModel": "Текст в 3D-модель",
"textToImage": "Текст в изображение",
"textToVideo": "Текст в видео"
},
"exportToast": {
"allExportsCompleted": "Все экспорты завершены",
"downloadExport": "Скачать экспорт",
@@ -2985,7 +2947,6 @@
"node2only": "Только Node 2.0",
"selectModel": "Выбрать модель",
"uploadSelect": {
"maxSelectionReached": "Достигнут максимальный лимит выбора",
"placeholder": "Выбрать...",
"placeholderAudio": "Выбрать аудио...",
"placeholderImage": "Выбрать изображение...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} HATA | {count} HATA | {count} HATA",
"seeErrors": "Hataları Gör"
},
"essentials": {
"batchImage": "Toplu Görüntü",
"canny": "Canny",
"cannyToImage": "Canny'den Görüntüye",
"cannyToVideo": "Canny'den Videoya",
"cropImage": "Görüntüyü Kırp",
"depthToImage": "Derinlikten Görüntüye",
"depthToVideo": "Derinlikten Videoya",
"extractFrame": "Kareyi Çıkar",
"imageCompare": "Görüntü Karşılaştır",
"imageTo3DModel": "Görüntüden 3D Modele",
"imageToImage": "Görüntüden Görüntüye",
"imageToVideo": "Görüntüden Videoya",
"inpaintImage": "Görüntüyü Tamamla",
"invert": "Ters Çevir",
"lipsync": "Dudak Senkronizasyonu",
"load3DModel": "3D Model Yükle",
"loadAudio": "Ses Yükle",
"loadImage": "Görüntü Yükle",
"loadStyleLora": "Stil Yükle (LoRA)",
"loadVideo": "Video Yükle",
"musicGeneration": "Müzik Üretimi",
"outpaintImage": "Görüntüyü Genişlet",
"poseToImage": "Pozdan Görüntüye",
"poseToVideo": "Pozdan Videoya",
"removeBackground": "Arka Planı Kaldır",
"resizeImage": "Görüntüyü Yeniden Boyutlandır",
"rotate": "Döndür",
"save3DModel": "3D Modeli Kaydet",
"saveAudio": "Sesi Kaydet",
"saveImage": "Görüntüyü Kaydet",
"saveVideo": "Videoyu Kaydet",
"text": "Metin",
"textGenerationLLM": "Metin Üretimi (LLM)",
"textTo3DModel": "Metinden 3D Modele",
"textToImage": "Metinden Görüntüye",
"textToVideo": "Metinden Videoya"
},
"exportToast": {
"allExportsCompleted": "Tüm dışa aktarmalar tamamlandı",
"downloadExport": "Dışa aktarmayı indir",
@@ -2985,7 +2947,6 @@
"node2only": "Yalnızca Node 2.0",
"selectModel": "Model seç",
"uploadSelect": {
"maxSelectionReached": "Maksimum seçim sınırına ulaşıldı",
"placeholder": "Seç...",
"placeholderAudio": "Ses seç...",
"placeholderImage": "Görsel seç...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count} 個錯誤",
"seeErrors": "查看錯誤"
},
"essentials": {
"batchImage": "批次圖片",
"canny": "Canny 邊緣",
"cannyToImage": "Canny 邊緣轉圖片",
"cannyToVideo": "Canny 邊緣轉影片",
"cropImage": "裁切圖片",
"depthToImage": "深度轉圖片",
"depthToVideo": "深度轉影片",
"extractFrame": "擷取影格",
"imageCompare": "圖片比較",
"imageTo3DModel": "圖片轉 3D 模型",
"imageToImage": "圖片轉圖片",
"imageToVideo": "圖片轉影片",
"inpaintImage": "圖片修補",
"invert": "反相",
"lipsync": "唇形同步",
"load3DModel": "載入 3D 模型",
"loadAudio": "載入音訊",
"loadImage": "載入圖片",
"loadStyleLora": "載入風格 (LoRA)",
"loadVideo": "載入影片",
"musicGeneration": "音樂生成",
"outpaintImage": "圖片擴展",
"poseToImage": "姿勢轉圖片",
"poseToVideo": "姿勢轉影片",
"removeBackground": "去背",
"resizeImage": "調整圖片尺寸",
"rotate": "旋轉",
"save3DModel": "儲存 3D 模型",
"saveAudio": "儲存音訊",
"saveImage": "儲存圖片",
"saveVideo": "儲存影片",
"text": "文字",
"textGenerationLLM": "文字生成 (LLM)",
"textTo3DModel": "文字轉 3D 模型",
"textToImage": "文字轉圖片",
"textToVideo": "文字轉影片"
},
"exportToast": {
"allExportsCompleted": "所有匯出已完成",
"downloadExport": "下載匯出檔",
@@ -2985,7 +2947,6 @@
"node2only": "僅限 Node 2.0",
"selectModel": "選擇模型",
"uploadSelect": {
"maxSelectionReached": "已達到最大選取限制",
"placeholder": "選擇...",
"placeholderAudio": "選擇音訊...",
"placeholderImage": "選擇圖片...",

View File

@@ -735,44 +735,6 @@
"errorCount": "{count}个错误",
"seeErrors": "查看错误"
},
"essentials": {
"batchImage": "批量图像",
"canny": "Canny",
"cannyToImage": "Canny转图像",
"cannyToVideo": "Canny转视频",
"cropImage": "裁剪图像",
"depthToImage": "深度转图像",
"depthToVideo": "深度转视频",
"extractFrame": "提取帧",
"imageCompare": "图像对比",
"imageTo3DModel": "图像转3D模型",
"imageToImage": "图像转图像",
"imageToVideo": "图像转视频",
"inpaintImage": "图像修复",
"invert": "反转",
"lipsync": "唇形同步",
"load3DModel": "加载3D模型",
"loadAudio": "加载音频",
"loadImage": "加载图像",
"loadStyleLora": "加载风格LoRA",
"loadVideo": "加载视频",
"musicGeneration": "音乐生成",
"outpaintImage": "图像扩展",
"poseToImage": "姿态转图像",
"poseToVideo": "姿态转视频",
"removeBackground": "移除背景",
"resizeImage": "调整图像大小",
"rotate": "旋转",
"save3DModel": "保存3D模型",
"saveAudio": "保存音频",
"saveImage": "保存图像",
"saveVideo": "保存视频",
"text": "文本",
"textGenerationLLM": "文本生成LLM",
"textTo3DModel": "文本转3D模型",
"textToImage": "文本转图像",
"textToVideo": "文本转视频"
},
"exportToast": {
"allExportsCompleted": "全部导出完成",
"downloadExport": "下载导出文件",
@@ -2997,7 +2959,6 @@
"node2only": "仅限 Node 2.0",
"selectModel": "选择模型",
"uploadSelect": {
"maxSelectionReached": "已达到最大选择数量",
"placeholder": "请选择...",
"placeholderAudio": "请选择音频...",
"placeholderImage": "请选择图片...",

View File

@@ -49,6 +49,7 @@ describe('MediaVideoTop', () => {
expect(wrapper.find('video').exists()).toBe(true)
expect(wrapper.find('source').exists()).toBe(false)
})
it('emits playback events and hides paused overlay while playing', async () => {
const wrapper = mount(MediaVideoTop, {
props: {

View File

@@ -161,7 +161,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
sel.clear()
sel.add(item.id)
} else {
toastStore.addAlert(t('widgets.uploadSelect.maxSelectionReached'))
toastStore.addAlert(`Maximum selection limit reached`)
return
}
}

View File

@@ -49,7 +49,7 @@ const theButtonStyle = computed(() =>
<div
:class="
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
})
"
>

View File

@@ -1,113 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import type { FormDropdownItem, LayoutMode } from './types'
function createItem(id: string, name: string): FormDropdownItem {
return {
id,
preview_url: '',
name,
label: name
}
}
describe('FormDropdownMenu', () => {
const defaultProps = {
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
isSelected: () => false,
filterOptions: [],
sortOptions: []
}
it('renders empty state when no items', async () => {
const wrapper = mount(FormDropdownMenu, {
props: {
...defaultProps,
items: []
},
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
},
mocks: {
$t: (key: string) => key
}
}
})
await nextTick()
const emptyIcon = wrapper.find('.icon-\\[lucide--circle-off\\]')
expect(emptyIcon.exists()).toBe(true)
})
it('renders VirtualGrid when items exist', async () => {
const wrapper = mount(FormDropdownMenu, {
props: defaultProps,
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
await nextTick()
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
expect(virtualGrid.exists()).toBe(true)
})
it('transforms items to include key property for VirtualGrid', async () => {
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
const wrapper = mount(FormDropdownMenu, {
props: {
...defaultProps,
items
},
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
await nextTick()
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
const virtualItems = virtualGrid.props('items')
expect(virtualItems).toHaveLength(2)
expect(virtualItems[0]).toHaveProperty('key', '1')
expect(virtualItems[1]).toHaveProperty('key', '2')
})
it('uses single column layout for list modes', async () => {
const wrapper = mount(FormDropdownMenu, {
props: {
...defaultProps,
layoutMode: 'list' as LayoutMode
},
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
await nextTick()
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
expect(virtualGrid.props('maxColumns')).toBe(1)
})
})

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { cn } from '@/utils/tailwindUtil'
import type {
FilterOption,
@@ -31,18 +30,7 @@ interface Props {
baseModelOptions?: FilterOption[]
}
const {
items,
isSelected,
filterOptions,
sortOptions,
searcher,
updateKey,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
baseModelOptions
} = defineProps<Props>()
defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
}>()
@@ -53,48 +41,11 @@ const sortSelected = defineModel<string>('sortSelected')
const searchQuery = defineModel<string>('searchQuery')
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
type LayoutConfig = {
maxColumns: number
itemHeight: number
itemWidth: number
gap: string
}
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
'list-small': {
maxColumns: 1,
itemHeight: 40,
itemWidth: 380,
gap: '0.25rem'
}
}
const layoutConfig = computed<LayoutConfig>(
() => LAYOUT_CONFIGS[layoutMode.value ?? 'grid']
)
const gridStyle = computed<CSSProperties>(() => ({
display: 'grid',
gap: layoutConfig.value.gap,
padding: '1rem',
width: '100%'
}))
type VirtualDropdownItem = FormDropdownItem & { key: string }
const virtualItems = computed<VirtualDropdownItem[]>(() =>
items.map((item) => ({
...item,
key: String(item.id)
}))
)
</script>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"
@@ -115,30 +66,34 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:show-base-model-filter
:base-model-options
/>
<div
v-if="items.length === 0"
class="flex h-50 items-center justify-center"
>
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
/>
</div>
<VirtualGrid
v-else
:key="layoutMode"
:items="virtualItems"
:grid-style
:max-columns="layoutConfig.maxColumns"
:default-item-height="layoutConfig.itemHeight"
:default-item-width="layoutConfig.itemWidth"
:buffer-rows="2"
class="mt-2 min-h-0 flex-1"
>
<template #item="{ item, index }">
<div class="relative flex h-full mt-2 overflow-y-scroll">
<div
:class="
cn(
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
{
'grid-cols-4': layoutMode === 'grid',
'grid-cols-1 gap-y-2': layoutMode === 'list',
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
}
)
"
>
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
<div
v-if="items.length === 0"
class="h-50 col-span-full flex items-center justify-center"
>
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
/>
</div>
<FormDropdownMenuItem
:index
v-for="(item, index) in items"
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:preview-url="item.preview_url ?? ''"
:name="item.name"
@@ -146,7 +101,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>
</template>
</VirtualGrid>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import { cn } from '@/utils/tailwindUtil'
import { AssetKindKey } from './types'
@@ -56,7 +57,7 @@ function handleVideoLoad(event: Event) {
:class="
cn(
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
'transition-[transform,box-shadow,background-color] duration-150',
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
@@ -78,7 +79,7 @@ function handleVideoLoad(event: Event) {
cn(
'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
'transition-[transform,box-shadow] duration-150',
'transition-all duration-150',
{
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
@@ -107,12 +108,11 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<img
<LazyImage
v-else-if="previewUrl"
:src="previewUrl"
:alt="name"
draggable="false"
class="size-full object-cover"
image-class="size-full object-cover"
@load="handleImageLoad"
/>
<div

View File

@@ -17,11 +17,7 @@ import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
const cloudOnboardingRoutes = isCloud
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
.cloudOnboardingRoutes
: []
import { cloudOnboardingRoutes } from './platform/cloud/onboarding/onboardingCloudRoutes'
const isFileProtocol = window.location.protocol === 'file:'

View File

@@ -535,7 +535,6 @@ describe('useQueueStore', () => {
await store.update()
const initialTask = store.historyTasks[0]
const initialHistoryTasks = store.historyTasks
// Same job with same outputs_count
mockGetHistory.mockResolvedValue([{ ...job }])
@@ -544,8 +543,6 @@ describe('useQueueStore', () => {
// Should reuse the same instance
expect(store.historyTasks[0]).toBe(initialTask)
// Should preserve array identity when history is unchanged
expect(store.historyTasks).toBe(initialHistoryTasks)
})
})

View File

@@ -479,7 +479,6 @@ export const useQueueStore = defineStore('queue', () => {
const runningTasks = shallowRef<TaskItemImpl[]>([])
const pendingTasks = shallowRef<TaskItemImpl[]>([])
const historyTasks = shallowRef<TaskItemImpl[]>([])
const hasFetchedHistorySnapshot = ref(false)
const maxHistoryItems = ref(64)
const isLoading = ref(false)
@@ -558,7 +557,7 @@ export const useQueueStore = defineStore('queue', () => {
currentHistory.map((impl) => [impl.jobId, impl])
)
const nextHistoryTasks = sortedHistory.map((job) => {
historyTasks.value = sortedHistory.map((job) => {
const existing = existingByJobId.get(job.id)
if (!existing) return new TaskItemImpl(job)
// Recreate if outputs_count changed to ensure lazy loading works
@@ -567,15 +566,6 @@ export const useQueueStore = defineStore('queue', () => {
}
return existing
})
const isHistoryUnchanged =
nextHistoryTasks.length === currentHistory.length &&
nextHistoryTasks.every((task, index) => task === currentHistory[index])
if (!isHistoryUnchanged) {
historyTasks.value = nextHistoryTasks
}
hasFetchedHistorySnapshot.value = true
} finally {
// Only clear loading if this is the latest request.
// A stale request completing (success or error) should not touch loading state
@@ -605,7 +595,6 @@ export const useQueueStore = defineStore('queue', () => {
runningTasks,
pendingTasks,
historyTasks,
hasFetchedHistorySnapshot,
maxHistoryItems,
isLoading,

View File

@@ -1,187 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const createHistoryTask = ({
id,
outputsCount,
hasPreview = true
}: {
id: string
outputsCount?: number
hasPreview?: boolean
}) =>
new TaskItemImpl({
id,
status: 'completed',
create_time: Date.now(),
priority: 1,
outputs_count: outputsCount,
preview_output: hasPreview
? {
filename: `${id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
: undefined
} as JobListItem)
describe('useAssetsSidebarBadgeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('does not count initial fetched history when store starts before hydration', async () => {
const queueStore = useQueueStore()
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 2 })
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.hasFetchedHistorySnapshot = true
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
})
it('counts new history items after baseline hydration while assets tab is closed', async () => {
const queueStore = useQueueStore()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 2 })
]
queueStore.hasFetchedHistorySnapshot = true
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-2', hasPreview: true }),
...queueStore.historyTasks
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
})
it('does not count preview fallback when server outputsCount is zero', async () => {
const queueStore = useQueueStore()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 2 })
]
queueStore.hasFetchedHistorySnapshot = true
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-2', outputsCount: 0, hasPreview: true }),
...queueStore.historyTasks
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
})
it('adds only delta when a seen job gains more outputs', async () => {
const queueStore = useQueueStore()
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', hasPreview: true })
]
queueStore.hasFetchedHistorySnapshot = true
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 3 })
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2)
})
it('treats a reappearing job as unseen after it aged out of history', async () => {
const queueStore = useQueueStore()
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 2 })
]
queueStore.hasFetchedHistorySnapshot = true
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-2', outputsCount: 1 })
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 1 }),
createHistoryTask({ id: 'job-2', outputsCount: 1 })
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2)
})
it('clears and suppresses count while assets tab is open', async () => {
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-1', outputsCount: 2 })
]
queueStore.hasFetchedHistorySnapshot = true
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-2', outputsCount: 4 }),
...queueStore.historyTasks
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(4)
sidebarTabStore.activeSidebarTabId = 'assets'
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
queueStore.historyTasks = [
createHistoryTask({ id: 'job-3', outputsCount: 4 }),
...queueStore.historyTasks
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
sidebarTabStore.activeSidebarTabId = 'node-library'
await nextTick()
queueStore.historyTasks = [
createHistoryTask({ id: 'job-4', outputsCount: 1 }),
...queueStore.historyTasks
]
await nextTick()
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
})
})

View File

@@ -1,99 +0,0 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import type { TaskItemImpl } from '@/stores/queueStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const getAddedAssetCount = (task: TaskItemImpl): number => {
if (typeof task.outputsCount === 'number') {
return Math.max(task.outputsCount, 0)
}
return task.previewOutput ? 1 : 0
}
export const useAssetsSidebarBadgeStore = defineStore(
'assetsSidebarBadge',
() => {
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const unseenAddedAssetsCount = ref(0)
const countedHistoryAssetsByJobId = ref(new Map<string, number>())
const hasInitializedHistory = ref(false)
const markCurrentHistoryAsSeen = () => {
countedHistoryAssetsByJobId.value = new Map(
queueStore.historyTasks.map((task) => [
task.jobId,
getAddedAssetCount(task)
])
)
}
watch(
() =>
[
queueStore.historyTasks,
queueStore.hasFetchedHistorySnapshot
] as const,
([historyTasks, hasFetchedHistorySnapshot]) => {
if (!hasFetchedHistorySnapshot) {
return
}
if (!hasInitializedHistory.value) {
hasInitializedHistory.value = true
markCurrentHistoryAsSeen()
return
}
const isAssetsTabOpen = sidebarTabStore.activeSidebarTabId === 'assets'
const previousCountedAssetsByJobId = countedHistoryAssetsByJobId.value
const nextCountedAssetsByJobId = new Map<string, number>()
for (const task of historyTasks) {
const jobId = task.jobId
if (!jobId) {
continue
}
const countedAssets = previousCountedAssetsByJobId.get(jobId) ?? 0
const currentAssets = getAddedAssetCount(task)
const hasSeenJob = previousCountedAssetsByJobId.has(jobId)
if (!isAssetsTabOpen && !hasSeenJob) {
unseenAddedAssetsCount.value += currentAssets
} else if (!isAssetsTabOpen && currentAssets > countedAssets) {
unseenAddedAssetsCount.value += currentAssets - countedAssets
}
nextCountedAssetsByJobId.set(
jobId,
Math.max(countedAssets, currentAssets)
)
}
countedHistoryAssetsByJobId.value = nextCountedAssetsByJobId
},
{ immediate: true }
)
watch(
() => sidebarTabStore.activeSidebarTabId,
(activeSidebarTabId) => {
if (activeSidebarTabId !== 'assets') {
return
}
unseenAddedAssetsCount.value = 0
markCurrentHistoryAsSeen()
}
)
return {
unseenAddedAssetsCount
}
}
)

View File

@@ -92,9 +92,7 @@ export const graphToPrompt = async (
const inputs: ComfyApiWorkflow[string]['inputs'] = {}
const { widgets } = node
// Store all widget values in the API prompt.
// Note: widget.options.serialize controls prompt inclusion (checked here).
// widget.serialize controls workflow persistence (checked by LGraphNode).
// Store all widget values
if (widgets) {
for (const [i, widget] of widgets.entries()) {
if (!widget.name || widget.options?.serialize === false) continue

View File

@@ -242,20 +242,6 @@ export default defineConfig({
tailwindcss(),
typegpuPlugin({}),
comfyAPIPlugin(IS_DEV),
// Exclude proprietary ABCROM fonts from non-cloud builds
{
name: 'exclude-proprietary-fonts',
generateBundle(_options, bundle) {
if (DISTRIBUTION !== 'cloud') {
// Remove ABCROM font files from bundle
for (const [fileName] of Object.entries(bundle)) {
if (/ABCROM.*\.(woff2?|ttf|otf)$/i.test(fileName)) {
delete bundle[fileName]
}
}
}
}
},
// Inject legacy user stylesheet links for desktop/localhost only
{
name: 'inject-user-stylesheet-links',