Compare commits

...

21 Commits

Author SHA1 Message Date
bymyself
660ccb9236 fix: promoted textarea widgets in subgraphs no longer permanently read-only
Skip disabled override for promoted widgets whose internal slot is
linked to SubgraphInput. Extract isPromotedOnOwningNode variable
and reuse it for the existing borderStyle check.

Amp-Thread-ID: https://ampcode.com/threads/T-019c88b8-a377-754b-b624-db394d59f5b5
2026-02-23 04:19:57 +00:00
Comfy Org PR Bot
1267c4b9b3 1.41.3 (#9103)
Patch version increment to 1.41.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9103-1-41-3-3106d73d365081b9b4c7f402c860150e)
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-02-22 18:14:46 -08:00
Christian Byrne
ddc2159bed docs: clarify widget serialize vs options.serialize in types and JSDoc (#9105)
## Summary

Clarifies the two distinct `serialize` properties on widgets via
improved TypeScript types and JSDoc:

- **`IWidgetOptions.serialize`** — controls whether the widget value is
included in the **API prompt** sent for execution
- **`IBaseWidget.serialize`** — controls whether the widget value is
persisted in the **workflow JSON** (`widgets_values`)

These two properties are easily confused. This PR adds cross-linking
JSDoc, explicit `@default` tags, and a clarifying comment at the check
site in `executionUtil.ts`.

## Changes

| File | Change |
|------|--------|
| `src/lib/litegraph/src/types/widgets.ts` | Add `serialize?: boolean`
to `IWidgetOptions` with JSDoc; expand JSDoc on `IBaseWidget.serialize`
|
| `src/utils/executionUtil.ts` | Clarifying comment at the
`widget.options.serialize` check |
| `src/types/metadataTypes.ts` | Connect `ComfyMetadataTags` enum values
to their corresponding serialize properties |

## Related

- Companion doc: #9102 (`WIDGET_SERIALIZATION.md`)
- Issue: #1757

## Verification

- `vue-tsc --noEmit` passes clean
- `eslint` passes clean on all 3 files
- No runtime changes — JSDoc and types only

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9105-docs-clarify-widget-serialize-vs-options-serialize-in-types-and-JSDoc-3106d73d36508155b618ee56cf18f969)
by [Unito](https://www.unito.io)
2026-02-22 18:13:48 -08:00
sno
5687b44422 Add CI validation for OSS assets (fonts and licenses) (#8828)
## Summary
Adds CI workflow to validate OSS build compliance by checking for
proprietary fonts and non-approved dependency licenses.

## Context
- Part of comprehensive OSS compliance effort (split from closed PR
#6777)
- Uses simple bash/grep approach following proven #8623 pattern
- Complements telemetry checking in PR #8826 and existing #8354

## Implementation

### Font Validation
- Scans dist/ for proprietary ABCROM fonts (.woff, .woff2, .ttf, .otf)
- Fails if any ABCROM fonts found in OSS builds
- Provides clear fix instructions

### License Validation  
- Uses `license-checker` npm package
- Validates all production dependencies
- Only allows OSI-approved licenses:
  - MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC
  - 0BSD, BlueOak-1.0.0, Python-2.0, CC0-1.0
  - Unlicense, CC-BY-4.0, CC-BY-3.0
  - Common dual-license combinations

### Workflow Details
- Two separate jobs for parallel execution
- Runs on PRs and pushes to main/dev
- Builds with `DISTRIBUTION=localhost` for OSS mode
- Clear error messages with remediation steps

## Testing
- [ ] Font check passes on current main (no ABCROM fonts in dist)
- [ ] License check passes on current main (all approved licenses)
- [ ] Intentional violation testing

## Related
- Supersedes remaining parts of closed PR #6777
- Complements PR #8826 (Mixpanel telemetry)
- Follows pattern from PR #8623 (simple bash/grep)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8828-Add-CI-validation-for-OSS-assets-fonts-and-licenses-3056d73d3650812390d5d91ca2f319fc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-02-22 17:35:19 -08:00
Christian Byrne
26fa84ce1b docs: add widget serialization reference (widget.serialize vs widget.options.serialize) (#9102)
Documents the distinction between `widget.serialize` (workflow
persistence, checked by `LGraphNode.serialize()`) and
`widget.options.serialize` (API prompt, checked by `executionUtil.ts`).

These share a property name but live at different levels of the widget
object and are consumed by different code paths — a common source of
confusion when debugging serialization bugs.

Includes:
- Explanation of both properties with code references
- Permutation table of the 4 possible combinations with real examples
- Gotchas section covering the `addWidget` options bag behavior and
`PrimitiveNode` dynamic widgets
- Reference added to `src/lib/litegraph/AGENTS.md`

Context: discovered while debugging #1757 (PrimitiveNode
`control_after_generate` lost on copy-paste).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9102-docs-add-widget-serialization-reference-widget-serialize-vs-widget-options-serialize-30f6d73d365081cd86add44bdaa20d30)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-22 16:40:43 -08:00
Benjamin Lu
e37bba7250 fix: use AssetsListItem in queue overlay expanded (#9055)
## Summary
- replace `JobGroupsList` with `JobAssetsList` in `QueueOverlayExpanded`
- standardize expanded queue rows to use `AssetsListItem`
- add `QueueOverlayExpanded` tests to verify list rendering and action
re-emits

It works surprisingly well.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9055-fix-use-AssetsListItem-in-queue-overlay-expanded-30e6d73d365081c586c7c7bca222c290)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 02:02:04 -08:00
Benjamin Lu
35f15d18b4 feat: add job history and assets sidebar badge behavior (#9050)
## Summary
Add sidebar badge behavior for queue/asset visibility updates:
- Job History tab icon shows active jobs count (`queued + running`) only
when the Job History panel is closed.
- Assets tab icon no longer mirrors active jobs; when QPO V2 is enabled
it now shows the number of assets added since the last time Assets was
opened.
- Opening Assets clears the unseen added-assets badge count.

## Changes
- Added `iconBadge` logic to Job History sidebar tab.
- Replaced Assets sidebar badge source with new unseen-assets counter
logic.
- Added `assetsSidebarBadgeStore` to track unseen asset additions from
history updates and reset on Assets open.
- Added/updated unit tests for both sidebar tab composables and the new
store behavior.


https://github.com/user-attachments/assets/33588a2a-c607-4fcc-8221-e7f11c3d79cc



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9050-fix-add-job-history-and-assets-sidebar-badge-behavior-30e6d73d365081c38297fe6aac9cd34c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 01:05:39 -08:00
Comfy Org PR Bot
a82c984520 1.41.2 (#9089)
Patch version increment to 1.41.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9089-1-41-2-30f6d73d365081beb90de78b0571f396)
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-02-22 01:05:31 -08:00
Christian Byrne
54f13930a4 feat: add display name mappings for Essentials tab nodes (#9072)
## Summary

Add frontend-only display name mappings for nodes shown in the
Essentials tab, plus parse the new `essentials_category` field from the
backend.

## Changes

- **What**: Created `src/constants/essentialsDisplayNames.ts` with a
static mapping of node names to user-friendly display names (e.g.
`CLIPTextEncode` → "Text", `ImageScale` → "Resize Image"). Regular nodes
use exact name matching; blueprint nodes use prefix matching since their
filenames include model-specific suffixes. Integrated into
`NodeLibrarySidebarTab.vue`'s `renderedRoot` computed for leaf node
labels with fallback to `display_name`. Added `essentials_category`
(z.string().optional()) to the node def schema and `ComfyNodeDefImpl` to
parse the field already sent by the backend (PR #12357).

## Review Focus

Display names are resolved only in the Essentials tab tree view
(`NodeLibrarySidebarTab.vue`), not globally, to avoid side effects on
search, bookmarks, or other views. Blueprint prefix matching is ordered
longest-first so more specific prefixes (e.g. `image_inpainting_`) match
before shorter ones (e.g. `image_edit`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9072-feat-add-display-name-mappings-for-Essentials-tab-nodes-30f6d73d3650817c9acdc9b0315ed0be)
by [Unito](https://www.unito.io)
2026-02-22 01:03:15 -08:00
Benjamin Lu
c972dca61e fix: remove duplicate running indicator from queue header (#9032)
## Summary
Remove the extra running-workflow indicator from the expanded Queue
Progress Overlay header to avoid duplicate running-count signals.

## Changes
- Remove `showConcurrentIndicator` and `concurrentWorkflowCount` props
from `QueueOverlayHeader`.
- Stop passing those props through `QueueOverlayExpanded` and
`QueueProgressOverlay`.
- Simplify `QueueOverlayHeader` tests to reflect the updated header
behavior.

## Why
The expanded header was showing redundant running status information
alongside existing running/queued summaries.

Prevent this:
<img width="240" height="92" alt="image"
src="https://github.com/user-attachments/assets/f4b1775c-b347-46f7-8668-3a1054365ada"
/>

Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=4024-28147&m=dev

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9032-fix-remove-duplicate-running-indicator-from-queue-header-30d6d73d365081d19041de2f1b0c8886)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 00:40:43 -08:00
Benjamin Lu
cf8952e205 fix: keep queue overlay clear action static (#9031)
## Summary
Make the Queue Progress Overlay header clear action label static text
(`Clear queue`) and remove queued-count text from that header area.

## Changes
- Replace dynamic header text (`{count} queued`) with static `Clear
queue` text.
- Keep the existing icon clear button behavior/style in the header.
- Keep the clear action visible when queue is empty, but disabled.
- Add disabled visual treatment for the static text when queue is empty.
- Update `QueueOverlayHeader` tests to cover the new static label and
disabled behavior.

<img width="630" height="103" alt="image"
src="https://github.com/user-attachments/assets/0a5870fc-2ad6-4241-a12d-f8c4ef72a5fa"
/>


Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=4024-28153&m=dev

<img width="528" height="105" alt="image"
src="https://github.com/user-attachments/assets/3d03a4ac-a0f1-449d-afc7-b9a6cb1c8820"
/>
<img width="529" height="124" alt="image"
src="https://github.com/user-attachments/assets/7452cd17-e388-4b6a-b164-fce0b0d55ca1"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9031-fix-keep-queue-overlay-clear-action-static-30d6d73d365081f8b908fa54775ab4d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 00:25:51 -08:00
Christian Byrne
1dcaf5d0dc perf: virtualize FormDropdownMenu to reduce DOM nodes and image requests (#8476)
## Summary

Virtualize the FormDropdownMenu to only render visible items, fixing
slow dropdown performance on cloud.

## Changes

- Integrate `VirtualGrid` into `FormDropdownMenu` for virtualized
rendering
- Add computed properties for grid configuration per layout mode
(grid/list/list-small)
- Extend `VirtualGrid` slot to provide original item index for O(1)
lookups
- Change container from `max-h-[640px]` to fixed `h-[640px]` for proper
virtualization

## Review Focus

- VirtualGrid integration within the popover context
- Layout mode switching with `:key="layoutMode"` to force re-render
- Grid style computed properties match original Tailwind classes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8476-perf-virtualize-FormDropdownMenu-to-reduce-DOM-nodes-and-image-requests-2f86d73d365081b3a79dd5e0b84df944)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Dropdowns now render with a virtualized grid/list (stable indexes,
responsive sizing) and show an empty-state icon when no items exist.

* **Bug Fixes**
* Reduced layout shift and rendering glitches with improved
spacer/scroll calculations and more reliable media measurement.

* **Style**
* Simplified media rendering (standard img/video), unified item visuals
and hover/background behavior.

* **Tests**
* Added unit and end-to-end tests for virtualization, indexing, layouts,
dynamic updates, and empty states.

* **Breaking Changes**
* Dropdown item/selection shapes and related component props/events were
updated (adapter changes may be required).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-21 22:54:19 -08:00
Christian Byrne
f707098f05 fix: subgraph unpacking creates extra link to seed widget (#9046)
## Summary

Fix subgraph unpacking creating spurious links to widget inputs (e.g.
seed) when the subgraph contains ComfySwitchNode with duplicate internal
links.

## Changes

- **What**: Two fixes in `_unpackSubgraphImpl`:
1. Strip links from serialized node data **before** `configure()` so
`onConnectionsChange` doesn't resolve subgraph-internal link IDs against
the parent graph's link map (which may contain unrelated links with
colliding numeric IDs).
2. Deduplicate links by `(origin, origin_slot, target, target_slot)`
before reconnecting, preventing repeated disconnect/reconnect cycles on
widget inputs that cause slot index drift.

## Review Focus

- The link-stripping before `configure()` mirrors what
`LGraphNode.clone()` already does — nodes should be configured without
stale link references when links will be recreated separately.
- Deduplication is defensive against malformed subgraph data; the
duplicate links in the reproduction workflow likely originated from a
prior serialization bug.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9046-fix-subgraph-unpacking-creates-extra-link-to-seed-widget-30e6d73d36508125a5fefa1309485516)
by [Unito](https://www.unito.io)
2026-02-21 22:38:05 -08:00
Christian Byrne
d2917be3a7 feat: support custom descriptions for subgraph tooltips (#9003)
## Summary

Adds support for custom descriptions on subgraph nodes that display as
tooltips when hovering.

## Changes

- Add optional `description` field to `ExportedSubgraph` interface and
`Subgraph` class
- Use description with fallback to default string in
`subgraphService.createNodeDef()`
- Add `description` to `SubgraphDefinitionBase` interface and Zod schema
for validation

## Review Focus

- Backwards compatibility: undefined description falls back to `Subgraph
node for ${name}`
- Serialization pattern: conditional spread `...(this.description && {
description })`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9003-feat-support-custom-descriptions-for-subgraph-tooltips-30d6d73d36508129bd75c77eb1c31cfb)
by [Unito](https://www.unito.io)
2026-02-21 22:29:28 -08:00
Christian Byrne
2639248867 fix: replace PrimeVue FloatLabel in WidgetTextarea with CSS-only IFTA label (#9076)
## Summary

Replace PrimeVue `FloatLabel` + `Textarea` in `WidgetTextarea` with a
CSS-only IFTA label and a new shadcn-vue Textarea component, fixing the
label-obscures-content bug.

<img width="965" height="754" alt="image"
src="https://github.com/user-attachments/assets/cab98527-834c-496d-a0ef-942fb21fd862"
/>


## Changes

- **What**: Add `src/components/ui/textarea/Textarea.vue` — thin wrapper
around native `<textarea>` with `cn()` class merging and `defineModel`.
Rewrite `WidgetTextarea.vue` to use a plain `<div>` wrapper with an
absolutely-positioned label and the new Textarea, replacing PrimeVue's
`FloatLabel variant="in"`. Add Storybook stories (Default, Disabled,
WithLabel). Update tests to remove PrimeVue plugin setup.

## Review Focus

- The label uses `absolute left-3 top-1.5 z-10 text-xxs` positioning —
verify it clears textarea content with `pt-5` padding
- `filteredProps` forwards widget options to a native textarea via
`v-bind="restAttrs"` — unknown attrs are silently ignored by the browser

Supersedes #8536

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9076-fix-replace-PrimeVue-FloatLabel-in-WidgetTextarea-with-CSS-only-IFTA-label-30f6d73d3650816fabe5ee30de0c793e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-21 22:09:56 -08:00
Christian Byrne
b41f162607 dx: compact storybook PR comment to single-line header (#9078)
## Summary

Compact the Storybook build status PR comment to a single-line header
with collapsible details, matching the approach from #8677.

## Changes

- **Starting**: Collapsed from multi-line with build steps to `## 🎨
Storybook:  Building...`
- **Completed (success)**: `## 🎨 Storybook:  Built — [View
Storybook](url)` with timestamp/links in `<details>`
- **Completed (failure)**: `## 🎨 Storybook:  Failed` with details
collapsed
- Removed version-bump branch check (starting comment no longer varies
by branch type)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9078-dx-compact-storybook-PR-comment-to-single-line-header-30f6d73d365081b98666c48a94542b70)
by [Unito](https://www.unito.io)
2026-02-21 21:45:25 -08:00
Christian Byrne
3678e65bec feat: add feature flag to disable Essentials tab in node library (#9067)
## Summary

Add a `node_library_essentials_enabled` feature flag to gate the
Essentials tab, allowing the rest of the new node library to ship while
the Essentials tab is finalized.

## Changes

- **What**: New feature flag (`node_library_essentials_enabled`) that
hides the Essentials tab in the node library sidebar and search category
sidebar. Defaults to `true` in dev/nightly builds, `false` in
production. Overridable via remote config or server feature flags. Falls
back to the "All" tab if a user previously had Essentials selected.

**Disabled UI**

<img width="547" height="782" alt="image"
src="https://github.com/user-attachments/assets/bcfcecd4-cbae-4d7b-9bcc-64bdf57929e2"
/>

**Enabled UI**

<img width="547" height="782" alt="image"
src="https://github.com/user-attachments/assets/0fb030ea-3bde-475e-982b-45e8f190cb8f"
/>


## Review Focus

- Feature flag pattern follows existing conventions (e.g.
`linearToggleEnabled`)
- Fallback behavior when essentials tab was previously selected by user

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9067-feat-add-feature-flag-to-disable-Essentials-tab-in-node-library-30e6d73d36508103b3cad9fc5d260611)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-21 21:40:45 -08:00
Christian Byrne
16ddcfdbaf feat: add toolkit node tracking to execution telemetry (#9073)
## Summary

Add toolkit (Essentials) node tracking to execution telemetry, enabling
measurement of toolkit node adoption and popularity.

## Changes

- **What**: Add `has_toolkit_nodes`, `toolkit_node_names`, and
`toolkit_node_count` fields to `ExecutionContext` and
`RunButtonProperties`. Toolkit nodes are identified via a hardcoded set
of node type names (10 novel Essentials nodes) and by `python_module ===
'comfy_essentials'` for blueprint nodes. Detection runs inside the
existing `reduceAllNodes()` traversal — no additional graph walks.

## Review Focus

- Toolkit node identification is frontend-only (no backend flag) — uses
two mechanisms: hardcoded `TOOLKIT_NODE_NAMES` set and
`TOOLKIT_BLUEPRINT_MODULES` for blueprints
- API node overlap is intentional — a node can appear in both
`api_node_names` and `toolkit_node_names`
- Blueprint detection via `python_module` automatically picks up new
essentials blueprints without code changes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9073-feat-add-toolkit-node-tracking-to-execution-telemetry-30f6d73d365081b3ac91e697889c58b6)
by [Unito](https://www.unito.io)
2026-02-21 21:31:09 -08:00
Christian Byrne
ef5198be25 fix: invalidate loader node dropdown cache after model asset deletion (#8434)
## Summary

When deleting a model asset (checkpoint, lora, etc.), the loader node
dropdowns now update correctly by invalidating the category-keyed cache.

## Problem

After deleting a model asset in the asset browser, the loader node
dropdowns (e.g., CheckpointLoaderSimple, LoraLoader) still showed the
deleted model. Users had to refresh or re-open the dropdown to see the
updated list.

## Solution

After successful asset deletion, check each deleted asset's tags for
model categories (checkpoints, loras, etc.) and call
`assetsStore.invalidateCategory()` for each affected category. This
triggers a refetch when the dropdown is next accessed.

## Changes

- In `useMediaAssetActions.ts`:
  - After deletion, iterate through deleted assets' tags
- Check if each tag corresponds to a model category using
`modelToNodeStore.getAllNodeProviders()`
  - Call `invalidateCategory()` for each affected category

- In `useMediaAssetActions.test.ts`:
  - Added mocks for `useAssetsStore` and `useModelToNodeStore`
  - Added tests for deletion invalidation behavior

## Testing

- Added unit tests verifying:
  - Model cache is invalidated when deleting model assets
  - Multiple categories are invalidated when deleting multiple assets
  - Non-model assets (input, output) don't trigger invalidation

## Part of Stack

This is **PR 2 of 2** in a stacked PR series:
1. **[PR 1](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8433)**:
Refactor asset cache to category-keyed (architectural improvement)
2. **This PR**: Fix deletion invalidation using the clean architecture

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8434-fix-invalidate-loader-node-dropdown-cache-after-model-asset-deletion-2f76d73d3650813181aedc373d9799c6)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved model cache invalidation after asset deletions — only
relevant model categories are invalidated and non-model assets are
ignored.
* Fixed edge-rendering behavior so reroutes are cleared correctly in the
canvas.

* **Chores**
* Added category-aware cache management and targeted refreshes for model
assets.

* **Tests**
* Expanded tests for cache invalidation, category handling, workflow
interactions, and related mocks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-21 20:55:32 -08:00
Dante
38675e658f feat: add dev-time feature flag overrides via localStorage (#9075)
## Summary
- Adds `localStorage`-based dev-time override for feature flags, with
`ff:` key prefix (e.g.
`localStorage.setItem('ff:team_workspaces_enabled', 'true')`)
- Override priority: dev localStorage > remoteConfig >
serverFeatureFlags
- Guarded by `import.meta.env.DEV` — tree-shaken to empty function in
production builds
- Extracts `resolveFlag` helper in `useFeatureFlags` to eliminate
repeated fallback pattern

Fixes #9054

## Test plan
- [x] `getDevOverride` unit tests: boolean/number/string/object parsing,
prefix isolation, invalid JSON warning
- [x] `api.getServerFeature` / `serverSupportsFeature` override tests
- [x] `useFeatureFlags` override priority tests, including
`teamWorkspacesEnabled` bypassing guards
- [x] Production build verified: `getDevOverride` compiles to empty
function body, localStorage never accessed
- [x] `pnpm typecheck`, `pnpm lint` clean

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9075-feat-add-dev-time-feature-flag-overrides-via-localStorage-30f6d73d365081b394d3ccc461987b1a)
by [Unito](https://www.unito.io)
2026-02-21 20:36:09 -08:00
Dante
bd95150f82 test: remove unused DebugHelper from browser_tests (#9017)
## Summary

- Remove unused `DebugHelper` class and its import from browser test
fixtures

Fixes #8581

## Test plan

- [x] Verify browser tests still pass without `DebugHelper`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9017-test-remove-unused-DebugHelper-from-browser_tests-30d6d73d36508158b8c9fc83670df4f3)
by [Unito](https://www.unito.io)
2026-02-22 11:52:54 +09:00
104 changed files with 3154 additions and 517 deletions

View File

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

View File

@@ -6,9 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -63,8 +60,7 @@ jobs:
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'

View File

@@ -182,10 +182,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -194,8 +190,7 @@ jobs:
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
"starting"
# Deploy and comment for non-forked PRs only
deploy-and-comment:

View File

@@ -6,9 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -63,8 +60,7 @@ jobs:
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'

View File

@@ -24,8 +24,7 @@ jobs:
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
"starting"
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,183 @@
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
"pos": [600, 400],
"size": [200, 100],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 5,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph With Duplicate Links",
"inputNode": {
"id": -10,
"bounding": [200, 400, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 400, 120, 60]
},
"inputs": [],
"outputs": [
{
"id": "out-latent-1",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": [920, 420]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [400, 100],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "EmptyLatentImage",
"pos": [100, 200],
"size": [200, 106],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1, 3, 4, 5]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 3,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 4,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 5,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.38.14"
},
"version": 0.4
}

View File

@@ -26,7 +26,6 @@ import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DebugHelper } from './helpers/DebugHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
@@ -174,7 +173,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly debug: DebugHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -219,7 +217,6 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.debug = new DebugHelper(page, this.canvas)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)

View File

@@ -1,167 +0,0 @@
import type { Locator, Page, TestInfo } from '@playwright/test'
import type { Position } from '../types'
export interface DebugScreenshotOptions {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
export class DebugHelper {
constructor(
private page: Page,
private canvas: Locator
) {}
async addMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
const existing = document.getElementById(markerId)
if (existing) existing.remove()
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
async removeMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
async attachScreenshot(
testInfo: TestInfo,
name: string,
options?: DebugScreenshotOptions
): Promise<void> {
if (options?.markers) {
for (const marker of options.markers) {
await this.addMarker(marker.position, marker.id)
}
}
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
if (options?.markers) {
await this.removeMarkers()
}
}
async saveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
}
async getCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
async showCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
async hideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
}

View File

@@ -375,6 +375,45 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
})
})
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-duplicate-links'
)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
return { error: 'No subgraph node found' }
}
graph.unpackSubgraph(subgraphNode)
const linkCount = graph.links.size
const nodes = graph.nodes
const ksampler = nodes.find((n) => n.type === 'KSampler')
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.link != null
).length
return { linkCount, linkedInputCount, nodeCount: nodes.length }
})
expect(result).not.toHaveProperty('error')
// Should have exactly 1 link (EmptyLatentImage→KSampler)
// not 4 (with 3 duplicates). The KSampler→output link is dropped
// because the subgraph output has no downstream connection.
expect(result.linkCount).toBe(1)
// KSampler should have exactly 1 linked input (latent_image)
expect(result.linkedInputCount).toBe(1)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
set -e
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status>
# Input validation
# Validate PR number is numeric
@@ -31,8 +31,6 @@ case "$STATUS" in
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
@@ -135,23 +133,8 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post concise starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Tests: ⏳ Running...
Tests started at $START_TIME UTC
<details>
<summary>📊 Browser Tests</summary>
- **chromium**: Running...
- **chromium-0.5x**: Running...
- **chromium-2x**: Running...
- **mobile-chrome**: Running...
</details>
EOF
)
comment="$COMMENT_MARKER
## 🎭 Playwright: ⏳ Running..."
post_comment "$comment"
else
@@ -300,7 +283,7 @@ else
# Generate compact single-line comment
comment="$COMMENT_MARKER
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
## 🎭 Playwright: $status_icon $total_passed passed, $total_failed failed$flaky_note"
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then

