Compare commits

...

19 Commits

Author SHA1 Message Date
Claude
d930514bea fix: incorporate Fuse search scores into template sorting
When searching templates, the Fuse.js relevance scores were being
discarded when any sort option other than 'default' was selected.
This caused templates with better search matches to be ranked lower
than templates with higher usage/popularity but worse search relevance.

Changes:
- Store Fuse search scores in a Map for use during sorting
- For 'recommended' sort with active search: weight search relevance
  at 60% and base recommendation score at 40%
- For 'popular' sort with active search: weight both equally at 50%
- For VRAM/size sorts: use search relevance as tiebreaker
- 'default' sort preserves Fuse's original relevance order
2026-01-08 20:01:41 +00:00
AustinMroz
99cb7a2da1 Fix linked asset widget promotion in vue (#7895)
Asset widgets resolve the list of models by checking the name of the
node the widget is contained on. When an asset widget is linked to a
subgraph node, a clone is made of the widget and then the clone is used
to initialize an asset widget in vue mode. Since the widget no longer
holds any form of reference to the original node, asset data fails to
resolve.

This is fixed by storing the original nodeType as an option on the
cloned widget when an asset widget is linked to a subgraph input.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/345f9cc1-da04-44ab-8fed-76379c8528de"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/88d1ddaa-56fb-41b3-8d5d-0ded02aaa7d2"
/>|

See also #7563, #7560

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7895-Fix-linked-asset-widget-promotion-in-vue-2e26d73d365081e5b295f6236458b978)
by [Unito](https://www.unito.io)
2026-01-08 09:12:02 -08:00
Terry Jia
b3d87673ec feat: display label_on/label_off for boolean widgets in vueNodes mode (#7894)
## Summary

Add support for displaying custom on/off labels for boolean toggle
widgets, matching the behavior in litegraph mode.

## Screenshots
before - litegraph
<img width="1232" height="600" alt="image"
src="https://github.com/user-attachments/assets/aae91acd-4b6b-4a89-aded-c5445e352006"
/>
before - vueNodes
<img width="869" height="584" alt="image"
src="https://github.com/user-attachments/assets/a69dc71e-45f7-4941-911f-f037a2b1c5c2"
/>

after - vueNodes
<img width="1156" height="608" alt="image"
src="https://github.com/user-attachments/assets/818164a6-826b-4545-bc20-e01625f11d7d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7894-feat-display-label_on-label_off-for-boolean-widgets-in-vueNodes-mode-2e26d73d365081a3b938c87dd4cf23aa)
by [Unito](https://www.unito.io)
2026-01-07 23:04:47 -05:00
Jin Yi
6a733918a7 Improve Import Failed Error Messages (#7871) 2026-01-07 18:54:01 -07:00
Terry Jia
a87d2cf1bd fix: use pre-bundled wwobjloader2 worker for production builds (#7879)
## Summary

The unbundled worker from 'wwobjloader2/worker' has ES module imports
that fail in production builds because Vite's ?url suffix doesn't bundle
dependencies.
Switch to 'wwobjloader2/bundle/worker/module' which is self-contained
with all dependencies included.

Fixes OBJ loading failing in production with Worker error events.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7879-fix-use-pre-bundled-wwobjloader2-worker-for-production-builds-2e16d73d365081f4a485c993852be1d3)
by [Unito](https://www.unito.io)
2026-01-07 20:46:22 -05:00
Terry Jia
a1d689d3b3 fix: wrap image preview navigation dots when overflowing node width (#7891)
## Summary

When Preview Image node has many images, the navigation dots would
overflow beyond the node boundaries. Adding flex-wrap ensures dots wrap
to multiple lines instead of overflowing.

## Screenshots
before
<img width="1175" height="1357" alt="image"
src="https://github.com/user-attachments/assets/1903ae13-c304-4c75-a947-aa879ef9c2e1"
/>

after
<img width="654" height="840" alt="image"
src="https://github.com/user-attachments/assets/37012379-b72f-4b7d-9355-08bac11b094b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7891-fix-wrap-image-preview-navigation-dots-when-overflowing-node-width-2e26d73d36508130a5edf0a0d34f966c)
by [Unito](https://www.unito.io)
2026-01-07 20:42:44 -05:00
Jin Yi
dc64e16f7c feature: media asset card design changes (#7858) 2026-01-07 17:21:26 -08:00
Jin Yi
c19a004f0d Prevent nav item shrink (#7869) 2026-01-08 00:10:37 +00:00
Alexander Brown
626d8dac70 feat: Stale-while-revalidate pattern for AssetBrowserModal (#7880)
## Summary

Implements stale-while-revalidate pattern for AssetBrowserModal to show
cached assets immediately while refreshing in background.

## Changes

### AssetBrowserModal.vue
- Reads assets directly from store cache via computed properties
- Shows loading spinner only when loading AND no cached data exists
- Simplified refresh logic: single `refreshAssets()` call on mount

### assetsStore.ts
- Added `updateModelsForTag(tag)` for tag-based fetching
- Added `updateModelsForKey()` internal helper to unify node type and
tag fetching
- Cache key convention: node types as-is, tags prefixed with `tag:`
- Added `isEqual` check before cache updates to prevent unnecessary
re-renders

### useModelUpload.ts
- Simplified signature from `UseAsyncStateReturn<...>['execute']` to `()
=> Promise<unknown> | void`

## UX Improvement

| Scenario | Before | After |
|----------|--------|-------|
| First open | Spinner → Assets | Spinner → Assets |
| Re-open same type | Spinner → Assets | Instant + silent refresh |
| Re-open after download | Spinner → Assets | Cached + auto-update |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7880-feat-Stale-while-revalidate-pattern-for-AssetBrowserModal-2e16d73d365081ba93f4d6e0415ebfae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-07 15:10:03 -08:00
Alexander Brown
b6a12ddae1 Cleanup: Remove test block from vite.config.ts since we already have a vitest.config.ts (#7873)
## Summary

Just removing the extra configuration.
https://vitest.dev/config/

We _could_ go the other direction and move the vitest configuration
options into the main vite config file.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7873-Cleanup-Remove-test-block-from-vite-config-ts-since-we-already-have-a-vitest-config-ts-2e16d73d3650819ea07bd3bf3416bf11)
by [Unito](https://www.unito.io)
2026-01-07 13:07:05 -08:00
Johnpaul Chiwetelu
11f8cdb9bd fix: make hover-based buttons accessible on touch devices (#7872)
## Summary
- Define `touch:` Tailwind variant using `@media (hover: none)` to
target touch devices
- Add `touch:opacity-100` to `TreeExplorerTreeNode` for node action
buttons
- Add `useMediaQuery('(hover: none)')` to `MediaAssetCard` for action
overlay visibility

## Problem
On touch devices, sidebar buttons that appear on hover are inaccessible
because:
1. The `touch:` Tailwind variant was used but never defined (classes
silently ignored)
2. `TreeExplorerTreeNode` had no touch support for action buttons
3. `MediaAssetCard` used JS-based `useElementHover` which doesn't work
on touch

## Screenshots (Touch Device Emulation)

### Before (main branch)
- No "Generated"/"Imported" tabs visible in header
- Only duration chips shown on cards, no action buttons (zoom, menu)

![Before - Touch Device](https://i.imgur.com/V0qcr2D.png)

### After (with fix)
- "Generated"/"Imported" tabs visible in header
- Action buttons (zoom, menu) visible on left of cards
- Duration chips moved to right side

![After - Touch Device](https://i.imgur.com/vQ3dUBc.png)

## Test plan
- [ ] On touch device: verify Media Assets sidebar
"Imported"/"Generated" tabs are visible
- [ ] On touch device: verify Node Library filter buttons are visible
- [ ] On touch device: verify tree node action buttons (bookmark, help)
are visible
- [ ] On touch device: verify media asset card zoom/menu buttons are
visible
- [ ] On desktop with mouse: verify hover behavior still works as
expected
2026-01-07 07:29:39 +01:00
AustinMroz
dcf0886d89 Dynamic input fixes (#7837)
A couple small dynamic input fixes.
- When removing widgets, call any onRemove methods
  - This is required for DOMWidgets to properly clean themself up.
- Resolve actual current link state when initializing match type
- This is only a partial fix for combing matchtype with autogrow, there
is a separate issue with skipped initialization that will need to be
resolved separately in the future.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7837-Dynamic-input-fixes-2de6d73d365081bdb263ed659e25e6ea)
by [Unito](https://www.unito.io)
2026-01-06 19:45:36 -07:00
Simula_r
ab6678534f Feat(cloud)/pricing plan template details (#7867)
## Summary

- Use video helper popover in top up modal
- Update copy for video helper
- Misc style changes

## Changes

- **What**: /en/main.json, TopUpCreditsDialogContent.vue,
PricingTable.vue
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Screenshots (if applicable)

<img width="2226" height="1322" alt="image"
src="https://github.com/user-attachments/assets/e8419c73-f26c-4d1c-84a6-10cdd10937c4"
/>
<img width="2880" height="1624" alt="image"
src="https://github.com/user-attachments/assets/b27c3665-5eae-4983-a40b-f88705bf53be"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7867-Feat-cloud-pricing-plan-template-details-2e16d73d365081599610e47151b3783b)
by [Unito](https://www.unito.io)
2026-01-06 19:45:06 -07:00
Comfy Org PR Bot
ea3b3ceb00 1.37.5 (#7866)
Patch version increment to 1.37.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7866-1-37-5-2e16d73d365081ecafa2f325c415f4a2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-06 19:15:39 -07:00
Terry Jia
2356b0bc9e fix: prevent image preview resize issues when switching to vueNodes mode (#7868)
## Summary
- Fix duplicate rendering issue for image preview nodes when switching
from litegraph to vueNodes mode by setting canvasOnly: true on
ImagePreviewWidget

## Problem

When switching from litegraph to vueNodes mode, image preview nodes
(LoadImage, PreviewImage) had two issues:

1. Node becoming longer: The ImagePreviewWidget was being rendered twice
- once as a WidgetLegacy canvas (with stale computedHeight from
litegraph mode) and once as Vue's ImagePreview component

## Solution

1. Set canvasOnly: true for ImagePreviewWidget so it won't render as
WidgetLegacy in Vue mode (Vue's ImagePreview.vue already handles image
display)


## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/925c4fb4-bc9a-4da5-b8ae-3557c2d3836b


after


https://github.com/user-attachments/assets/5faa6878-c56d-44dd-86f5-728bff9ad58a

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7868-fix-prevent-image-preview-resize-issues-when-switching-to-vueNodes-mode-2e16d73d36508106a058da2f8d17c410)
by [Unito](https://www.unito.io)
2026-01-06 19:15:20 -07:00
Terry Jia
dad1eafecc feat: add skeleton visualization toggle for 3D models (#7857)
## Summary

For better support animation 3d model custon node, such as
https://github.com/jtydhr88/ComfyUI-HY-Motion1, add ability to show/hide
skeleton bones in Load3D nodes for models with skeletal animation. Uses
THREE.SkeletonHelper with root bone detection to properly support both
FBX and GLB model formats.

## Screenshots


https://github.com/user-attachments/assets/df9de4a6-549e-4227-aa00-8859d71f43d1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7857-feat-add-skeleton-visualization-toggle-for-3D-models-2e06d73d365081a39f49f81f72657a70)
by [Unito](https://www.unito.io)
2026-01-06 19:11:06 -07:00
Luke Mino-Altherr
6e5dfc0109 feat: split asset_update_options_enabled into separate deletion and rename flags (#7864)
## Summary
Replace single `asset_update_options_enabled` feature flag with two
granular flags:
- `asset_deletion_enabled`: controls delete button visibility
- `asset_rename_enabled`: controls rename button visibility

The context menu only shows when at least one flag is enabled.

## Changes
- Updated `ServerFeatureFlag` enum with new flag names
- Updated `RemoteConfig` type with new properties
- Updated `AssetCard.vue` to conditionally show rename/delete buttons
based on their respective flags

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7864-feat-split-asset_update_options_enabled-into-separate-deletion-and-rename-flags-2e06d73d365081f9ac0afa12b87bd988)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-06 23:56:38 +00:00
Alexander Brown
43f0ac2e8f Chore: Typescript cleanup (1 / N) (#7817)
## Summary

Remove 178 `@ts-expect-error` suppressions (935 → 757, 19% reduction) by
fixing underlying type issues instead of suppressing errors.

## Changes

- **What**: Type safety improvements across `src/lib/litegraph/` and
related test files
  - Prefix unused callback parameters with `_` instead of suppressing
  - Use type intersections for mock methods on real objects
  - Use `Partial<T>` for incomplete test objects instead of `as unknown`
  - Add non-null assertions after `.toBeDefined()` checks in tests
  - Let TypeScript infer vitest fixture parameter types
- **Breaking**: None

## Review Focus

- `LGraphCanvas.ts` has the largest changes (232 lines) — all mechanical
unused parameter fixes
- Test files use type intersection pattern for mocks: `node as
LGraphNode & { mockFn: ... }`
- Removed dead code: `src/platform/cloud/onboarding/auth.ts` (47 lines,
unused)

### Key Files
| File | Change |
|------|--------|
| `LGraphCanvas.ts` | 57 suppressions removed (unused params) |
| `subgraph/__fixtures__/*` | Fixture type improvements |
| `*.test.ts` files | Mock typing with intersections |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7817-WIP-Chore-Typescript-cleanup-2da6d73d365081d1ade9e09a6c5bf935)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-06 15:47:50 -08:00
Benjamin Lu
76a0b0b4b4 [QPOv2] Add N active jobs and clear queue button (#7731)
Add text displaying N active jobs and a clear queue button to the media
assets sidebar tab.

<img width="824" height="208" alt="image"
src="https://github.com/user-attachments/assets/6996251a-8d2c-4527-ba1c-26f450859236"
/>

Part of the QPO v2 iteration, figma design can be found
[here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev).
This will be implemented in a series of stacked PRs that can be reviewed
and merged individually.

main <-- #7731, #7737, #7743, #7745

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7731-QPOv2-Add-N-active-jobs-and-clear-queue-button-2d16d73d365081468c1ce8f9d1b9a0c1)
by [Unito](https://www.unito.io)
2026-01-06 15:06:33 -08:00
87 changed files with 1418 additions and 1016 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.4",
"version": "1.37.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -9,6 +9,8 @@
@config '../../tailwind.config.ts';
@custom-variant touch (@media (hover: none));
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);

View File

@@ -2,7 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
backgroundClass || 'bg-secondary-background'
)
"
>
@@ -12,4 +13,8 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { backgroundClass } = defineProps<{
backgroundClass?: string
}>()
</script>

View File

@@ -28,7 +28,7 @@
/>
</div>
<div
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
</div>

View File

@@ -22,7 +22,7 @@
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" size="sm" @click="openManager">{{
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton

View File

@@ -49,25 +49,66 @@
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoTemplateBasedCredits') }}
</span>
</div>
</div>
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
@click="handleBuy"
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
@click="handleBuy"
>
{{ $t('credits.topUp.buy') }}
</Button>
</div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
{{ $t('credits.topUp.buy') }}
</Button>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
>
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">
import { Popover } from 'primevue'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -101,22 +142,28 @@ const toast = useToast()
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
const popover = ref()
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
description: t('credits.topUp.videosEstimate', { count: 30 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
description: t('credits.topUp.videosEstimate', { count: 60 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
description: t('credits.topUp.videosEstimate', { count: 120 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
description: t('credits.topUp.videosEstimate', { count: 301 })
}
]

View File

@@ -24,6 +24,7 @@
v-model:light-config="lightConfig"
:is-splat-model="isSplatModel"
:is-ply-model="isPlyModel"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
/>
@@ -116,6 +117,7 @@ const {
isPreview,
isSplatModel,
isPlyModel,
hasSkeleton,
hasRecording,
recordingDuration,
animations,

View File

@@ -58,8 +58,10 @@
v-if="showModelControls"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
v-model:show-skeleton="modelConfig!.showSkeleton"
:hide-material-mode="isSplatModel"
:is-ply-model="isPlyModel"
:has-skeleton="hasSkeleton"
/>
<CameraControls
@@ -99,9 +101,14 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { isSplatModel = false, isPlyModel = false } = defineProps<{
const {
isSplatModel = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
isSplatModel?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -70,6 +70,22 @@
</div>
</div>
</div>
<div v-if="hasSkeleton">
<Button
v-tooltip.right="{
value: t('load3d.showSkeleton'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
:aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton"
>
<i class="pi pi-sitemap text-lg text-white" />
</Button>
</div>
</div>
</template>
@@ -84,13 +100,19 @@ import type {
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
const {
hideMaterialMode = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
const showSkeleton = defineModel<boolean>('showSkeleton')
const showUpDirection = ref(false)
const showMaterialMode = ref(false)

View File

@@ -52,7 +52,31 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
<div
v-if="isQueuePanelV2Enabled"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="loading && !displayAssets.length">
@@ -165,7 +189,7 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { Divider } from 'primevue'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -188,18 +212,26 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = ref<'list' | 'grid'>('grid')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
@@ -228,6 +260,19 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobs',
{ count: n(count) },
count
)
})
const toast = useToast()
const inputAssets = useMediaAssets('input')
@@ -492,6 +537,10 @@ const handleDeleteSelected = async () => {
clearSelection()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}
const handleApproachEnd = useDebounceFn(async () => {
if (
activeTab.value === 'output' &&

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<i :class="icon" class="text-neutral text-sm" />
<i :class="icon" class="text-neutral text-sm shrink-0" />
</template>
<script setup lang="ts">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'
@@ -9,9 +9,11 @@
role="button"
@click="onClick"
>
<NavIcon v-if="icon" :icon="icon" />
<i v-else class="text-neutral icon-[lucide--folder] text-xs" />
<span class="flex items-center">
<div v-if="icon" class="py-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span class="flex items-center break-all">
<slot></slot>
</span>
</div>

View File

@@ -11,7 +11,8 @@ export enum ServerFeatureFlag {
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
@@ -42,14 +43,16 @@ export function useFeatureFlags() {
)
)
},
get assetUpdateOptionsEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
get assetDeletionEnabled() {
return (
remoteConfig.value.asset_update_options_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
remoteConfig.value.asset_deletion_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
)
},
get assetRenameEnabled() {
return (
remoteConfig.value.asset_rename_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
)
},
get privateModelsEnabled() {

View File

@@ -54,7 +54,8 @@ describe('useLoad3d', () => {
},
'Model Config': {
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
showSkeleton: false
},
'Camera Config': {
cameraType: 'perspective',
@@ -107,6 +108,8 @@ describe('useLoad3d', () => {
exportModel: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
hasSkeleton: vi.fn().mockReturnValue(false),
setShowSkeleton: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
@@ -143,7 +146,8 @@ describe('useLoad3d', () => {
})
expect(composable.modelConfig.value).toEqual({
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
showSkeleton: false
})
expect(composable.cameraConfig.value).toEqual({
cameraType: 'perspective',
@@ -410,7 +414,8 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(mockNode.properties['Model Config']).toEqual({
upDirection: '+y',
materialMode: 'wireframe'
materialMode: 'wireframe',
showSkeleton: false
})
})
@@ -696,10 +701,13 @@ describe('useLoad3d', () => {
'backgroundImageLoadingEnd',
'modelLoadingStart',
'modelLoadingEnd',
'skeletonVisibilityChange',
'exportLoadingStart',
'exportLoadingEnd',
'recordingStatusChange',
'animationListChange'
'animationListChange',
'animationProgressChange',
'cameraChanged'
]
expectedEvents.forEach((event) => {

View File

@@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const modelConfig = ref<ModelConfig>({
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
showSkeleton: false
})
const hasSkeleton = ref(false)
const cameraConfig = ref<CameraConfig>({
cameraType: 'perspective',
fov: 75
@@ -273,6 +276,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Model Config'] = newValue
load3d.setUpDirection(newValue.upDirection)
load3d.setMaterialMode(newValue.materialMode)
load3d.setShowSkeleton(newValue.showSkeleton)
}
},
{ deep: true }
@@ -503,6 +507,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
hasSkeleton.value = load3d?.hasSkeleton() ?? false
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
},
skeletonVisibilityChange: (value: boolean) => {
modelConfig.value.showSkeleton = value
},
exportLoadingStart: (message: string) => {
loadingMessage.value = message || t('load3d.exportingModel')
@@ -584,6 +594,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
hasSkeleton,
hasRecording,
recordingDuration,
animations,

View File

@@ -272,4 +272,108 @@ describe('useTemplateFiltering', () => {
'beta-pro'
])
})
it('incorporates search relevance into recommended sorting', async () => {
vi.useFakeTimers()
const templates = ref<TemplateInfo[]>([
{
name: 'wan-video-exact',
title: 'Wan Video Template',
description: 'A template with Wan in title',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
usage: 10
},
{
name: 'qwen-image-partial',
title: 'Qwen Image Editor',
description: 'A template that contains w, a, n scattered',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
usage: 1000 // Higher usage but worse search match
},
{
name: 'wan-text-exact',
title: 'Wan2.5: Text to Image',
description: 'Another exact match for Wan',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
usage: 50
}
])
const { searchQuery, sortBy, filteredTemplates } =
useTemplateFiltering(templates)
// Search for "Wan"
searchQuery.value = 'Wan'
sortBy.value = 'recommended'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
// Templates with "Wan" in title should rank higher than Qwen despite lower usage
// because search relevance is now factored into the recommended sort
const results = filteredTemplates.value.map((t) => t.name)
// Verify exact matches appear (Qwen might be filtered out by threshold)
expect(results).toContain('wan-video-exact')
expect(results).toContain('wan-text-exact')
// If Qwen appears, it should be ranked lower than exact matches
if (results.includes('qwen-image-partial')) {
const wanIndex = results.indexOf('wan-video-exact')
const qwenIndex = results.indexOf('qwen-image-partial')
expect(wanIndex).toBeLessThan(qwenIndex)
}
vi.useRealTimers()
})
it('preserves Fuse search order when using default sort', async () => {
vi.useFakeTimers()
const templates = ref<TemplateInfo[]>([
{
name: 'portrait-basic',
title: 'Basic Portrait',
description: 'A basic template',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'portrait-pro',
title: 'Portrait Pro Edition',
description: 'Advanced portrait features',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'landscape-view',
title: 'Landscape Generator',
description: 'Generate landscapes',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { searchQuery, sortBy, filteredTemplates } =
useTemplateFiltering(templates)
searchQuery.value = 'Portrait Pro'
sortBy.value = 'default'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
const results = filteredTemplates.value.map((t) => t.name)
// With default sort, Fuse's relevance ordering is preserved
// "Portrait Pro Edition" should be first as it's the best match
expect(results[0]).toBe('portrait-pro')
})
})

View File

@@ -82,13 +82,31 @@ export function useTemplateFiltering(
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const filteredBySearch = computed(() => {
// Store Fuse search results with scores for use in sorting
const fuseSearchResults = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return null
}
return fuse.value.search(debouncedSearchQuery.value)
})
// Map of template name to search score (lower is better in Fuse, 0 = perfect match)
const searchScoreMap = computed(() => {
const map = new Map<string, number>()
if (fuseSearchResults.value) {
fuseSearchResults.value.forEach((result) => {
// Store the score (0 = perfect match, 1 = worst match)
map.set(result.item.name, result.score ?? 1)
})
}
return map
})
const filteredBySearch = computed(() => {
if (!fuseSearchResults.value) {
return templatesArray.value
}
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
return fuseSearchResults.value.map((result) => result.item)
})
const filteredByModels = computed(() => {
@@ -165,31 +183,66 @@ export function useTemplateFiltering(
{ immediate: true }
)
// Helper to get search relevance score (higher is better, 0-1 range)
// Fuse returns scores where 0 = perfect match, 1 = worst match
// We invert it so higher = better for combining with other scores
const getSearchRelevance = (template: TemplateInfo): number => {
const fuseScore = searchScoreMap.value.get(template.name)
if (fuseScore === undefined) return 0 // Not in search results or no search
return 1 - fuseScore // Invert: 0 (worst) -> 1 (best)
}
const hasActiveSearch = computed(
() => debouncedSearchQuery.value.trim() !== ''
)
const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]
switch (sortBy.value) {
case 'recommended':
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
// When searching, heavily weight search relevance
// Formula with search: searchRelevance × 0.6 + (usage × 0.5 + internal × 0.3 + freshness × 0.2) × 0.4
// Formula without search: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
const scoreA = rankingStore.computeDefaultScore(
const baseScoreA = rankingStore.computeDefaultScore(
a.date,
a.searchRank,
a.usage
)
const scoreB = rankingStore.computeDefaultScore(
const baseScoreB = rankingStore.computeDefaultScore(
b.date,
b.searchRank,
b.usage
)
return scoreB - scoreA
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
const finalA = searchA * 0.6 + baseScoreA * 0.4
const finalB = searchB * 0.6 + baseScoreB * 0.4
return finalB - finalA
}
return baseScoreB - baseScoreA
})
case 'popular':
// User-driven: usage × 0.9 + freshness × 0.1
// When searching, include search relevance
// Formula with search: searchRelevance × 0.5 + (usage × 0.9 + freshness × 0.1) × 0.5
// Formula without search: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
const baseScoreA = rankingStore.computePopularScore(a.date, a.usage)
const baseScoreB = rankingStore.computePopularScore(b.date, b.usage)
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
const finalA = searchA * 0.5 + baseScoreA * 0.5
const finalB = searchB * 0.5 + baseScoreB * 0.5
return finalB - finalA
}
return baseScoreB - baseScoreA
})
case 'alphabetical':
return templates.sort((a, b) => {
@@ -209,6 +262,12 @@ export function useTemplateFiltering(
const vramB = getVramMetric(b)
if (vramA === vramB) {
// Use search relevance as tiebreaker when searching
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
if (searchA !== searchB) return searchB - searchA
}
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
@@ -225,11 +284,20 @@ export function useTemplateFiltering(
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
if (sizeA === sizeB) return 0
if (sizeA === sizeB) {
// Use search relevance as tiebreaker when searching
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
if (searchA !== searchB) return searchB - searchA
}
return 0
}
return sizeA - sizeB
})
case 'default':
default:
// 'default' preserves Fuse's search order (already sorted by relevance)
return templates
}
})

View File

@@ -94,7 +94,7 @@ function dynamicComboWidget(
const newSpec = value ? options[value] : undefined
const removedInputs = remove(node.inputs, isInGroup)
remove(node.widgets, isInGroup)
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
if (!newSpec) return
@@ -341,10 +341,16 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
//TODO: instead apply on output add?
//ensure outputs get updated
const index = node.inputs.length - 1
const input = node.inputs.at(-1)!
requestAnimationFrame(() =>
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
)
requestAnimationFrame(() => {
const input = node.inputs.at(index)!
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,
!!input.link,
input.link ? node.graph?.links?.[input.link] : undefined,
input
)
})
}
function autogrowOrdinalToName(
@@ -482,7 +488,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
remove(node.widgets, (w) => w.name === widgetName)
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
widget.onRemove?.()
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -196,8 +196,7 @@ export class GroupNodeConfig {
primitiveToWidget: {}
nodeInputs: {}
outputVisibility: any[]
// @ts-expect-error fixme ts strict error
nodeDef: ComfyNodeDef
nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined
// @ts-expect-error fixme ts strict error
inputs: any[]
// @ts-expect-error fixme ts strict error
@@ -231,8 +230,7 @@ export class GroupNodeConfig {
output: [],
output_name: [],
output_is_list: [],
// @ts-expect-error Unused, doesn't exist
output_is_hidden: [],
output_node: false, // This is a lie (to satisfy the interface)
name: source + SEPARATOR + this.name,
display_name: this.name,
category: 'group nodes' + (SEPARATOR + source),
@@ -261,6 +259,7 @@ export class GroupNodeConfig {
}
// @ts-expect-error fixme ts strict error
this.#convertedToProcess = null
if (!this.nodeDef) return
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
useNodeDefStore().addNodeDef(this.nodeDef)
}

View File

@@ -13,8 +13,6 @@ export class CameraManager implements CameraManagerInterface {
orthographicCamera: THREE.OrthographicCamera
activeCamera: THREE.Camera
// @ts-expect-error unused variable
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private controls: OrbitControls | null = null
@@ -42,10 +40,9 @@ export class CameraManager implements CameraManagerInterface {
}
constructor(
renderer: THREE.WebGLRenderer,
_renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.eventManager = eventManager
this.perspectiveCamera = new THREE.PerspectiveCamera(

View File

@@ -156,8 +156,9 @@ class Load3DConfiguration {
return {
upDirection: 'original',
materialMode: 'original'
} as ModelConfig
materialMode: 'original',
showSkeleton: false
}
}
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {

View File

@@ -727,6 +727,19 @@ class Load3d {
return this.animationManager.animationClips.length > 0
}
public hasSkeleton(): boolean {
return this.modelManager.hasSkeleton()
}
public setShowSkeleton(show: boolean): void {
this.modelManager.setShowSkeleton(show)
this.forceRender()
}
public getShowSkeleton(): boolean {
return this.modelManager.showSkeleton
}
public getAnimationTime(): number {
return this.animationManager.getAnimationTime()
}

View File

@@ -6,7 +6,9 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
import OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
// Use pre-bundled worker module (has all dependencies included)
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'

View File

@@ -27,13 +27,11 @@ export class SceneManager implements SceneManagerInterface {
private renderer: THREE.WebGLRenderer
private getActiveCamera: () => THREE.Camera
// @ts-expect-error unused variable
private getControls: () => OrbitControls
constructor(
renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
getControls: () => OrbitControls,
_getControls: () => OrbitControls,
eventManager: EventManagerInterface
) {
this.renderer = renderer
@@ -41,7 +39,6 @@ export class SceneManager implements SceneManagerInterface {
this.scene = new THREE.Scene()
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.gridHelper = new THREE.GridHelper(20, 20)
this.gridHelper.position.set(0, 0, 0)

View File

@@ -30,6 +30,8 @@ export class SceneModelManager implements ModelManagerInterface {
originalURL: string | null = null
appliedTexture: THREE.Texture | null = null
textureLoader: THREE.TextureLoader
skeletonHelper: THREE.SkeletonHelper | null = null
showSkeleton: boolean = false
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
@@ -414,9 +416,69 @@ export class SceneModelManager implements ModelManagerInterface {
this.appliedTexture = null
}
if (this.skeletonHelper) {
this.scene.remove(this.skeletonHelper)
this.skeletonHelper.dispose()
this.skeletonHelper = null
}
this.showSkeleton = false
this.originalMaterials = new WeakMap()
}
hasSkeleton(): boolean {
if (!this.currentModel) return false
let found = false
this.currentModel.traverse((child) => {
if (child instanceof THREE.SkinnedMesh && child.skeleton) {
found = true
}
})
return found
}
setShowSkeleton(show: boolean): void {
this.showSkeleton = show
if (show) {
if (!this.skeletonHelper && this.currentModel) {
let rootBone: THREE.Bone | null = null
this.currentModel.traverse((child) => {
if (child instanceof THREE.Bone && !rootBone) {
if (!(child.parent instanceof THREE.Bone)) {
rootBone = child
}
}
})
if (rootBone) {
this.skeletonHelper = new THREE.SkeletonHelper(rootBone)
this.scene.add(this.skeletonHelper)
} else {
let skinnedMesh: THREE.SkinnedMesh | null = null
this.currentModel.traverse((child) => {
if (child instanceof THREE.SkinnedMesh && !skinnedMesh) {
skinnedMesh = child
}
})
if (skinnedMesh) {
this.skeletonHelper = new THREE.SkeletonHelper(skinnedMesh)
this.scene.add(this.skeletonHelper)
}
}
} else if (this.skeletonHelper) {
this.skeletonHelper.visible = true
}
} else {
if (this.skeletonHelper) {
this.skeletonHelper.visible = false
}
}
this.eventManager.emitEvent('skeletonVisibilityChange', show)
}
addModelToScene(model: THREE.Object3D): void {
this.currentModel = model
model.name = 'MainModel'

View File

@@ -14,16 +14,13 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
private getActiveCamera: () => THREE.Camera
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private renderer: THREE.WebGLRenderer
constructor(
renderer: THREE.WebGLRenderer,
_renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
getControls: () => OrbitControls,
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.eventManager = eventManager

View File

@@ -34,6 +34,7 @@ export interface SceneConfig {
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
showSkeleton: boolean
}
export interface CameraConfig {

View File

@@ -5,16 +5,14 @@ import { LGraphButton, Rectangle } from '@/lib/litegraph/src/litegraph'
describe('LGraphButton', () => {
describe('Constructor', () => {
it('should create a button with default options', () => {
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({})
const button = new LGraphButton({ text: '' })
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(button._last_area).toBeInstanceOf(Rectangle)
})
it('should create a button with custom name', () => {
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({ name: 'test_button' })
const button = new LGraphButton({ text: '', name: 'test_button' })
expect(button.name).toBe('test_button')
})
@@ -158,9 +156,8 @@ describe('LGraphButton', () => {
const button = new LGraphButton({
text: '→',
fontSize: 20,
// @ts-expect-error TODO: Fix after merge - color property not defined in type
color: '#FFFFFF',
backgroundColor: '#333333',
fgColor: '#FFFFFF',
bgColor: '#333333',
xOffset: -10,
yOffset: 5
})

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
@@ -46,8 +47,8 @@ describe('LGraphCanvas Title Button Rendering', () => {
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
// @ts-expect-error TODO: Fix after merge - LGraphCanvas constructor type issues
canvas = new LGraphCanvas(canvasElement, null, {
const graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
@@ -56,18 +57,9 @@ describe('LGraphCanvas Title Button Rendering', () => {
node.pos = [100, 200]
node.size = [200, 100]
// Mock required methods
node.drawTitleBarBackground = vi.fn()
// @ts-expect-error Property 'drawTitleBarText' does not exist on type 'LGraphNode'
node.drawTitleBarText = vi.fn()
node.drawBadges = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawToggles not defined in type
node.drawToggles = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawNodeShape not defined in type
node.drawNodeShape = vi.fn()
node.drawSlots = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawContent not defined in type
node.drawContent = vi.fn()
node.drawWidgets = vi.fn()
node.drawCollapsedSlots = vi.fn()
node.drawTitleBox = vi.fn()
@@ -75,24 +67,31 @@ describe('LGraphCanvas Title Button Rendering', () => {
node.drawProgressBar = vi.fn()
node._setConcreteSlots = vi.fn()
node.arrange = vi.fn()
// @ts-expect-error TODO: Fix after merge - isSelectable not defined in type
node.isSelectable = vi.fn().mockReturnValue(true)
const nodeWithMocks = node as LGraphNode & {
drawTitleBarText: ReturnType<typeof vi.fn>
drawToggles: ReturnType<typeof vi.fn>
drawNodeShape: ReturnType<typeof vi.fn>
drawContent: ReturnType<typeof vi.fn>
isSelectable: ReturnType<typeof vi.fn>
}
nodeWithMocks.drawTitleBarText = vi.fn()
nodeWithMocks.drawToggles = vi.fn()
nodeWithMocks.drawNodeShape = vi.fn()
nodeWithMocks.drawContent = vi.fn()
nodeWithMocks.isSelectable = vi.fn().mockReturnValue(true)
})
describe('drawNode title button rendering', () => {
it('should render visible title buttons', () => {
const button1 = node.addTitleButton({
name: 'button1',
text: 'A',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'A'
})
const button2 = node.addTitleButton({
name: 'button2',
text: 'B',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'B'
})
// Mock button methods
@@ -127,9 +126,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
it('should skip invisible title buttons', () => {
const visibleButton = node.addTitleButton({
name: 'visible',
text: 'V',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'V'
})
const invisibleButton = node.addTitleButton({
@@ -171,9 +168,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
for (let i = 0; i < 3; i++) {
const button = node.addTitleButton({
name: `button${i}`,
text: String(i),
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: String(i)
})
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
const spy = vi.spyOn(button, 'draw')
@@ -196,18 +191,12 @@ describe('LGraphCanvas Title Button Rendering', () => {
it('should render buttons in low quality mode', () => {
const button = node.addTitleButton({
name: 'test',
text: 'T',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'T'
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
// Set low quality rendering
// @ts-expect-error TODO: Fix after merge - lowQualityRenderingRequired not defined in type
canvas.lowQualityRenderingRequired = true
canvas.drawNode(node, ctx)
// Buttons should still be rendered in low quality mode
@@ -219,16 +208,12 @@ describe('LGraphCanvas Title Button Rendering', () => {
it('should handle buttons with different widths', () => {
const smallButton = node.addTitleButton({
name: 'small',
text: 'S',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'S'
})
const largeButton = node.addTitleButton({
name: 'large',
text: 'LARGE',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'LARGE'
})
smallButton.getWidth = vi.fn().mockReturnValue(15)
@@ -256,9 +241,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
const button = node.addTitleButton({
name: 'test',
text: 'X',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
text: 'X'
})
button.getWidth = vi.fn().mockReturnValue(20)

View File

@@ -969,10 +969,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onGroupAdd(
// @ts-expect-error - unused parameter
info: unknown,
// @ts-expect-error - unused parameter
entry: unknown,
_info: unknown,
_entry: unknown,
mouse_event: MouseEvent
): void {
const canvas = LGraphCanvas.active_canvas
@@ -1020,10 +1018,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onNodeAlign(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
_value: IContextMenuValue,
_options: IContextMenuOptions,
event: MouseEvent,
prev_menu: ContextMenu<string>,
node: LGraphNode
@@ -1046,10 +1042,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onGroupAlign(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
_value: IContextMenuValue,
_options: IContextMenuOptions,
event: MouseEvent,
prev_menu: ContextMenu<string>
): void {
@@ -1070,10 +1064,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static createDistributeMenu(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
_value: IContextMenuValue,
_options: IContextMenuOptions,
event: MouseEvent,
prev_menu: ContextMenu<string>
): void {
@@ -1095,16 +1087,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuAdd(
// @ts-expect-error - unused parameter
value: unknown,
// @ts-expect-error - unused parameter
options: unknown,
_value: unknown,
_options: unknown,
e: MouseEvent,
prev_menu?: ContextMenu<string>,
callback?: (node: LGraphNode | null) => void
): boolean | undefined {
const canvas = LGraphCanvas.active_canvas
const ref_window = canvas.getCanvasWindow()
const { graph } = canvas
if (!graph) return
@@ -1155,14 +1144,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value: category_path,
content: name,
has_submenu: true,
callback: function (
value,
// @ts-expect-error - unused parameter
event,
// @ts-expect-error - unused parameter
mouseEvent,
contextMenu
) {
callback: function (value, _event, _mouseEvent, contextMenu) {
inner_onMenuAdded(value.value, contextMenu)
}
})
@@ -1181,14 +1163,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value: node.type,
content: node.title,
has_submenu: false,
callback: function (
value,
// @ts-expect-error - unused parameter
event,
// @ts-expect-error - unused parameter
mouseEvent,
contextMenu
) {
callback: function (value, _event, _mouseEvent, contextMenu) {
if (!canvas.graph) throw new NullGraphError()
const first_event = contextMenu.getFirstEvent()
@@ -1213,12 +1188,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
entries.push(entry)
}
new LiteGraph.ContextMenu(
entries,
{ event: e, parentMenu: prev_menu },
// @ts-expect-error - extra parameter
ref_window
)
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
}
}
@@ -1227,8 +1197,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param _options Parameter is never used */
static showMenuNodeOptionalOutputs(
// @ts-expect-error - unused parameter
v: unknown,
_v: unknown,
/** Unused - immediately overwritten */
_options: INodeOutputSlot[],
e: MouseEvent,
@@ -1312,8 +1281,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param value Parameter is never used */
static onShowMenuNodeProperties(
value: NodeProperty | undefined,
// @ts-expect-error - unused parameter
options: unknown,
_options: unknown,
e: MouseEvent,
prev_menu: ContextMenu<string>,
node: LGraphNode
@@ -1321,7 +1289,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!node || !node.properties) return
const canvas = LGraphCanvas.active_canvas
const ref_window = canvas.getCanvasWindow()
const entries: IContextMenuValue<string>[] = []
for (const i in node.properties) {
@@ -1344,23 +1311,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
new LiteGraph.ContextMenu<string>(
entries,
{
event: e,
callback: inner_clicked,
parentMenu: prev_menu,
allow_html: true,
node
},
// @ts-expect-error Unused
ref_window
)
new LiteGraph.ContextMenu<string>(entries, {
event: e,
callback: inner_clicked,
parentMenu: prev_menu,
node
})
function inner_clicked(this: ContextMenuDivElement, v: { value: any }) {
if (!node) return
function inner_clicked(
this: ContextMenu<string>,
v?: string | IContextMenuValue<string>
) {
if (!node || typeof v === 'string' || !v?.value) return
const rect = this.getBoundingClientRect()
const rect = this.root.getBoundingClientRect()
canvas.showEditPropertyValue(node, v.value, {
position: [rect.left, rect.top]
})
@@ -1377,14 +1341,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuResizeNode(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
node: LGraphNode
): void {
if (!node) return
@@ -1411,11 +1371,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// TODO refactor :: this is used fot title but not for properties!
static onShowPropertyEditor(
item: { property: keyof LGraphNode; type: string },
// @ts-expect-error - unused parameter
options: IContextMenuOptions<string>,
_options: IContextMenuOptions<string>,
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu<string>,
_menu: ContextMenu<string>,
node: LGraphNode
): void {
const property = item.property || 'title'
@@ -1485,11 +1443,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
input.focus()
let dialogCloseTimer: number
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
dialog.addEventListener('mouseleave', function () {
if (LiteGraph.dialog_close_on_mouse_leave) {
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
// @ts-expect-error - setTimeout type
dialogCloseTimer = setTimeout(
dialog.close,
LiteGraph.dialog_close_on_mouse_leave_delay
@@ -1544,14 +1501,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuNodeCollapse(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
node: LGraphNode
): void {
if (!node.graph) throw new NullGraphError()
@@ -1578,14 +1531,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuToggleAdvanced(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
node: LGraphNode
): void {
if (!node.graph) throw new NullGraphError()
@@ -1610,10 +1559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuNodeMode(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
_value: IContextMenuValue,
_options: IContextMenuOptions,
e: MouseEvent,
menu: ContextMenu,
node: LGraphNode
@@ -1657,8 +1604,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param value Parameter is never used */
static onMenuNodeColors(
value: IContextMenuValue<string | null>,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
_options: IContextMenuOptions,
e: MouseEvent,
menu: ContextMenu<string | null>,
node: LGraphNode
@@ -1719,10 +1665,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuNodeShapes(
// @ts-expect-error - unused parameter
value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
// @ts-expect-error - unused parameter
options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
_value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
_options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
e: MouseEvent,
menu?: ContextMenu<(typeof LiteGraph.VALID_SHAPES)[number]>,
node?: LGraphNode
@@ -3596,13 +3540,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_over?.onMouseUp?.(
e,
[x - this.node_over.pos[0], y - this.node_over.pos[1]],
// @ts-expect-error - extra parameter
this
)
this.node_capturing_input?.onMouseUp?.(e, [
x - this.node_capturing_input.pos[0],
y - this.node_capturing_input.pos[1]
])
this.node_capturing_input?.onMouseUp?.(
e,
[
x - this.node_capturing_input.pos[0],
y - this.node_capturing_input.pos[1]
],
this
)
}
} else if (e.button === 1) {
// middle button
@@ -4599,9 +4546,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/**
* converts a coordinate from graph coordinates to canvas2D coordinates
*/
convertOffsetToCanvas(pos: Point, out: Point): Point {
// @ts-expect-error Unused param
return this.ds.convertOffsetToCanvas(pos, out)
convertOffsetToCanvas(pos: Point, _out?: Point): Point {
return this.ds.convertOffsetToCanvas(pos)
}
/**
@@ -6144,11 +6090,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/**
* draws every group area in the background
*/
drawGroups(
// @ts-expect-error - unused parameter
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
): void {
drawGroups(_canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
if (!this.graph) return
const groups = this.graph._groups
@@ -6242,8 +6184,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner_clicked(
this: LGraphCanvas,
v: string,
// @ts-expect-error - unused parameter
options: unknown,
_options: unknown,
e: MouseEvent
) {
if (!graph) throw new NullGraphError()
@@ -6762,13 +6703,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.ds.scale > 1) dialog.style.transform = `scale(${this.ds.scale})`
let dialogCloseTimer: number
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
let prevent_timeout = 0
LiteGraph.pointerListenerAdd(dialog, 'leave', function () {
if (prevent_timeout) return
if (LiteGraph.dialog_close_on_mouse_leave) {
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
// @ts-expect-error - setTimeout type
dialogCloseTimer = setTimeout(
dialog.close,
LiteGraph.dialog_close_on_mouse_leave_delay
@@ -6957,7 +6897,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (options.hide_on_mouse_leave) {
// FIXME: Remove "any" kludge
let prevent_timeout: any = false
let timeout_close: number | null = null
let timeout_close: ReturnType<typeof setTimeout> | null = null
LiteGraph.pointerListenerAdd(dialog, 'enter', function () {
if (timeout_close) {
clearTimeout(timeout_close)
@@ -6969,7 +6909,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const hideDelay = options.hide_on_mouse_leave
const delay = typeof hideDelay === 'number' ? hideDelay : 500
// @ts-expect-error - setTimeout type
timeout_close = setTimeout(dialog.close, delay)
})
// if filtering, check focus changed to comboboxes and prevent closing
@@ -7005,7 +6944,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
that.search_box = dialog
let first: string | null = null
let timeout: number | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
let selected: ChildNode | null = null
const maybeInput = dialog.querySelector('input')
@@ -7039,7 +6978,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (timeout) {
clearInterval(timeout)
}
// @ts-expect-error - setTimeout type
timeout = setTimeout(refreshHelper, 10)
return
}
@@ -7314,9 +7252,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
options.show_general_after_typefiltered &&
(sIn.value || sOut.value)
) {
// FIXME: Undeclared variable again
// @ts-expect-error Variable declared without type annotation
filtered_extra = []
const filtered_extra: string[] = []
for (const i in LiteGraph.registered_node_types) {
if (
inner_test_filter(i, {
@@ -7324,11 +7260,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
outTypeOverride: sOut && sOut.value ? '*' : false
})
) {
// @ts-expect-error Variable declared without type annotation
filtered_extra.push(i)
}
}
// @ts-expect-error Variable declared without type annotation
for (const extraItem of filtered_extra) {
addResult(extraItem, 'generic_type')
if (
@@ -7345,14 +7279,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
helper.childNodes.length == 0 &&
options.show_general_if_none_on_typefilter
) {
// @ts-expect-error Variable declared without type annotation
filtered_extra = []
const filtered_extra: string[] = []
for (const i in LiteGraph.registered_node_types) {
if (inner_test_filter(i, { skipFilter: true }))
// @ts-expect-error Variable declared without type annotation
filtered_extra.push(i)
}
// @ts-expect-error Variable declared without type annotation
for (const extraItem of filtered_extra) {
addResult(extraItem, 'not_in_filter')
if (
@@ -7647,13 +7578,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
let dialogCloseTimer: number
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
let prevent_timeout = 0
dialog.addEventListener('mouseleave', function () {
if (prevent_timeout) return
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
// @ts-expect-error - setTimeout type
dialogCloseTimer = setTimeout(
dialog.close,
LiteGraph.dialog_close_on_mouse_leave_delay
@@ -7687,7 +7617,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
createPanel(title: string, options: ICreatePanelOptions) {
options = options || {}
const ref_window = options.window || window
// TODO: any kludge
const root: any = document.createElement('div')
root.className = 'litegraph dialog'
@@ -7865,16 +7794,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
innerChange(propname, v)
return false
}
new LiteGraph.ContextMenu(
values,
{
event,
className: 'dark',
callback: inner_clicked
},
// @ts-expect-error ref_window parameter unused in ContextMenu constructor
ref_window
)
new LiteGraph.ContextMenu(values, {
event,
className: 'dark',
// @ts-expect-error fixme ts strict error - callback signature mismatch
callback: inner_clicked
})
})
}
@@ -8194,14 +8119,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
{
content: 'Properties Panel',
callback: function (
// @ts-expect-error - unused parameter
item: any,
// @ts-expect-error - unused parameter
options: any,
// @ts-expect-error - unused parameter
e: any,
// @ts-expect-error - unused parameter
menu: any,
_item: any,
_options: any,
_e: any,
_menu: any,
node: LGraphNode
) {
LGraphCanvas.active_canvas.showShowNodePanel(node)
@@ -8312,9 +8233,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node: LGraphNode | undefined,
event: CanvasPointerEvent
): void {
const canvas = LGraphCanvas.active_canvas
const ref_window = canvas.getCanvasWindow()
// TODO: Remove type kludge
let menu_info: (IContextMenuValue | string | null)[]
const options: IContextMenuOptions = {
@@ -8428,8 +8346,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// show menu
if (!menu_info) return
// @ts-expect-error Remove param ref_window - unused
new LiteGraph.ContextMenu(menu_info, options, ref_window)
new LiteGraph.ContextMenu(menu_info, options)
const createDialog = (options: IDialogOptions) =>
this.createDialog(

View File

@@ -38,7 +38,7 @@ describe('LGraphNode', () => {
beforeEach(() => {
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error TODO: Fix after merge - Classes property not in type
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
delete origLiteGraph.Classes
Object.assign(LiteGraph, {

View File

@@ -35,11 +35,10 @@ describe('LGraphNode Title Buttons', () => {
expect(node.title_buttons[2]).toBe(button3)
})
it('should create buttons with default options', () => {
it('should create buttons with minimal options', () => {
const node = new LGraphNode('Test Node')
// @ts-expect-error TODO: Fix after merge - addTitleButton type issues
const button = node.addTitleButton({})
const button = node.addTitleButton({ text: '' })
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
@@ -55,9 +54,7 @@ describe('LGraphNode Title Buttons', () => {
const button = node.addTitleButton({
name: 'close_button',
text: 'X',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
text: 'X'
})
// Mock button methods
@@ -112,9 +109,7 @@ describe('LGraphNode Title Buttons', () => {
const button = node.addTitleButton({
name: 'test_button',
text: 'T',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
text: 'T'
})
button.getWidth = vi.fn().mockReturnValue(20)
@@ -164,16 +159,12 @@ describe('LGraphNode Title Buttons', () => {
const button1 = node.addTitleButton({
name: 'button1',
text: 'A',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
text: 'A'
})
const button2 = node.addTitleButton({
name: 'button2',
text: 'B',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
text: 'B'
})
// Mock button methods
@@ -297,8 +288,7 @@ describe('LGraphNode Title Buttons', () => {
describe('onTitleButtonClick', () => {
it('should dispatch litegraph:node-title-button-clicked event', () => {
const node = new LGraphNode('Test Node')
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({ name: 'test_button' })
const button = new LGraphButton({ name: 'test_button', text: 'X' })
const canvas = {
dispatch: vi.fn()

View File

@@ -679,7 +679,12 @@ export class LGraphNode
this: LGraphNode,
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
): (IContextMenuValue<INodeSlotContextItem> | null)[]
onMouseUp?(this: LGraphNode, e: CanvasPointerEvent, pos: Point): void
onMouseUp?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): void
onMouseEnter?(this: LGraphNode, e: CanvasPointerEvent): void
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
onMouseDown?(
@@ -2769,8 +2774,7 @@ export class LGraphNode
!LiteGraph.allow_multi_output_for_events
) {
graph.beforeChange()
// @ts-expect-error Unused param
this.disconnectOutput(slot, false, { doProcessChange: false })
this.disconnectOutput(slot)
}
}

View File

@@ -1,5 +1,6 @@
import type {
ISerialisedGraph,
ISerialisedNode,
SerialisableGraph
} from '@/lib/litegraph/src/litegraph'
@@ -19,12 +20,7 @@ export const oldSchemaGraph: ISerialisedGraph = {
title: 'A group to test with'
}
],
nodes: [
// @ts-expect-error TODO: Fix after merge - missing required properties for test
{
id: 1
}
],
nodes: [{ id: 1 } as Partial<ISerialisedNode> as ISerialisedNode],
links: []
}
@@ -65,11 +61,7 @@ export const basicSerialisableGraph: SerialisableGraph = {
}
],
nodes: [
// @ts-expect-error TODO: Fix after merge - missing required properties for test
{
id: 1,
type: 'mustBeSet'
}
{ id: 1, type: 'mustBeSet' } as Partial<ISerialisedNode> as ISerialisedNode
],
links: []
}

View File

@@ -338,6 +338,7 @@ export interface INodeFlags {
*/
export interface IWidgetLocator {
name: string
type?: string
}
export interface INodeInputSlot extends INodeSlot {

View File

@@ -8,16 +8,19 @@ import {
inputAsSerialisable,
outputAsSerialisable
} from '@/lib/litegraph/src/litegraph'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
describe('NodeSlot', () => {
describe('inputAsSerialisable', () => {
it('removes _data from serialized slot', () => {
// @ts-expect-error Missing boundingRect property for test
const slot: INodeOutputSlot = {
_data: 'test data',
name: 'test-id',
type: 'STRING',
links: []
links: [],
boundingRect
}
// @ts-expect-error Argument type mismatch for test
const serialized = outputAsSerialisable(slot)
@@ -25,20 +28,14 @@ describe('NodeSlot', () => {
})
it('removes pos from widget input slots', () => {
// Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
pos: [10, 20],
type: 'STRING',
link: null,
widget: {
name: 'test-widget',
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
type: 'combo',
value: 'test-value-1',
options: {
values: ['test-value-1', 'test-value-2']
}
}
widget: { name: 'test-widget', type: 'combo' },
boundingRect
}
const serialized = inputAsSerialisable(widgetInputSlot)
@@ -46,30 +43,27 @@ describe('NodeSlot', () => {
})
it('preserves pos for non-widget input slots', () => {
// @ts-expect-error TODO: Fix after merge - missing boundingRect property for test
const normalSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
pos: [10, 20],
link: null
link: null,
boundingRect
}
const serialized = inputAsSerialisable(normalSlot)
expect(serialized).toHaveProperty('pos')
})
it('preserves only widget name during serialization', () => {
// Extra widget properties simulate real data that should be stripped during serialization
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
link: null,
boundingRect,
widget: {
name: 'test-widget',
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
type: 'combo',
value: 'test-value-1',
options: {
values: ['test-value-1', 'test-value-2']
}
type: 'combo'
}
}

View File

@@ -86,10 +86,8 @@ describe.skip('ExecutableNodeDTO Creation', () => {
expect(dto.applyToGraph).toBeDefined()
// Test that wrapper calls original method
const args = ['arg1', 'arg2']
// @ts-expect-error TODO: Fix after merge - applyToGraph expects different arguments
dto.applyToGraph!(args[0], args[1])
;(dto.applyToGraph as (...args: unknown[]) => void)(args[0], args[1])
expect(mockApplyToGraph).toHaveBeenCalledWith(args[0], args[1])
})

View File

@@ -185,14 +185,10 @@ describe.skip('Subgraph Serialization', () => {
expect(serialized.inputs).toHaveLength(1)
expect(serialized.outputs).toHaveLength(1)
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
expect(serialized.inputs[0].name).toBe('input')
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
expect(serialized.inputs[0].type).toBe('number')
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
expect(serialized.outputs[0].name).toBe('output')
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
expect(serialized.outputs[0].type).toBe('number')
expect(serialized.inputs![0].name).toBe('input')
expect(serialized.inputs![0].type).toBe('number')
expect(serialized.outputs![0].name).toBe('output')
expect(serialized.outputs![0].type).toBe('number')
}
)

View File

@@ -350,8 +350,7 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate concurrent operations
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
const operations = []
const operations: Array<() => void> = []
for (let i = 0; i < 20; i++) {
operations.push(
() => {
@@ -371,7 +370,6 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
// Execute all operations - should not crash
expect(() => {
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
for (const op of operations) op()
}).not.toThrow()
})

View File

@@ -22,7 +22,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
})
})
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
expect(addedEvents[0].detail.input).toBe(input)
}
)
@@ -44,7 +43,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
})
})
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
expect(addedEvents[0].detail.output).toBe(output)
}
)
@@ -71,7 +69,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
index: 0
})
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
expect(removingEvents[0].detail.input).toBe(input)
}
)
@@ -98,7 +95,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
index: 0
})
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
expect(removingEvents[0].detail.output).toBe(output)
}
)
@@ -126,7 +122,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
newName: 'new_name'
})
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
expect(renamingEvents[0].detail.input).toBe(input)
// Verify the label was updated after the event (renameInput sets label, not name)
@@ -160,7 +155,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
newName: 'new_name'
})
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
expect(renamingEvents[0].detail.output).toBe(output)
// Verify the label was updated after the event

View File

@@ -28,8 +28,7 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
}).not.toThrow()
expect(
// @ts-expect-error TODO: Fix after merge - link can be null
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link)
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link!)
).toBe(true)
expect(subgraphNode.inputs[0].link).not.toBe(null)
}
@@ -47,10 +46,8 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
expect(simpleSubgraph.inputs.length).toBe(initialInputCount + 1)
// The empty slot should be configurable
const emptyInput = simpleSubgraph.inputs.at(-1)
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
const emptyInput = simpleSubgraph.inputs.at(-1)!
expect(emptyInput.name).toBe('')
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
expect(emptyInput.type).toBe('*')
}
)
@@ -149,8 +146,7 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
}).not.toThrow()
expect(
// @ts-expect-error TODO: Fix after merge - link can be null
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link)
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link!)
).toBe(true)
expect(externalNode.inputs[0].link).not.toBe(null)
}
@@ -168,10 +164,8 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
expect(simpleSubgraph.outputs.length).toBe(initialOutputCount + 1)
// The empty slot should be configurable
const emptyOutput = simpleSubgraph.outputs.at(-1)
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
const emptyOutput = simpleSubgraph.outputs.at(-1)!
expect(emptyOutput.name).toBe('')
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
expect(emptyOutput.type).toBe('*')
}
)
@@ -454,15 +448,11 @@ describe('SubgraphIO - Empty Slot Connection', () => {
// 3. A link should be established inside the subgraph
expect(internalNode.inputs[0].link).not.toBe(null)
const link = subgraph.links.get(internalNode.inputs[0].link!)
const link = subgraph.links.get(internalNode.inputs[0].link!)!
expect(link).toBeDefined()
// @ts-expect-error TODO: Fix after merge - link possibly undefined
expect(link.target_id).toBe(internalNode.id)
// @ts-expect-error TODO: Fix after merge - link possibly undefined
expect(link.target_slot).toBe(0)
// @ts-expect-error TODO: Fix after merge - link possibly undefined
expect(link.origin_id).toBe(subgraph.inputNode.id)
// @ts-expect-error TODO: Fix after merge - link possibly undefined
expect(link.origin_slot).toBe(1) // Should be the second slot
}
)

View File

@@ -2,6 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -72,10 +73,10 @@ describe.skip('SubgraphNode Memory Management', () => {
size: [200, 100],
inputs: [],
outputs: [],
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
properties: {},
flags: {},
mode: 0
mode: 0,
order: 0
})
}
@@ -93,12 +94,13 @@ describe.skip('SubgraphNode Memory Management', () => {
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate widget promotion scenario
const input = subgraphNode.inputs[0]
const mockWidget = {
type: 'number',
name: 'promoted_widget',
value: 123,
options: {},
y: 0,
draw: vi.fn(),
mouse: vi.fn(),
computeSize: vi.fn(),
@@ -107,21 +109,16 @@ describe.skip('SubgraphNode Memory Management', () => {
name: 'promoted_widget',
value: 123
})
}
} as Partial<IWidget> as IWidget
// Simulate widget promotion
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
input._widget = mockWidget
input.widget = { name: 'promoted_widget' }
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
subgraphNode.widgets.push(mockWidget)
expect(input._widget).toBe(mockWidget)
expect(input.widget).toBeDefined()
expect(subgraphNode.widgets).toContain(mockWidget)
// Remove widget (this should clean up references)
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
subgraphNode.removeWidget(mockWidget)
// Widget should be removed from array
@@ -146,10 +143,10 @@ describe.skip('SubgraphNode Memory Management', () => {
size: [200, 100],
inputs: [],
outputs: [],
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
properties: {},
flags: {},
mode: 0
mode: 0,
order: 0
})
}
@@ -328,17 +325,26 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => {
const initialWidgetCount = subgraphNode.widgets?.length || 0
// Add mock widgets
const widget1 = { type: 'number', value: 1, name: 'widget1' }
const widget2 = { type: 'string', value: 'test', name: 'widget2' }
const widget1 = {
type: 'number',
value: 1,
name: 'widget1',
options: {},
y: 0
} as Partial<IWidget> as IWidget
const widget2 = {
type: 'string',
value: 'test',
name: 'widget2',
options: {},
y: 0
} as Partial<IWidget> as IWidget
if (subgraphNode.widgets) {
// @ts-expect-error TODO: Fix after merge - widget type mismatch
subgraphNode.widgets.push(widget1, widget2)
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
}
// Remove widgets
if (subgraphNode.widgets) {
subgraphNode.widgets.length = initialWidgetCount
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)

View File

@@ -7,6 +7,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
@@ -210,10 +211,10 @@ describe.skip('SubgraphNode Lifecycle', () => {
size: [180, 100],
inputs: [],
outputs: [],
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
properties: {},
flags: {},
mode: 0
mode: 0,
order: 0
})
// Should reflect updated subgraph structure
@@ -542,8 +543,6 @@ describe.skip('SubgraphNode Cleanup', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
// Add and remove nodes multiple times
// @ts-expect-error TODO: Fix after merge - SubgraphNode should be Subgraph
const removedNodes: SubgraphNode[] = []
for (let i = 0; i < 3; i++) {
const node = createTestSubgraphNode(subgraph)

View File

@@ -28,6 +28,7 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
@@ -333,6 +334,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this
)
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type
Object.assign(promotedWidget, {
get name() {

View File

@@ -134,9 +134,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
// Check event was fired
const promotedEvents = eventCapture.getEventsByType('widget-promoted')
expect(promotedEvents).toHaveLength(1)
// @ts-expect-error Object is of type 'unknown'
expect(promotedEvents[0].detail.widget).toBeDefined()
// @ts-expect-error Object is of type 'unknown'
expect(promotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
eventCapture.cleanup()
@@ -161,9 +159,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
// Check event was fired
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
expect(demotedEvents).toHaveLength(1)
// @ts-expect-error Object is of type 'unknown'
expect(demotedEvents[0].detail.widget).toBeDefined()
// @ts-expect-error Object is of type 'unknown'
expect(demotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
// Widget should be removed

View File

@@ -8,6 +8,7 @@
*/
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { test } from '../../__fixtures__/testExtensions'
@@ -17,6 +18,7 @@ import {
createTestSubgraph,
createTestSubgraphNode
} from './subgraphHelpers'
import type { EventCapture } from './subgraphHelpers'
interface SubgraphFixtures {
/** A minimal subgraph with no inputs, outputs, or nodes */
@@ -41,7 +43,7 @@ interface SubgraphFixtures {
/** Event capture system for testing subgraph events */
eventCapture: {
subgraph: Subgraph
capture: ReturnType<typeof createEventCapture>
capture: EventCapture<SubgraphEventMap>
}
}
@@ -59,9 +61,7 @@ interface SubgraphFixtures {
* ```
*/
export const subgraphTest = test.extend<SubgraphFixtures>({
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
emptySubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
emptySubgraph: async ({}, use) => {
const subgraph = createTestSubgraph({
name: 'Empty Test Subgraph',
inputCount: 0,
@@ -72,9 +72,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
await use(subgraph)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
simpleSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
simpleSubgraph: async ({}, use) => {
const subgraph = createTestSubgraph({
name: 'Simple Test Subgraph',
inputs: [{ name: 'input', type: 'number' }],
@@ -85,9 +83,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
await use(subgraph)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
complexSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
complexSubgraph: async ({}, use) => {
const subgraph = createTestSubgraph({
name: 'Complex Test Subgraph',
inputs: [
@@ -105,9 +101,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
await use(subgraph)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
nestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
nestedSubgraph: async ({}, use) => {
const nested = createNestedSubgraphs({
depth: 3,
nodesPerLevel: 2,
@@ -118,10 +112,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
await use(nested)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
subgraphWithNode: async ({}, use: (value: unknown) => Promise<void>) => {
// Create the subgraph definition
subgraphWithNode: async ({}, use) => {
const subgraph = createTestSubgraph({
name: 'Subgraph With Node',
inputs: [{ name: 'input', type: '*' }],
@@ -129,14 +120,12 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
nodeCount: 1
})
// Create the parent graph and subgraph node instance
const parentGraph = new LGraph()
const subgraphNode = createTestSubgraphNode(subgraph, {
pos: [200, 200],
size: [180, 80]
})
// Add the subgraph node to the parent graph
parentGraph.add(subgraphNode)
await use({
@@ -146,15 +135,12 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
})
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
eventCapture: async ({}, use: (value: unknown) => Promise<void>) => {
eventCapture: async ({}, use) => {
const subgraph = createTestSubgraph({
name: 'Event Test Subgraph'
})
// Set up event capture for all subgraph events
const capture = createEventCapture(subgraph.events, [
const capture = createEventCapture<SubgraphEventMap>(subgraph.events, [
'adding-input',
'input-added',
'removing-input',
@@ -167,7 +153,6 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
await use({ subgraph, capture })
// Cleanup event listeners
capture.cleanup()
}
})

View File

@@ -55,6 +55,16 @@ interface CapturedEvent<T = unknown> {
timestamp: number
}
/** Return type for createEventCapture with typed getEventsByType */
export interface EventCapture<TEventMap extends object> {
events: CapturedEvent<TEventMap[keyof TEventMap]>[]
clear: () => void
cleanup: () => void
getEventsByType: <K extends keyof TEventMap & string>(
type: K
) => CapturedEvent<TEventMap[K]>[]
}
/**
* Creates a test subgraph with specified inputs, outputs, and nodes.
* This is the primary function for creating subgraphs in tests.
@@ -91,34 +101,35 @@ export function createTestSubgraph(
}
const rootGraph = new LGraph()
// Create the base subgraph data
const subgraphData: ExportedSubgraph = {
// Basic graph properties
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [],
// @ts-expect-error TODO: Fix after merge - links type mismatch
links: {},
links: [],
groups: [],
config: {},
definitions: { subgraphs: [] },
// Subgraph-specific properties
id: options.id || createUuidv4(),
name: options.name || 'Test Subgraph',
// IO Nodes (required for subgraph functionality)
inputNode: {
id: -10, // SUBGRAPH_INPUT_ID
bounding: [10, 100, 150, 126], // [x, y, width, height]
id: -10,
bounding: [10, 100, 150, 126],
pinned: false
},
outputNode: {
id: -20, // SUBGRAPH_OUTPUT_ID
bounding: [400, 100, 140, 126], // [x, y, width, height]
id: -20,
bounding: [400, 100, 140, 126],
pinned: false
},
// IO definitions - will be populated by addInput/addOutput calls
inputs: [],
outputs: [],
widgets: []
@@ -127,11 +138,9 @@ export function createTestSubgraph(
// Create the subgraph
const subgraph = new Subgraph(rootGraph, subgraphData)
// Add requested inputs
if (options.inputs) {
for (const input of options.inputs) {
// @ts-expect-error TODO: Fix after merge - addInput parameter types
subgraph.addInput(input.name, input.type)
subgraph.addInput(input.name, String(input.type))
}
} else if (options.inputCount) {
for (let i = 0; i < options.inputCount; i++) {
@@ -139,11 +148,9 @@ export function createTestSubgraph(
}
}
// Add requested outputs
if (options.outputs) {
for (const output of options.outputs) {
// @ts-expect-error TODO: Fix after merge - addOutput parameter types
subgraph.addOutput(output.name, output.type)
subgraph.addOutput(output.name, String(output.type))
}
} else if (options.outputCount) {
for (let i = 0; i < options.outputCount; i++) {
@@ -193,10 +200,10 @@ export function createTestSubgraphNode(
size: options.size || [200, 100],
inputs: [],
outputs: [],
// @ts-expect-error TODO: Fix after merge - properties type mismatch
properties: {},
flags: {},
mode: 0
mode: 0,
order: 0
}
return new SubgraphNode(parentGraph, subgraph, instanceData)
@@ -237,18 +244,11 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
subgraphs.push(subgraph)
// Create instance in parent
const subgraphNode = createTestSubgraphNode(subgraph, {
pos: [100 + level * 200, 100]
})
if (currentParent instanceof LGraph) {
currentParent.add(subgraphNode)
} else {
// @ts-expect-error TODO: Fix after merge - add method parameter types
currentParent.add(subgraphNode)
}
currentParent.add(subgraphNode)
subgraphNodes.push(subgraphNode)
// Next level will be nested inside this subgraph
@@ -353,9 +353,15 @@ export function createTestSubgraphData(
): ExportedSubgraph {
return {
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [],
// @ts-expect-error TODO: Fix after merge - links type mismatch
links: {},
links: [],
groups: [],
config: {},
definitions: { subgraphs: [] },
@@ -386,13 +392,13 @@ export function createTestSubgraphData(
* Creates an event capture system for testing event sequences.
* @param eventTarget The event target to monitor
* @param eventTypes Array of event types to capture
* @returns Object with captured events and helper methods
* @returns Object with captured events and typed getEventsByType method
*/
export function createEventCapture<T = unknown>(
export function createEventCapture<TEventMap extends object = object>(
eventTarget: EventTarget,
eventTypes: string[]
) {
const capturedEvents: CapturedEvent<T>[] = []
eventTypes: Array<keyof TEventMap & string>
): EventCapture<TEventMap> {
const capturedEvents: CapturedEvent<TEventMap[keyof TEventMap]>[] = []
const listeners: Array<() => void> = []
// Set up listeners for each event type
@@ -400,7 +406,7 @@ export function createEventCapture<T = unknown>(
const listener = (event: Event) => {
capturedEvents.push({
type: eventType,
detail: (event as CustomEvent<T>).detail,
detail: (event as CustomEvent<TEventMap[typeof eventType]>).detail,
timestamp: Date.now()
})
}
@@ -418,7 +424,9 @@ export function createEventCapture<T = unknown>(
// Remove all event listeners to prevent memory leaks
for (const cleanup of listeners) cleanup()
},
getEventsByType: (type: string) =>
capturedEvents.filter((e) => e.type === type)
getEventsByType: <K extends keyof TEventMap & string>(type: K) =>
capturedEvents.filter((e) => e.type === type) as CapturedEvent<
TEventMap[K]
>[]
}
}

View File

@@ -111,6 +111,8 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps {
* @see {@link ExportedSubgraph.subgraphs}
*/
type: UUID
/** Custom properties for this subgraph instance */
properties?: Dictionary<NodeProperty | undefined>
}
/**

View File

@@ -27,6 +27,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
socketless?: boolean
/** If `true`, the widget will not be rendered by the Vue renderer. */
canvasOnly?: boolean
/** Used as a temporary override for determining the asset type in vue mode*/
nodeType?: string
values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */

View File

@@ -463,7 +463,8 @@ describe('ComboWidget', () => {
.mockImplementation(function (_values, options) {
capturedCallback = options.callback
})
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
@@ -505,7 +506,8 @@ describe('ComboWidget', () => {
.mockImplementation(function (_values, options) {
capturedCallback = options.callback
})
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
@@ -766,8 +768,8 @@ describe('ComboWidget', () => {
.mockImplementation(function () {
this.addItem = mockAddItem
})
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
// Should show formatted labels in dropdown
@@ -829,7 +831,8 @@ describe('ComboWidget', () => {
capturedCallback = options.callback
this.addItem = mockAddItem
})
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
@@ -882,7 +885,8 @@ describe('ComboWidget', () => {
capturedCallback = options.callback
this.addItem = mockAddItem
})
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
@@ -960,7 +964,8 @@ describe('ComboWidget', () => {
.mockImplementation(function () {
this.addItem = mockAddItem
})
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
@@ -1007,7 +1012,8 @@ describe('ComboWidget', () => {
node.size = [200, 30]
const mockContextMenu = vi.fn<typeof LiteGraph.ContextMenu>()
LiteGraph.ContextMenu = mockContextMenu
LiteGraph.ContextMenu =
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })

View File

@@ -333,4 +333,4 @@
"label": "Toggle Workflows Sidebar",
"tooltip": "Workflows"
}
}
}

View File

@@ -378,6 +378,10 @@
"warningTooltip": "This package may have compatibility issues with your current environment"
}
},
"importFailed": {
"title": "Import Failed",
"copyError": "Copy Error"
},
"issueReport": {
"helpFix": "Help Fix This"
},
@@ -738,6 +742,7 @@
"filterCurrentWorkflow": "Current workflow",
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsSuffix": "active jobs",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
@@ -1167,6 +1172,7 @@
"Canvas Performance": "Canvas Performance",
"Help Center": "Help Center",
"toggle linear mode": "toggle linear mode",
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
"Undo": "Undo",
"Open Sign In Dialog": "Open Sign In Dialog",
@@ -1641,6 +1647,7 @@
"loadingModel": "Loading 3D Model...",
"upDirection": "Up Direction",
"materialMode": "Material Mode",
"showSkeleton": "Show Skeleton",
"scene": "Scene",
"model": "Model",
"camera": "Camera",
@@ -1911,7 +1918,7 @@
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
"videosEstimate": "~{count} videos",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseError": "Purchase Failed",
@@ -2014,10 +2021,11 @@
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimateLabel": "Number of 5s videos generated with Wan Fun Control template",
"videoEstimateHelp": "What is this?",
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
"videoEstimateTryTemplate": "Try the Wan Fun Control template",
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan 2.2 Image-to-Video template",
"videoEstimateHelp": "More details on this template",
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 seconds, 640x640, 16fps, 4-step sampling).",
"videoEstimateTryTemplate": "Try this template",
"videoTemplateBasedCredits": "Videos generated with Wan 2.2 Image to Video",
"upgradePlan": "Upgrade Plan",
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
@@ -2369,6 +2377,8 @@
"actions": {
"inspect": "Inspect asset",
"more": "More options",
"zoom": "Zoom in",
"moreOptions": "More options",
"seeMoreOutputs": "See more outputs",
"addToWorkflow": "Add to current workflow",
"download": "Download",
@@ -2471,4 +2481,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -4,20 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const mockAssetService = vi.hoisted(() => ({
getAssetsForNodeType: vi.fn(),
getAssetsByTag: vi.fn(),
getAssetDetails: vi.fn((id: string) =>
Promise.resolve({
id,
name: 'Test Model',
user_metadata: {
filename: 'Test Model'
}
})
)
}))
import { useAssetsStore } from '@/stores/assetsStore'
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) =>
@@ -25,9 +12,15 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toLocaleDateString()
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: mockAssetService
}))
vi.mock('@/stores/assetsStore', () => {
const store = {
modelAssetsByNodeType: new Map<string, AssetItem[]>(),
modelLoadingByNodeType: new Map<string, boolean>(),
updateModelsForNodeType: vi.fn(),
updateModelsForTag: vi.fn()
}
return { useAssetsStore: () => store }
})
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
@@ -190,9 +183,12 @@ describe('AssetBrowserModal', () => {
})
}
const mockStore = useAssetsStore()
beforeEach(() => {
mockAssetService.getAssetsForNodeType.mockReset()
mockAssetService.getAssetsByTag.mockReset()
vi.resetAllMocks()
mockStore.modelAssetsByNodeType.clear()
mockStore.modelLoadingByNodeType.clear()
})
describe('Integration with useAssetBrowser', () => {
@@ -201,7 +197,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -218,7 +214,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
createTestAsset('l1', 'lora.pt', 'loras')
]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -234,31 +230,54 @@ describe('AssetBrowserModal', () => {
})
describe('Data fetching', () => {
it('fetches assets for node type', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
it('triggers store refresh for node type on mount', async () => {
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith(
expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
})
it('fetches assets for tag when node type not provided', async () => {
mockAssetService.getAssetsByTag.mockResolvedValueOnce([])
it('displays cached assets immediately from store', async () => {
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
createWrapper({ assetType: 'loras' })
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const gridAssets = assetGrid.props('assets') as AssetItem[]
expect(gridAssets).toHaveLength(1)
expect(gridAssets[0].name).toBe('Cached Model')
})
it('triggers store refresh for asset type (tag) on mount', async () => {
createWrapper({ assetType: 'models' })
await flushPromises()
expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras')
expect(mockStore.updateModelsForTag).toHaveBeenCalledWith('models')
})
it('uses tag: prefix for cache key when assetType is provided', async () => {
const assets = [createTestAsset('asset1', 'Tagged Model', 'models')]
mockStore.modelAssetsByNodeType.set('tag:models', assets)
const wrapper = createWrapper({ assetType: 'models' })
await flushPromises()
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const gridAssets = assetGrid.props('assets') as AssetItem[]
expect(gridAssets).toHaveLength(1)
expect(gridAssets[0].name).toBe('Tagged Model')
})
})
describe('Asset Selection', () => {
it('emits asset-select event when asset is selected', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -271,7 +290,7 @@ describe('AssetBrowserModal', () => {
it('executes onSelect callback when provided', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const onSelect = vi.fn()
const wrapper = createWrapper({
@@ -289,8 +308,6 @@ describe('AssetBrowserModal', () => {
describe('Left Panel Conditional Logic', () => {
it('hides left panel by default when showLeftPanel is undefined', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -299,8 +316,6 @@ describe('AssetBrowserModal', () => {
})
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
showLeftPanel: true
@@ -318,7 +333,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -339,8 +354,6 @@ describe('AssetBrowserModal', () => {
describe('Title Management', () => {
it('passes custom title to BaseModalLayout when title prop provided', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
title: 'Custom Title'
@@ -353,7 +366,7 @@ describe('AssetBrowserModal', () => {
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()

View File

@@ -63,12 +63,8 @@
</template>
<script setup lang="ts">
import {
breakpointsTailwind,
useAsyncState,
useBreakpoints
} from '@vueuse/core'
import { computed, provide, watch } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -81,68 +77,68 @@ import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBro
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
const { t } = useI18n()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
const props = defineProps<{
nodeType?: string
assetType?: string
onSelect?: (asset: AssetItem) => void
onClose?: () => void
showLeftPanel?: boolean
title?: string
assetType?: string
}>()
const { t } = useI18n()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
close: []
}>()
const breakpoints = useBreakpoints(breakpointsTailwind)
provide(OnCloseKey, props.onClose ?? (() => {}))
const fetchAssets = async () => {
// Compute the cache key based on nodeType or assetType
const cacheKey = computed(() => {
if (props.nodeType) return props.nodeType
if (props.assetType) return `tag:${props.assetType}`
return ''
})
// Read directly from store cache - reactive to any store updates
const fetchedAssets = computed(
() => assetStore.modelAssetsByNodeType.get(cacheKey.value) ?? []
)
const isStoreLoading = computed(
() => assetStore.modelLoadingByNodeType.get(cacheKey.value) ?? false
)
// Only show loading spinner when loading AND no cached data
const isLoading = computed(
() => isStoreLoading.value && fetchedAssets.value.length === 0
)
async function refreshAssets(): Promise<AssetItem[]> {
if (props.nodeType) {
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
return await assetStore.updateModelsForNodeType(props.nodeType)
}
if (props.assetType) {
return (await assetService.getAssetsByTag(props.assetType)) ?? []
return await assetStore.updateModelsForTag(props.assetType)
}
return []
}
const {
state: fetchedAssets,
isLoading,
execute
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
// Trigger background refresh on mount
void refreshAssets()
watch(
() => [props.nodeType, props.assetType],
async () => {
await execute()
},
{ immediate: true }
)
const assetDownloadStore = useAssetDownloadStore()
watch(
() => assetDownloadStore.hasActiveDownloads,
async (currentlyActive, previouslyActive) => {
if (previouslyActive && !currentlyActive) {
await execute()
}
}
)
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(refreshAssets)
const {
searchQuery,
@@ -153,8 +149,6 @@ const {
updateFilters
} = useAssetBrowser(fetchedAssets)
const modelToNodeStore = useModelToNodeStore()
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
@@ -202,6 +196,4 @@ function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
// It handles the appropriate transformation (filename extraction or full asset)
props.onSelect?.(asset)
}
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload(execute)
</script>

View File

@@ -33,7 +33,7 @@
<AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="flags.assetUpdateOptionsEnabled && !(asset.is_immutable ?? true)"
v-if="showAssetOptions"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
@@ -44,6 +44,7 @@
<MoreButton ref="dropdown-menu-button" size="sm">
<template #default>
<Button
v-if="flags.assetRenameEnabled"
variant="secondary"
size="md"
class="justify-start"
@@ -53,6 +54,7 @@
<span>{{ $t('g.rename') }}</span>
</Button>
<Button
v-if="flags.assetDeletionEnabled"
variant="secondary"
size="md"
class="justify-start"
@@ -160,6 +162,12 @@ const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
const showAssetOptions = computed(
() =>
(flags.assetDeletionEnabled || flags.assetRenameEnabled) &&
!(asset.is_immutable ?? true)
)
const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay')
)

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div
v-if="asset.size"
class="flex items-center gap-2 text-xs text-zinc-400"
>
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<CardContainer
<div
ref="cardContainerRef"
role="button"
:aria-label="
@@ -11,105 +11,114 @@
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
rounded="lg"
:class="containerClasses"
:class="
cn(
'flex flex-col overflow-hidden cursor-pointer p-2 transition-colors duration-200 rounded-lg',
'gap-2 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
)
"
:data-selected="selected"
@click.stop="$emit('click')"
@contextmenu.prevent="handleContextMenu"
>
<template #top>
<CardTop
ratio="square"
:bottom-left-class="durationChipClasses"
:bottom-right-class="durationChipClasses"
<!-- Top Area: Media Preview -->
<div class="relative aspect-square overflow-hidden p-0">
<!-- Loading State -->
<div
v-if="loading"
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
/>
<!-- Content based on asset type -->
<component
:is="getTopComponent(fileKind)"
v-else-if="asset && adaptedAsset"
:asset="adaptedAsset"
:context="{ type: assetType }"
class="absolute inset-0"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
<!-- Action buttons overlay (top-left) -->
<div
v-if="showActionsOverlay"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
>
<!-- Loading State -->
<template v-if="loading">
<IconGroup background-class="bg-white">
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@click.stop="handleZoomClick"
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="handleContextMenu"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</div>
</div>
<!-- Bottom Area: Media Info -->
<div class="flex-1">
<!-- Loading State -->
<div v-if="loading" class="flex justify-between items-start">
<div class="flex flex-col gap-1">
<div
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
class="h-4 w-24 animate-pulse rounded bg-modal-card-background"
/>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<component
:is="getTopComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
<div
class="h-3 w-20 animate-pulse rounded bg-modal-card-background"
/>
</template>
</div>
<div class="h-6 w-12 animate-pulse rounded bg-modal-card-background" />
</div>
<!-- Top-left slot: Duration/Format chips OR Media actions -->
<template #top-left>
<!-- Duration/Format chips - show when not hovered and not playing -->
<div v-if="showStaticChips" class="flex flex-wrap items-center gap-1">
<SquareChip
v-if="formattedDuration"
variant="gray"
:label="formattedDuration"
/>
<!-- Content -->
<div
v-else-if="asset && adaptedAsset"
class="flex justify-between items-end gap-1.5"
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
</div>
<!-- Media actions - show on hover or when playing -->
<IconGroup v-else-if="showActionsOverlay">
<Button size="icon" @click.stop="handleZoomClick">
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button size="icon" @click.stop="handleContextMenu">
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</template>
<!-- Output count (top-right) -->
<template v-if="showOutputCount" #top-right>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="flex-shrink-0">
<Button
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
size="sm"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<!-- Loading State -->
<template v-if="loading">
<div class="flex flex-col items-center justify-between gap-1">
<div
class="h-4 w-2/3 animate-pulse rounded bg-modal-card-background"
/>
<div
class="h-3 w-1/2 animate-pulse rounded bg-modal-card-background"
/>
</div>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<component
:is="getBottomComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
/>
</template>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
</div>
<MediaAssetContextMenu
v-if="asset"
@@ -128,12 +137,13 @@ import { useElementHover, whenever } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Button from '@/components/ui/button/Button.vue'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
formatDuration,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
@@ -142,6 +152,7 @@ import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
import MediaTitle from './MediaTitle.vue'
const mediaComponents = {
top: {
@@ -149,12 +160,6 @@ const mediaComponents = {
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
},
bottom: {
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
}
}
@@ -162,10 +167,6 @@ function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
}
function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const {
asset,
loading,
@@ -215,6 +216,11 @@ const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
})
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
const adaptedAsset = computed(() => {
if (!asset) return undefined
@@ -240,15 +246,6 @@ provide(MediaAssetKey, {
showVideoControls
})
const containerClasses = computed(() =>
cn(
'gap-1 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
)
)
const formattedDuration = computed(() => {
// Check for execution time first (from history API)
const executionTime = asset?.user_metadata?.executionTimeInSeconds
@@ -262,30 +259,22 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && imageDimensions.value) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (fileKind.value === 'video' && showVideoControls.value) {
return '-translate-y-16'
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
}
return ''
})
// Show static chips when NOT hovered and NOT playing (normal state)
const showStaticChips = computed(
() =>
!loading &&
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
formattedDuration.value
)
// Show action overlay when hovered OR playing
const showActionsOverlay = computed(
() => !loading && !!asset && (isHovered.value || isVideoPlaying.value)
)
const showActionsOverlay = computed(() => {
if (loading || !asset) return false
return isHovered.value || selected || isVideoPlaying.value
})
const handleZoomClick = () => {
if (asset) {

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div
v-if="asset.size"
class="flex items-center gap-2 text-xs text-zinc-400"
>
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,27 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<div class="flex items-center text-xs text-zinc-400">
<span v-if="asset.dimensions"
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,21 +1,14 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-base-foreground"
:title="fullName"
<p
class="m-0 line-clamp-2 text-sm text-base-foreground leading-tight break-all"
:title="fileName"
>
{{ displayName }}
</h3>
{{ fileName }}
</p>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { truncateFilename } from '@/utils/formatUtil'
const props = defineProps<{
defineProps<{
fileName: string
}>()
const fullName = computed(() => props.fileName)
const displayName = computed(() => truncateFilename(props.fileName))
</script>

View File

@@ -1,26 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div v-if="asset.size" class="flex items-center text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -3,13 +3,11 @@ import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vu
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useDialogStore } from '@/stores/dialogStore'
import type { UseAsyncStateReturn } from '@vueuse/core'
import { computed } from 'vue'
export function useModelUpload(
execute?: UseAsyncStateReturn<AssetItem[], [], true>['execute']
onUploadSuccess?: () => Promise<unknown> | void
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
@@ -37,7 +35,7 @@ export function useModelUpload(
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute?.()
await onUploadSuccess?.()
}
},
dialogComponentProps: {

View File

@@ -127,53 +127,6 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
}
}
// @ts-expect-error - Unused function kept for future use
async function postSurveyStatus(): Promise<void> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
if (!response.ok) {
const error = new Error(
`Failed to post survey status: ${response.statusText}`
)
captureApiError(
error,
'/settings/{key}',
'http_error',
response.status,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
throw error
}
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to post survey status:')) {
captureApiError(
error as Error,
'/settings/{key}',
'network_error',
undefined,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
}
throw error
}
}
export async function submitSurvey(
survey: Record<string, unknown>
): Promise<void> {

View File

@@ -157,7 +157,9 @@
<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-foreground">
<span
class="text-sm font-normal text-foreground leading-relaxed"
>
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 group pt-2">
@@ -220,16 +222,19 @@
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 underline"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
>
{{ t('subscription.videoEstimateTryTemplate') }}
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a>
</div>
</Popover>

View File

@@ -19,9 +19,9 @@ export interface TierPricing {
}
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 164 },
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 288 },
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 821 }
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 120 },
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 211 },
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 600 }
}
interface TierFeatures {

View File

@@ -35,7 +35,8 @@ export type RemoteConfig = {
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
asset_deletion_enabled?: boolean
asset_rename_enabled?: boolean
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean

View File

@@ -99,7 +99,10 @@
</span>
</div>
<!-- Multiple Images Navigation -->
<div v-if="hasMultipleImages" class="flex justify-center gap-1 pt-4">
<div
v-if="hasMultipleImages"
class="flex flex-wrap justify-center gap-1 pt-4"
>
<button
v-for="(_, index) in imageUrls"
:key="index"

View File

@@ -60,8 +60,9 @@ const combinedProps = computed(() => ({
}))
const getAssetData = () => {
if (props.isAssetMode && props.nodeType) {
return useAssetWidgetData(toRef(() => props.nodeType))
const nodeType = props.widget.options?.nodeType ?? props.nodeType
if (props.isAssetMode && nodeType) {
return useAssetWidgetData(toRef(nodeType))
}
return null
}

View File

@@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
@@ -11,9 +11,9 @@ import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
options: Partial<ToggleSwitchProps> = {},
options: IWidgetOptions = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean> => ({
): SimplifiedWidget<boolean, IWidgetOptions> => ({
name: 'test_toggle',
type: 'boolean',
value,
@@ -149,4 +149,47 @@ describe('WidgetToggleSwitch Value Binding', () => {
expect(emitted![3]).toContain(false)
})
})
describe('Label Display (label_on/label_off)', () => {
it('displays label_on when value is true', () => {
const widget = createMockWidget(true, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, true)
expect(wrapper.text()).toContain('inside')
})
it('displays label_off when value is false', () => {
const widget = createMockWidget(false, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('outside')
})
it('does not display label when no on/off options provided', () => {
const widget = createMockWidget(false, {})
const wrapper = mountComponent(widget, false)
expect(wrapper.find('span').exists()).toBe(false)
})
it('updates label when value changes', async () => {
const widget = createMockWidget(false, { on: 'enabled', off: 'disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('disabled')
await wrapper.setProps({ modelValue: true })
expect(wrapper.text()).toContain('enabled')
})
it('falls back to true/false when only partial options provided', () => {
const widgetOnOnly = createMockWidget(true, { on: 'active' })
const wrapperOn = mountComponent(widgetOnOnly, true)
expect(wrapperOn.text()).toContain('active')
const widgetOffOnly = createMockWidget(false, { off: 'inactive' })
const wrapperOff = mountComponent(widgetOffOnly, false)
expect(wrapperOff.text()).toContain('inactive')
})
})
})

View File

@@ -1,11 +1,25 @@
<template>
<WidgetLayoutField :widget>
<ToggleSwitch
v-model="modelValue"
v-bind="filteredProps"
class="ml-auto block"
:aria-label="widget.name"
/>
<div class="ml-auto flex w-fit items-center gap-2">
<span
v-if="stateLabel"
:class="
cn(
'text-sm transition-colors',
modelValue
? 'text-node-component-slot-text'
: 'text-node-component-slot-text/50'
)
"
>
{{ stateLabel }}
</span>
<ToggleSwitch
v-model="modelValue"
v-bind="filteredProps"
:aria-label="widget.name"
/>
</div>
</WidgetLayoutField>
</template>
@@ -13,7 +27,9 @@
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
@@ -22,7 +38,7 @@ import {
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget } = defineProps<{
widget: SimplifiedWidget<boolean>
widget: SimplifiedWidget<boolean, IWidgetOptions>
}>()
const modelValue = defineModel<boolean>()
@@ -30,4 +46,10 @@ const modelValue = defineModel<boolean>()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
const stateLabel = computed(() => {
const options = widget.options
if (!options?.on && !options?.off) return null
return modelValue.value ? (options.on ?? 'true') : (options.off ?? 'false')
})
</script>

View File

@@ -368,7 +368,8 @@ export const useImagePreviewWidget = () => {
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
serialize: false,
canvasOnly: true
})
)
}

View File

@@ -30,6 +30,9 @@ import ManagerProgressFooter from '@/workbench/extensions/manager/components/Man
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
@@ -482,6 +485,43 @@ export const useDialogService = () => {
})
}
function showImportFailedNodeDialog(
options: {
conflictedPackages?: ConflictDetectionResult[]
dialogComponentProps?: DialogComponentProps
} = {}
) {
const { dialogComponentProps, conflictedPackages } = options
return dialogStore.showDialog({
key: 'global-import-failed',
headerComponent: ImportFailedNodeHeader,
footerComponent: ImportFailedNodeFooter,
component: ImportFailedNodeContent,
dialogComponentProps: {
closable: true,
pt: {
root: { class: 'bg-base-background border-border-default' },
header: { class: '!p-0 !m-0' },
content: { class: '!p-0 overflow-y-hidden' },
footer: { class: '!p-0' },
pcCloseButton: {
root: {
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
}
}
},
...dialogComponentProps
},
props: {
conflictedPackages: conflictedPackages ?? []
},
footerProps: {
conflictedPackages: conflictedPackages ?? []
}
})
}
function showNodeConflictDialog(
options: {
showAfterWhatsNew?: boolean
@@ -561,6 +601,7 @@ export const useDialogService = () => {
toggleManagerDialog,
toggleManagerProgressDialog,
showLayoutDialog,
showImportFailedNodeDialog,
showNodeConflictDialog
}
}

View File

@@ -1,4 +1,5 @@
import { useAsyncState } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, shallowReactive, ref, watch } from 'vue'
import {
@@ -279,59 +280,81 @@ export const useAssetsStore = defineStore('assets', () => {
new Map<string, ReturnType<typeof useAsyncState<AssetItem[]>>>()
)
/**
* Internal helper to fetch and cache assets with a given key and fetcher
*/
async function updateModelsForKey(
key: string,
fetcher: () => Promise<AssetItem[]>
): Promise<AssetItem[]> {
if (!stateByNodeType.has(key)) {
stateByNodeType.set(
key,
useAsyncState(fetcher, [], {
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error(`Error fetching model assets for ${key}:`, err)
}
})
)
}
const state = stateByNodeType.get(key)!
modelLoadingByNodeType.set(key, true)
modelErrorByNodeType.set(key, null)
try {
await state.execute()
} finally {
modelLoadingByNodeType.set(key, state.isLoading.value)
}
const assets = state.state.value
const existingAssets = modelAssetsByNodeType.get(key)
if (!isEqual(existingAssets, assets)) {
modelAssetsByNodeType.set(key, assets)
}
modelErrorByNodeType.set(
key,
state.error.value instanceof Error ? state.error.value : null
)
return assets
}
/**
* Fetch and cache model assets for a specific node type
* Uses VueUse's useAsyncState for automatic loading/error tracking
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForNodeType(
nodeType: string
): Promise<AssetItem[]> {
if (!stateByNodeType.has(nodeType)) {
stateByNodeType.set(
nodeType,
useAsyncState(
() => assetService.getAssetsForNodeType(nodeType),
[],
{
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error(
`Error fetching model assets for ${nodeType}:`,
err
)
}
}
)
)
}
return updateModelsForKey(nodeType, () =>
assetService.getAssetsForNodeType(nodeType)
)
}
const state = stateByNodeType.get(nodeType)!
modelLoadingByNodeType.set(nodeType, true)
modelErrorByNodeType.set(nodeType, null)
try {
await state.execute()
const assets = state.state.value
modelAssetsByNodeType.set(nodeType, assets)
modelErrorByNodeType.set(
nodeType,
state.error.value instanceof Error ? state.error.value : null
)
return assets
} finally {
modelLoadingByNodeType.set(nodeType, state.isLoading.value)
}
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForTag(tag: string): Promise<AssetItem[]> {
const key = `tag:${tag}`
return updateModelsForKey(key, () => assetService.getAssetsByTag(tag))
}
return {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
updateModelsForNodeType,
updateModelsForTag
}
}
@@ -339,7 +362,8 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType: shallowReactive(new Map<string, AssetItem[]>()),
modelLoadingByNodeType: shallowReactive(new Map<string, boolean>()),
modelErrorByNodeType: shallowReactive(new Map<string, Error | null>()),
updateModelsForNodeType: async () => []
updateModelsForNodeType: async () => [],
updateModelsForTag: async () => []
}
}
@@ -347,7 +371,8 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
updateModelsForNodeType,
updateModelsForTag
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -403,6 +428,7 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
updateModelsForNodeType,
updateModelsForTag
}
})

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex w-[490px] flex-col border-t-1 border-border-default">
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Error Details -->
<div v-if="importFailedPackages.length > 0" class="flex flex-col gap-3">
<div
v-for="pkg in importFailedPackages"
:key="pkg.packageId"
class="flex flex-col gap-2 max-h-60 overflow-x-hidden overflow-y-auto scrollbar-custom"
role="region"
:aria-label="`Error traceback for ${pkg.packageId}`"
tabindex="0"
>
<!-- Error Message -->
<div
v-if="pkg.traceback || pkg.errorMessage"
class="text-xs p-4 rounded-md bg-secondary-background font-mono"
>
{{ pkg.traceback || pkg.errorMessage }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
const { conflictedPackages } = defineProps<{
conflictedPackages: ConflictDetectionResult[]
}>()
interface ImportFailedPackage {
packageId: string
packageName: string
errorMessage: string
traceback: string
}
const importFailedPackages = computed((): ImportFailedPackage[] => {
return conflictedPackages
.filter((pkg) =>
pkg.conflicts.some((conflict) => conflict.type === 'import_failed')
)
.map((pkg) => {
const importFailedConflict = pkg.conflicts.find(
(conflict) => conflict.type === 'import_failed'
)
if (!importFailedConflict) {
return {
packageId: pkg.package_id,
packageName: pkg.package_name,
errorMessage: 'Unknown import error',
traceback: ''
}
}
return {
packageId: pkg.package_id,
packageName: pkg.package_name,
errorMessage:
importFailedConflict.current_value || 'Unknown import error',
traceback: importFailedConflict.required_value || ''
}
})
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex w-full items-center justify-between px-3 pb-4">
<div class="flex w-full items-start justify-end gap-2 pr-1">
<Button variant="secondary" @click="handleCopyError">
{{ $t('importFailed.copyError') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
const { conflictedPackages = [] } = defineProps<{
conflictedPackages?: ConflictDetectionResult[]
}>()
const { copyToClipboard } = useCopyToClipboard()
const formatErrorText = computed(() => {
const errorParts: string[] = []
conflictedPackages.forEach((pkg) => {
const importFailedConflict = pkg.conflicts.find(
(conflict) => conflict.type === 'import_failed'
)
if (importFailedConflict?.required_value) {
errorParts.push(importFailedConflict.required_value)
}
})
return errorParts.join('\n\n')
})
const handleCopyError = () => {
copyToClipboard(formatErrorText.value)
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<p class="m-0 text-sm">
{{ $t('importFailed.title') }}
</p>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -245,7 +245,7 @@ describe('NodeConflictDialogContent', () => {
await conflictsHeader.trigger('click')
// Should be expanded now
const conflictItems = wrapper.findAll('.conflict-list-item')
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
expect(conflictItems.length).toBeGreaterThan(0)
})
@@ -324,7 +324,7 @@ describe('NodeConflictDialogContent', () => {
await conflictsHeader.trigger('click')
// Should display conflict messages (excluding import_failed)
const conflictItems = wrapper.findAll('.conflict-list-item')
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
@@ -338,7 +338,9 @@ describe('NodeConflictDialogContent', () => {
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll('.conflict-list-item')
const importFailedItems = wrapper.findAll(
'[aria-label*="Import failed package:"]'
)
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})

View File

@@ -50,7 +50,8 @@
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
:aria-label="`Import failed package: ${packageName}`"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
>
<span class="text-xs text-muted">
{{ packageName }}
@@ -98,7 +99,8 @@
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
:aria-label="`Conflict: ${getConflictMessage(conflict, t)}`"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
>
<span class="text-xs text-muted">{{
getConflictMessage(conflict, t)
@@ -146,7 +148,7 @@
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
>
<span class="text-xs text-muted">
{{ conflictResult.package_name }}
@@ -236,8 +238,3 @@ const toggleExtensionsPanel = () => {
importFailedExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgb(0 122 255 / 0.2);
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="flex h-12 w-full items-center justify-between pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="pi pi-exclamation-triangle text-lg"></i>
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}

View File

@@ -41,6 +41,8 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import { useImportFailedDetection } from '../../../composables/useImportFailedDetection'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{
@@ -53,6 +55,7 @@ const { isPackEnabled, enablePack, disablePack, installedPacks } =
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const { showImportFailedDialog } = useImportFailedDetection(nodePack.id || '')
const isLoading = ref(false)
@@ -81,23 +84,36 @@ const canToggleDirectly = computed(() => {
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
// Check if there's an import failed conflict first
const hasImportFailed = packageConflict.value.conflicts.some(
(conflict) => conflict.type === 'import_failed'
)
if (hasImportFailed) {
// Show import failed dialog instead of general conflict dialog
showImportFailedDialog(() => {
markConflictsAsSeen()
})
} else {
// Show general conflict dialog for other types of conflicts
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
})
}
}
}

View File

@@ -1,43 +1,37 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="inline-flex cursor-pointer items-center justify-end gap-1 border-none bg-transparent outline-none"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="text-sm text-base-foreground">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="rounded-md bg-yellow-800/20 p-3"
class="rounded-md bg-secondary-background/60 px-2 py-1"
>
<div class="flex items-center justify-between">
<div class="flex-1 text-sm break-words">
<!-- Import failed conflicts show detailed error message -->
<template v-if="conflict.type === 'import_failed'">
<div
v-if="conflict.required_value"
class="max-h-64 overflow-x-hidden scrollbar-custom overflow-y-auto rounded px-2"
>
<p class="text-xs text-muted-foreground break-all font-mono">
{{ conflict.required_value }}
</p>
</div>
</template>
<!-- Other conflict types use standard message -->
<template v-else>
<div class="text-sm break-words">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import type { components } from '@/types/comfyRegistryTypes'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { getConflictMessage } from '@/workbench/extensions/manager/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
const { conflictResult } = defineProps<{
conflictResult: ConflictDetectionResult | null | undefined
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -410,7 +410,7 @@ describe('useConflictDetection', () => {
mockComfyManagerService.getImportFailInfoBulk
).mockResolvedValue({
'fail-pack': {
msg: 'Import error',
error: 'Import error',
name: 'fail-pack',
path: '/path/to/pack'
} as any // The actual API returns different structure than types
@@ -428,7 +428,7 @@ describe('useConflictDetection', () => {
// Import failure should match the actual implementation
expect(result.results[0].conflicts).toContainEqual({
type: 'import_failed',
current_value: 'installed',
current_value: 'Import error',
required_value: 'Import error'
})
})

View File

@@ -389,7 +389,10 @@ export function useConflictDetection() {
* @returns Array of conflict detection results for failed imports
*/
function detectImportFailConflicts(
importFailInfo: Record<string, { msg: string; name: string; path: string }>
importFailInfo: Record<
string,
{ error?: string; traceback?: string } | null
>
): ConflictDetectionResult[] {
const results: ConflictDetectionResult[] = []
if (!importFailInfo || typeof importFailInfo !== 'object') {
@@ -400,8 +403,11 @@ export function useConflictDetection() {
for (const [packageId, failureInfo] of Object.entries(importFailInfo)) {
if (failureInfo && typeof failureInfo === 'object') {
// Extract error information from Manager API response
const errorMsg = failureInfo.msg || 'Unknown import error'
const modulePath = failureInfo.path || ''
const errorMsg = failureInfo.error || 'Unknown import error'
const traceback = failureInfo.traceback || ''
// Combine error and traceback for display
const fullErrorInfo = traceback || errorMsg
results.push({
package_id: packageId,
@@ -410,8 +416,8 @@ export function useConflictDetection() {
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: failureInfo.msg
current_value: errorMsg,
required_value: fullErrorInfo
}
],
is_compatible: false
@@ -420,8 +426,8 @@ export function useConflictDetection() {
console.warn(
`[ConflictDetection] Python import failure detected for ${packageId}:`,
{
path: modulePath,
error: errorMsg
error: errorMsg,
hasTraceback: !!traceback
}
)
}

View File

@@ -44,7 +44,8 @@ describe('useImportFailedDetection', () => {
>
mockDialogService = {
showErrorDialog: vi.fn()
showErrorDialog: vi.fn(),
showImportFailedNodeDialog: vi.fn()
} as unknown as ReturnType<typeof dialogService.useDialogService>
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
@@ -226,13 +227,22 @@ describe('useImportFailedDetection', () => {
showImportFailedDialog()
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
expect(mockDialogService.showImportFailedNodeDialog).toHaveBeenCalledWith({
conflictedPackages: expect.arrayContaining([
expect.objectContaining({
package_id: 'test-package',
package_name: 'Test Package',
conflicts: expect.arrayContaining([
expect.objectContaining({
type: 'import_failed'
})
])
})
]),
dialogComponentProps: {
onClose: undefined
}
)
})
})
it('should handle null packageId', () => {

View File

@@ -1,11 +1,13 @@
import { computed, unref } from 'vue'
import type { ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Extracting import failed conflicts from conflict list
@@ -24,22 +26,18 @@ function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
const { showImportFailedNodeDialog } = useDialogService()
return (importFailedInfo: ConflictDetail[] | null) => {
if (importFailedInfo) {
const errorMessage =
importFailedInfo
.map((conflict) => conflict.required_value)
.filter(Boolean)
.join('\n') || t('manager.importFailedGenericError')
const error = new Error(errorMessage)
showErrorDialog(error, {
title: t('manager.failedToInstall'),
reportType: 'importFailedError'
return (
conflictedPackages: ConflictDetectionResult[] | null,
onClose?: () => void
) => {
if (conflictedPackages && conflictedPackages.length > 0) {
showImportFailedNodeDialog({
conflictedPackages,
dialogComponentProps: {
onClose
}
})
}
}
@@ -74,13 +72,16 @@ export function useImportFailedDetection(
return importFailedInfo.value !== null
})
const showImportFailedDialog = createImportFailedDialog()
const openDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
showImportFailedDialog: (onClose?: () => void) => {
if (conflicts.value) {
openDialog([conflicts.value], onClose)
}
},
isInstalled
}
}

View File

@@ -482,12 +482,6 @@ export default defineConfig({
: []
},
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts']
},
define: {
__COMFYUI_FRONTEND_VERSION__: JSON.stringify(
process.env.npm_package_version