View File

@@ -2,7 +2,7 @@
set -e
# Deploy Storybook to Cloudflare Pages and comment on PR
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status>
# Input validation
# Validate PR number is numeric
@@ -31,7 +31,6 @@ case "$STATUS" in
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
@@ -120,50 +119,9 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Check if this is a version-bump branch
IS_VERSION_BUMP="false"
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
IS_VERSION_BUMP="true"
fi
# Post starting comment with appropriate message
if [ "$IS_VERSION_BUMP" = "true" ]; then
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🎨 Running Chromatic visual tests...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
else
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🌐 Preparing deployment to Cloudflare Pages...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
fi
# Post starting comment
comment="$COMMENT_MARKER
## 🎨 Storybook: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
@@ -192,56 +150,57 @@ elif [ "$STATUS" = "completed" ]; then
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate completion comment based on conclusion
# Generate compact header based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Build completed successfully!"
footer_text="🎉 Your Storybook is ready for review!"
status_text="Built"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Build skipped."
footer_text=" Chromatic was skipped for this PR."
status_text="Skipped"
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Build cancelled."
footer_text=" The Chromatic run was cancelled."
status_text="Cancelled"
else
status_icon="❌"
status_text="Build failed!"
footer_text="⚠️ Please check the workflow logs for error details."
status_text="Failed"
fi
comment="$COMMENT_MARKER
## 🎨 Storybook Build Status
$status_icon **$status_text**
# Build compact header with optional storybook link
header="## 🎨 Storybook: $status_icon $status_text"
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
header="$header$deployment_url"
fi
# Build details section
details="<details>
<summary>Details</summary>
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 🔗 Links
**Links**
- [📊 View Workflow Run]($WORKFLOW_URL)"
# Add deployment status
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
comment="$comment
details="$details
- ❌ Storybook deployment failed"
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
comment="$comment
- 🎨 $deployment_url"
else
comment="$comment
- ⚠️ Build failed - $deployment_url"
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⚠️ Build failed — $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
comment="$comment
details="$details
- ⏭️ Storybook deployment skipped (build did not succeed)"
fi
comment="$comment
---
$footer_text"
details="$details
</details>"
comment="$COMMENT_MARKER
$header
$details"
post_comment "$comment"
fi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -64,6 +65,7 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
@@ -79,7 +81,7 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })

View File

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

View File

@@ -52,7 +52,7 @@
:value="tab.value"
:class="
cn(
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -70,7 +70,9 @@
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="selectedTab === 'essentials'"
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
@node-click="handleNodeClick"
@@ -109,10 +111,12 @@ import {
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import {
@@ -136,11 +140,22 @@ import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()
const selectedTab = useLocalStorage<TabId>(
'Comfy.NodeLibrary.Tab',
DEFAULT_TAB_ID
)
watchEffect(() => {
if (
!flags.nodeLibraryEssentialsEnabled &&
selectedTab.value === 'essentials'
) {
selectedTab.value = DEFAULT_TAB_ID
}
})
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
'Comfy.NodeLibrary.SortByTab',
{
@@ -216,16 +231,23 @@ function findFirstLeaf(node: TreeNode): TreeNode | undefined {
}
function fillNodeInfo(
node: TreeNode
node: TreeNode,
{ useEssentialsLabels = false }: { useEssentialsLabels?: boolean } = {}
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
const children = node.children?.map(fillNodeInfo)
const children = node.children?.map((child) =>
fillNodeInfo(child, { useEssentialsLabels })
)
const totalLeaves = node.leaf
? 1
: (children?.reduce((acc, child) => acc + child.totalLeaves, 0) ?? 0)
return {
key: node.key,
label: node.leaf ? node.data?.display_name : node.label,
label: node.leaf
? useEssentialsLabels
? (resolveEssentialsDisplayName(node.data) ?? node.data?.display_name)
: node.data?.display_name
: node.label,
leaf: node.leaf,
data: node.data,
icon: node.leaf ? 'icon-[comfy--node]' : getFolderIcon(node),
@@ -260,7 +282,7 @@ const essentialSections = computed(() => {
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
? fillNodeInfo(applySorting(section.tree))
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
: fillNodeInfo({ key: 'root', label: '', children: [] })
})
@@ -324,11 +346,21 @@ async function handleSearch() {
expandedKeys.value = allKeys
}
const tabs = computed(() => [
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
])
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
})
onMounted(() => {
searchBoxRef.value?.focus()

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Textarea from './Textarea.vue'
const meta: Meta<typeof Textarea> = {
title: 'UI/Textarea',
component: Textarea,
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof Textarea>
export const Default: Story = {
render: () => ({
components: { Textarea },
setup() {
const value = ref('Hello world')
return { value }
},
template:
'<Textarea v-model="value" placeholder="Type something..." class="max-w-sm" />'
})
}
export const Disabled: Story = {
render: () => ({
components: { Textarea },
template:
'<Textarea model-value="Disabled textarea" disabled class="max-w-sm" />'
})
}
export const WithLabel: Story = {
render: () => ({
components: { Textarea },
setup() {
const value = ref('Content that sits below the label')
return { value }
},
template: `
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
Prompt
</label>
<Textarea
v-model="value"
class="size-full resize-none border-none bg-transparent pt-5 text-xs"
/>
</div>
`
})
}

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restAttrs } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
</script>
<template>
<textarea
v-bind="restAttrs"
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)
"
/>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isReactive, isReadonly } from 'vue'
import {
@@ -175,4 +175,49 @@ describe('useFeatureFlags', () => {
expect(flags.linearToggleEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
})
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
vi.mocked(api.getServerFeature).mockReturnValue(false)
localStorage.setItem('ff:model_upload_button_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.modelUploadButtonEnabled).toBe(true)
})
it('resolveFlag falls through to server when no override is set', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.assetRenameEnabled).toBe(true)
})
it('direct server flags delegate override to api.getServerFeature', () => {
vi.mocked(api.getServerFeature).mockImplementation((path) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return 'overridden'
return undefined
})
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe('overridden')
})
it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:team_workspaces_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
})
})

View File

@@ -6,6 +6,7 @@ import {
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
/**
* Known server feature flags (top-level, not extensions)
@@ -21,7 +22,21 @@ export enum ServerFeatureFlag {
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled'
}
/**
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
*/
function resolveFlag<T>(
flagKey: string,
remoteConfigValue: T | undefined,
defaultValue: T
): T {
const override = getDevOverride<T>(flagKey)
if (override !== undefined) return override
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
@@ -39,38 +54,40 @@ export function useFeatureFlags() {
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
get modelUploadButtonEnabled() {
return (
remoteConfig.value.model_upload_button_enabled ??
api.getServerFeature(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
)
return resolveFlag(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
remoteConfig.value.model_upload_button_enabled,
false
)
},
get assetRenameEnabled() {
return (
remoteConfig.value.asset_rename_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.ASSET_RENAME_ENABLED,
remoteConfig.value.asset_rename_enabled,
false
)
},
get privateModelsEnabled() {
return (
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.PRIVATE_MODELS_ENABLED,
remoteConfig.value.private_models_enabled,
false
)
},
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
remoteConfig.value.onboarding_survey_enabled,
false
)
},
get linearToggleEnabled() {
if (isNightly) return true
return (
remoteConfig.value.linear_toggle_enabled ??
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
remoteConfig.value.linear_toggle_enabled,
false
)
},
/**
@@ -80,11 +97,12 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
if (!isCloud) return false
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
// Only return true if authenticated config has been loaded.
// This prevents race conditions where code checks this flag before
// WorkspaceAuthGate has refreshed the config with auth.
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return false
return (
@@ -93,13 +111,25 @@ export function useFeatureFlags() {
)
},
get userSecretsEnabled() {
return (
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,
remoteConfig.value.user_secrets_enabled,
false
)
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
},
get nodeLibraryEssentialsEnabled() {
if (isNightly || import.meta.env.DEV) return true
return (
remoteConfig.value.node_library_essentials_enabled ??
api.getServerFeature(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
)
}
})

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',
// Video Tools
'Video Slice',
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode'
])
/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])

View File

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

View File

@@ -484,3 +484,110 @@ describe('ensureGlobalIdUniqueness', () => {
expect(subNode.id).toBe(subId)
})
})
describe('Subgraph Unpacking', () => {
class TestNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'TestNode')
this.addInput('input_0', 'number')
this.addOutput('output_0', 'number')
}
}
class MultiInputNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'MultiInputNode')
this.addInput('input_0', 'number')
this.addInput('input_1', 'number')
this.addOutput('output_0', 'number')
}
}
function registerTestNodes() {
LiteGraph.registerNodeType('test/TestNode', TestNode)
LiteGraph.registerNodeType('test/MultiInputNode', MultiInputNode)
}
function createSubgraphOnGraph(rootGraph: LGraph) {
return rootGraph.createSubgraph(createTestSubgraphData())
}
it('deduplicates links when unpacking subgraph with duplicate links', () => {
registerTestNodes()
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const sourceNode = LiteGraph.createNode('test/TestNode', 'Source')!
const targetNode = LiteGraph.createNode('test/TestNode', 'Target')!
subgraph.add(sourceNode)
subgraph.add(targetNode)
// Create a legitimate link
sourceNode.connect(0, targetNode, 0)
expect(subgraph._links.size).toBe(1)
// Manually add duplicate links (simulating the bug)
const existingLink = subgraph._links.values().next().value!
for (let i = 0; i < 3; i++) {
const dupLink = new LLink(
++subgraph.state.lastLinkId,
existingLink.type,
existingLink.origin_id,
existingLink.origin_slot,
existingLink.target_id,
existingLink.target_slot
)
subgraph._links.set(dupLink.id, dupLink)
sourceNode.outputs[0].links!.push(dupLink.id)
}
expect(subgraph._links.size).toBe(4)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.unpackSubgraph(subgraphNode)
// After unpacking, there should be exactly 1 link (not 4)
expect(rootGraph.links.size).toBe(1)
})
it('preserves correct link connections when unpacking with duplicate links', () => {
registerTestNodes()
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const sourceNode = LiteGraph.createNode('test/MultiInputNode', 'Source')!
const targetNode = LiteGraph.createNode('test/MultiInputNode', 'Target')!
subgraph.add(sourceNode)
subgraph.add(targetNode)
// Connect source output 0 → target input 0
sourceNode.connect(0, targetNode, 0)
// Add duplicate links to the same connection
const existingLink = subgraph._links.values().next().value!
const dupLink = new LLink(
++subgraph.state.lastLinkId,
existingLink.type,
existingLink.origin_id,
existingLink.origin_slot,
existingLink.target_id,
existingLink.target_slot
)
subgraph._links.set(dupLink.id, dupLink)
sourceNode.outputs[0].links!.push(dupLink.id)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.unpackSubgraph(subgraphNode)
// Verify only 1 link exists
expect(rootGraph.links.size).toBe(1)
// Verify target input 1 does NOT have a link (no spurious connection)
const unpackedTarget = rootGraph.nodes.find((n) => n.title === 'Target')!
expect(unpackedTarget.inputs[0].link).not.toBeNull()
expect(unpackedTarget.inputs[1].link).toBeNull()
})
})

View File

@@ -1929,15 +1929,20 @@ export class LGraph
node.id = this.last_node_id
n_info.id = this.last_node_id
// Strip links from serialized data before configure to prevent
// onConnectionsChange from resolving subgraph-internal link IDs
// against the parent graph's link map (which may contain unrelated
// links with the same numeric IDs).
for (const input of n_info.inputs ?? []) {
input.link = null
}
for (const output of n_info.outputs ?? []) {
output.links = []
}
this.add(node, true)
node.configure(n_info)
node.setPos(node.pos[0] + offsetX, node.pos[1] + offsetY)
for (const input of node.inputs) {
input.link = null
}
for (const output of node.outputs) {
output.links = []
}
toSelect.push(node)
}
const groups = structuredClone(
@@ -2043,8 +2048,19 @@ export class LGraph
}
this.remove(subgraphNode)
this.subgraphs.delete(subgraphNode.subgraph.id)
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
const seenLinks = new Set<string>()
const dedupedNewLinks = newLinks.filter((link) => {
const key = `${link.oid}:${link.oslot}:${link.tid}:${link.tslot}`
if (seenLinks.has(key)) return false
seenLinks.add(key)
return true
})
const linkIdMap = new Map<LinkId, LinkId[]>()
for (const newLink of newLinks) {
for (const newLink of dedupedNewLinks) {
let created: LLink | null | undefined
if (newLink.oid == SUBGRAPH_INPUT_ID) {
if (!(this instanceof Subgraph)) {
@@ -2102,7 +2118,7 @@ export class LGraph
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
for (const newLink of newLinks) {
for (const newLink of dedupedNewLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
@@ -2657,6 +2673,8 @@ export class Subgraph
/** The display name of the subgraph. */
name: string = 'Unnamed Subgraph'
/** Optional description shown as tooltip when hovering over the subgraph node. */
description?: string
readonly inputNode = new SubgraphInputNode(this)
readonly outputNode = new SubgraphOutputNode(this)
@@ -2707,9 +2725,10 @@ export class Subgraph
| (ISerialisedGraph & ExportedSubgraph)
| (SerialisableGraph & ExportedSubgraph)
): void {
const { name, inputs, outputs, widgets } = data
const { name, description, inputs, outputs, widgets } = data
this.name = name
this.description = description
if (inputs) {
this.inputs.length = 0
for (const input of inputs) {
@@ -2920,6 +2939,7 @@ export class Subgraph
revision: this.revision,
config: this.config,
name: this.name,
...(this.description && { description: this.description }),
inputNode: this.inputNode.asSerialisable(),
outputNode: this.outputNode.asSerialisable(),
inputs: this.inputs.map((x) => x.asSerialisable()),

View File

@@ -76,7 +76,6 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => {
// Verify core properties
expect(restored.id).toBe(original.id)
expect(restored.name).toBe(original.name)
// @ts-expect-error description property not in type definition
expect(restored.description).toBe(original.description)
// Verify I/O structure

View File

@@ -139,6 +139,8 @@ export interface ExportedSubgraph extends SerialisableGraph {
name: string
/** Optional category for organizing subgraph blueprints in the node library. */
category?: string
/** Optional description shown as tooltip when hovering over the subgraph node. */
description?: string
inputNode: ExportedSubgraphIONode
outputNode: ExportedSubgraphIONode
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -35,12 +36,32 @@ vi.mock('vue-i18n', () => ({
})
}))
const mockShowDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
showDialog: mockShowDialog
})
}))
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
const mockUpdateHistory = vi.hoisted(() => vi.fn())
const mockUpdateInputs = vi.hoisted(() => vi.fn())
const mockHasCategory = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
setAssetDeleting: mockSetAssetDeleting,
updateHistory: mockUpdateHistory,
updateInputs: mockUpdateInputs,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
hasCategory: mockHasCategory
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({})
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: vi.fn()
@@ -93,14 +114,33 @@ vi.mock('@/utils/typeGuardUtil', () => ({
isResultItemType: vi.fn().mockReturnValue(true)
}))
const mockGetAssetType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
getAssetType: vi.fn().mockReturnValue('input')
getAssetType: mockGetAssetType
}))
vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
}))
const mockDeleteAsset = vi.hoisted(() => vi.fn())
vi.mock('../services/assetService', () => ({
assetService: {
deleteAsset: mockDeleteAsset
}
}))
vi.mock('@/scripts/api', () => ({
api: {
deleteItem: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
user: 'test-user'
}
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -115,7 +155,7 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
describe('useMediaAssetActions', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
capturedFilenames.values = []
mockIsCloud.value = false
@@ -218,4 +258,114 @@ describe('useMediaAssetActions', () => {
})
})
})
describe('deleteAssets - model cache invalidation', () => {
beforeEach(() => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockDeleteAsset.mockResolvedValue(undefined)
mockInvalidateModelsForCategory.mockClear()
mockSetAssetDeleting.mockClear()
mockUpdateHistory.mockClear()
mockUpdateInputs.mockClear()
mockHasCategory.mockClear()
// By default, hasCategory returns true for model categories
mockHasCategory.mockImplementation(
(tag: string) => tag === 'checkpoints' || tag === 'loras'
)
})
it('should invalidate model cache when deleting a model asset', async () => {
const actions = useMediaAssetActions()
const modelAsset = createMockAsset({
id: 'checkpoint-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
})
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(modelAsset)
// Only 'checkpoints' exists in cache; 'models' is excluded
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
it('should invalidate multiple categories for multiple assets', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createMockAsset({ id: '2', tags: ['models', 'loras'] })
]
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(assets)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
})
it('should not invalidate model cache for non-model assets', async () => {
const actions = useMediaAssetActions()
const inputAsset = createMockAsset({
id: 'input-1',
name: 'image.png',
tags: ['input']
})
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(inputAsset)
// 'input' tag is excluded, so no cache invalidation
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
})
it('should only invalidate categories that exist in cache', async () => {
const actions = useMediaAssetActions()
// hasCategory returns false for 'unknown-category'
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
const assets = [
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
]
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(assets)
// Only checkpoints should be invalidated (unknown-category not in cache)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
})
})

View File

@@ -26,6 +26,8 @@ import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
export function useMediaAssetActions() {
const { t } = useI18n()
const toast = useToast()
@@ -639,6 +641,22 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
// Invalidate model caches for affected categories
const modelCategories = new Set<string>()
for (const asset of assetArray) {
for (const tag of asset.tags ?? []) {
if (EXCLUDED_TAGS.has(tag)) continue
if (assetsStore.hasCategory(tag)) {
modelCategories.add(tag)
}
}
}
for (const category of modelCategories) {
assetsStore.invalidateModelsForCategory(category)
}
// Show appropriate feedback based on results
if (failed.length === 0) {
toast.add({

View File

@@ -43,4 +43,5 @@ export type RemoteConfig = {
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
}

View File

@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
watch: vi.fn()
}
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: vi.fn()
})
}))
vi.mock('@/platform/telemetry/topupTracker', () => ({
checkForCompletedTopup: vi.fn(),
clearTopupTracking: vi.fn(),
startTopupTracking: vi.fn()
}))
const hoisted = vi.hoisted(() => ({
mockNodeDefsByName: {} as Record<string, unknown>,
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: hoisted.mockNodeDefsByName
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
knownTemplateNames: new Set()
})
})
)
function mockNode(
type: string,
isSubgraph = false
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
return {
type,
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
}
}
vi.mock('@/utils/graphTraversalUtil', () => ({
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
let result = initial
for (const node of hoisted.mockNodes) {
result = reducer(result, node)
}
return result
})
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: {} }
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
}))
import { MixpanelTelemetryProvider } from './MixpanelTelemetryProvider'
describe('MixpanelTelemetryProvider.getExecutionContext', () => {
let provider: MixpanelTelemetryProvider
beforeEach(() => {
vi.clearAllMocks()
hoisted.mockNodes.length = 0
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
delete hoisted.mockNodeDefsByName[key]
}
provider = new MixpanelTelemetryProvider()
})
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
hoisted.mockNodeDefsByName['KSampler'] = {
name: 'KSampler',
python_module: 'nodes'
}
hoisted.mockNodeDefsByName['LoadImage'] = {
name: 'LoadImage',
python_module: 'nodes'
}
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(false)
expect(context.toolkit_node_names).toEqual([])
expect(context.toolkit_node_count).toBe(0)
})
it('detects individual toolkit nodes by type name', () => {
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
hoisted.mockNodeDefsByName['Canny'] = {
name: 'Canny',
python_module: 'comfy_extras.nodes_canny'
}
hoisted.mockNodeDefsByName['KSampler'] = {
name: 'KSampler',
python_module: 'nodes'
}
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['Canny'])
expect(context.toolkit_node_count).toBe(1)
})
it('detects blueprint toolkit nodes via python_module', () => {
const blueprintType = 'SubgraphBlueprint.text_to_image'
hoisted.mockNodes.push(mockNode(blueprintType, true))
hoisted.mockNodeDefsByName[blueprintType] = {
name: blueprintType,
python_module: 'comfy_essentials'
}
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual([blueprintType])
expect(context.toolkit_node_count).toBe(1)
})
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
hoisted.mockNodeDefsByName['Canny'] = {
name: 'Canny',
python_module: 'comfy_extras.nodes_canny'
}
const context = provider.getExecutionContext()
expect(context.toolkit_node_names).toEqual(['Canny'])
expect(context.toolkit_node_count).toBe(2)
})
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
name: 'RecraftRemoveBackgroundNode',
python_module: 'comfy_extras.nodes_api',
api_node: true
}
const context = provider.getExecutionContext()
expect(context.has_api_nodes).toBe(true)
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
})
it('uses node.type as tracking name when nodeDef is missing', () => {
hoisted.mockNodes.push(mockNode('ImageCrop'))
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
})
})

View File

@@ -2,6 +2,10 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
TOOLKIT_BLUEPRINT_MODULES,
TOOLKIT_NODE_NAMES
} from '@/constants/toolkitNodes'
import {
checkForCompletedTopup as checkTopupUtil,
clearTopupTracking as clearTopupUtil,
@@ -285,6 +289,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source
}
@@ -432,10 +438,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
type NodeMetrics = {
custom_node_count: number
api_node_count: number
toolkit_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
@@ -458,8 +467,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
}
const isToolkitNode =
TOOLKIT_NODE_NAMES.has(node.type) ||
(nodeDef?.python_module !== undefined &&
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
if (isToolkitNode) {
metrics.has_toolkit_nodes = true
const trackingName = nodeDef?.name ?? node.type
if (!metrics.toolkit_node_names.includes(trackingName)) {
metrics.toolkit_node_names.push(trackingName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
@@ -468,10 +490,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
{
custom_node_count: 0,
api_node_count: 0,
toolkit_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: []
}
)

View File

@@ -59,6 +59,8 @@ export interface RunButtonProperties {
subgraph_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
}
@@ -82,6 +84,9 @@ export interface ExecutionContext {
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
toolkit_node_count: number
trigger_source?: ExecutionTriggerSource
}

View File

@@ -396,6 +396,8 @@ interface SubgraphDefinitionBase<
id: string
revision: number
name: string
/** Optional description shown as tooltip when hovering over the subgraph node. */
description?: string
category?: string
/** Custom metadata for the subgraph (description, searchAliases, etc.) */
extra?: T extends ComfyWorkflow1BaseInput
@@ -432,6 +434,8 @@ const zSubgraphDefinition = zComfyWorkflow1
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
/** Optional description shown as tooltip when hovering over the subgraph node. */
description: z.string().optional(),
category: z.string().optional(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,

View File

@@ -192,19 +192,23 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with slot-linked override for disabled
// Build options from store state, with slot-linked override for disabled.
// Promoted widgets inside a subgraph are always linked to SubgraphInput,
// but should remain interactive — skip the disabled override for them.
const storeOptions = widgetState?.options ?? {}
const widgetOptions = slotMetadata?.linked
? { ...storeOptions, disabled: true }
: storeOptions
const isPromotedOnOwningNode =
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
const widgetOptions =
slotMetadata?.linked && !isPromotedOnOwningNode
? { ...storeOptions, disabled: true }
: storeOptions
// Derive border style from store metadata
const borderStyle =
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
? 'ring ring-component-node-widget-promoted'
: widget.options?.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const borderStyle = isPromotedOnOwningNode
? 'ring ring-component-node-widget-promoted'
: widget.options?.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,

View File

@@ -1,6 +1,4 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -24,18 +22,12 @@ function createMockWidget(
function mountComponent(
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false,
placeholder?: string
) {
return mount(WidgetTextarea, {
global: {
plugins: [PrimeVue],
components: { Textarea }
},
props: {
widget,
modelValue,
readonly,
placeholder
}
})
@@ -185,18 +177,39 @@ describe('WidgetTextarea Value Binding', () => {
it('uses provided placeholder when specified', () => {
const widget = createMockWidget('test')
const wrapper = mountComponent(
widget,
'test',
false,
'Custom placeholder'
)
const wrapper = mountComponent(widget, 'test', 'Custom placeholder')
const textarea = wrapper.find('textarea')
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
})
})
describe('Read-Only Behavior', () => {
it('is readonly when options.read_only is true', () => {
const widget = createMockWidget('test', { read_only: true })
const wrapper = mountComponent(widget, 'test')
expect(wrapper.find('textarea').attributes('readonly')).toBeDefined()
})
it('is readonly when options.disabled is true', () => {
const widget = createMockWidget('test', { disabled: true })
const wrapper = mountComponent(widget, 'test')
expect(wrapper.find('textarea').attributes('readonly')).toBeDefined()
})
it('is editable when neither read_only nor disabled is set', () => {
const widget = createMockWidget('test', {})
const wrapper = mountComponent(widget, 'test')
expect(wrapper.find('textarea').attributes('readonly')).toBeUndefined()
})
it('is editable when disabled is explicitly false', () => {
const widget = createMockWidget('test', { disabled: false })
const wrapper = mountComponent(widget, 'test')
expect(wrapper.find('textarea').attributes('readonly')).toBeUndefined()
})
})
describe('Edge Cases', () => {
it('handles very long text', async () => {
const widget = createMockWidget('short')

View File

@@ -1,37 +1,45 @@
<template>
<FloatLabel
variant="in"
:unstyled="hideLayoutField"
<div
:class="
cn(
'rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
widget.borderStyle
)
"
>
<label
v-if="!hideLayoutField"
:for="id"
class="pointer-events-none absolute left-3 top-1.5 z-10 text-xxs text-muted-foreground"
>
{{ displayName }}
</label>
<Textarea
v-bind="filteredProps"
:id
v-model="modelValue"
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
:class="
cn(
WidgetInputBaseClass,
'size-full text-xs resize-none',
!hideLayoutField && 'pt-5'
)
"
:placeholder
:readonly="isReadOnly"
fluid
data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@contextmenu.capture.stop
/>
<label v-if="!hideLayoutField" :for="id">{{ displayName }}</label>
</FloatLabel>
</div>
</template>
<script setup lang="ts">
import FloatLabel from 'primevue/floatlabel'
import Textarea from 'primevue/textarea'
import { computed, useId } from 'vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -233,4 +233,37 @@ describe('API Feature Flags', () => {
expect(flag.value).toBe(true)
})
})
describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
})
it('getServerFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.getServerFeature('some_flag')).toBe(true)
})
it('serverSupportsFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.serverSupportsFeature('some_flag')).toBe(true)
})
it('getServerFeature falls through when no override is set', () => {
api.serverFeatureFlags.value = { some_flag: 'server_value' }
expect(api.getServerFeature('some_flag')).toBe('server_value')
})
it('getServerFeature override works with numeric values', () => {
api.serverFeatureFlags.value = { max_upload_size: 100 }
localStorage.setItem('ff:max_upload_size', '999')
expect(api.getServerFeature('max_upload_size')).toBe(999)
})
})
})

View File

@@ -5,6 +5,7 @@ import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import type {
ModelFile,
ModelFolderInfo
@@ -1299,6 +1300,8 @@ export class ComfyApi extends EventTarget {
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
const override = getDevOverride<boolean>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName) === true
}
@@ -1309,6 +1312,8 @@ export class ComfyApi extends EventTarget {
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
const override = getDevOverride<T>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
}

View File

@@ -49,7 +49,7 @@ export const useSubgraphService = () => {
output_tooltips: [],
name: id,
display_name: name,
description: `Subgraph node for ${name}`,
description: exportedSubgraph.description || `Subgraph node for ${name}`,
category: 'subgraph',
output_node: false,
python_module: 'nodes'

View File

@@ -507,12 +507,12 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
mockIsCloud.value = false
})
const createMockAsset = (id: string) => ({
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
id,
name: `asset-${id}`,
size: 100,
created_at: new Date().toISOString(),
tags: ['models'],
tags,
preview_url: `http://test.com/${id}`
})
@@ -751,4 +751,103 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.getAssets('tag:models')).toEqual([])
})
})
describe('hasCategory', () => {
it('should return true for loaded categories', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.hasCategory('checkpoints')).toBe(true)
})
it('should return true for tag-based category when tag: prefix is not used', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
await store.updateModelsForTag('models')
// hasCategory('models') checks for both 'models' and 'tag:models'
expect(store.hasCategory('models')).toBe(true)
})
it('should return false for unloaded categories', () => {
const store = useAssetsStore()
expect(store.hasCategory('checkpoints')).toBe(false)
expect(store.hasCategory('unknown-category')).toBe(false)
})
it('should return false after category is invalidated', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.hasCategory('checkpoints')).toBe(true)
store.invalidateCategory('checkpoints')
expect(store.hasCategory('checkpoints')).toBe(false)
})
})
describe('invalidateModelsForCategory', () => {
it('should clear cache for category and trigger refetch on next access', async () => {
const store = useAssetsStore()
const initialAssets = [createMockAsset('initial-1')]
const refreshedAssets = [
createMockAsset('refreshed-1'),
createMockAsset('refreshed-2')
]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
initialAssets
)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(1)
store.invalidateModelsForCategory('checkpoints')
// Cache should be cleared
expect(store.hasCategory('checkpoints')).toBe(false)
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
// Next fetch should get fresh data
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
refreshedAssets
)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(2)
})
it('should clear tag-based caches', async () => {
const store = useAssetsStore()
const tagAssets = [createMockAsset('tag-1'), createMockAsset('tag-2')]
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(tagAssets)
await store.updateModelsForTag('checkpoints')
await store.updateModelsForTag('models')
expect(store.getAssets('tag:checkpoints')).toHaveLength(2)
expect(store.getAssets('tag:models')).toHaveLength(2)
store.invalidateModelsForCategory('checkpoints')
expect(store.getAssets('tag:checkpoints')).toEqual([])
expect(store.getAssets('tag:models')).toEqual([])
})
it('should handle unknown categories gracefully', () => {
const store = useAssetsStore()
expect(() =>
store.invalidateModelsForCategory('unknown-category')
).not.toThrow()
})
})
})

View File

@@ -375,6 +375,18 @@ export const useAssetsStore = defineStore('assets', () => {
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
@@ -608,17 +620,30 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Clears the category cache and tag-based caches so next access triggers refetch
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
function invalidateModelsForCategory(category: string): void {
invalidateCategory(category)
invalidateCategory(`tag:${category}`)
invalidateCategory('tag:models')
}
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory
}
}
@@ -629,11 +654,13 @@ export const useAssetsStore = defineStore('assets', () => {
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
hasCategory: () => false,
updateModelsForNodeType: async () => {},
invalidateCategory: () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {}
updateAssetTags: async () => {},
invalidateModelsForCategory: () => {}
}
}
@@ -643,11 +670,13 @@ export const useAssetsStore = defineStore('assets', () => {
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -718,12 +747,14 @@ export const useAssetsStore = defineStore('assets', () => {
getError,
hasMore,
hasAssetKey,
hasCategory,
// Model assets - actions
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory
}
})

View File

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

View File

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

View File

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

View File

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

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