Compare commits

...

21 Commits

Author SHA1 Message Date
bymyself
97070cea8a feat: add NightlySurveyPopover component for feature surveys
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c88b1-2d4d-717f-b08c-e2f71c4d80fe
2026-02-22 20:25:46 -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
Alexander Brown
f9317e7078 fix: refresh deps and clear production audit vulnerabilities (#9068)
## Summary
- refresh workspace dependency catalog and lockfile for security and
maintenance updates
- resolve production audit findings (runtime dependencies now report
clean)
- align Tiptap dependencies on v2 and pin `@tiptap/pm` to avoid
mixed-version type issues
- update Storybook preview toolbar config for Storybook 10 typing
(`showName` removed)

## Validation
- `pnpm typecheck` 
- `pnpm lint`  (warnings only)
- `pnpm test:unit` 
- `pnpm audit --prod` 
- `pnpm audit` ⚠️ remaining dev-only transitive advisories
(`minimatch`/`ajv`/`brace-expansion`) in upstream toolchain deps

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9068-fix-refresh-deps-and-clear-production-audit-vulnerabilities-30e6d73d36508122b778ec3ca99d41a4)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 17:44:33 -08:00
Comfy Org PR Bot
79e71a5761 1.41.1 (#9069)
Patch version increment to 1.41.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9069-1-41-1-30f6d73d365081148845cb3024b7987f)
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-21 17:19:38 -08:00
Christian Byrne
3e4d273832 fix: update imagePreview browser tests to use current fixture APIs (#8995)
The browser tests added in #8143 were failing on main because they were
written against stale `ComfyPage` APIs that were refactored in #8510
(merged Feb 3, before #8143 merged Feb 18).

### Changes
- `comfyPage.dragAndDropFile` → `comfyPage.dragDrop.dragAndDropFile`
- `comfyPage.setSetting` → `comfyPage.settings.setSetting`
- `comfyPage.loadWorkflow` → `comfyPage.workflow.loadWorkflow`
- `comfyPage.getNodeRefsByType` → `comfyPage.nodeOps.getNodeRefsByType`
- Fix `comfyPage` type parameter to use `ComfyPage` import
- Remove `test.fixme` since root cause was API mismatch, not test logic

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8995-fix-update-imagePreview-browser-tests-to-use-current-fixture-APIs-30d6d73d365081219c1eda4ea7251160)
by [Unito](https://www.unito.io)
2026-02-21 16:56:25 -08:00
jaeone94
8aa4e36fd5 [refactor] Extract executionErrorStore from executionStore (#9060)
## Summary
Extracts error-related state and logic from `executionStore` into a
dedicated `executionErrorStore` for better separation of concerns.

## Changes
- **New store**: `executionErrorStore` with all error state
(`lastNodeErrors`, `lastExecutionError`, `lastPromptError`), computed
properties (`hasAnyError`, `totalErrorCount`,
`activeGraphErrorNodeIds`), and UI state (`isErrorOverlayOpen`,
`showErrorOverlay`, `dismissErrorOverlay`)
- **Moved util**: `executionIdToNodeLocatorId` extracted to
`graphTraversalUtil`, reusing `traverseSubgraphPath` and accepting
`rootGraph` as parameter
- **Updated consumers**: 12 files updated to import from
`executionErrorStore`
- **Backward compat**: Deprecated getters retained in `ComfyApp` for
extension compatibility

## Review Focus
- Deprecated getters in `app.ts` — can be removed in a future
breaking-change PR once extension authors migrate

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9060-refactor-Extract-executionErrorStore-from-executionStore-30e6d73d36508101973de835ab6b199f)
by [Unito](https://www.unito.io)
2026-02-21 16:51:22 -08:00
Christian Byrne
d9fdb01d9b fix: handle failed global subgraph blueprint loading gracefully (#9063)
## Summary

Fix "Failed to load subgraph blueprints Error: [ASSERT] Workflow content
should be loaded" error occurring on cloud.

## Changes

- **What**: `getGlobalSubgraphData` now throws on API failure instead of
returning empty string, global blueprint data is validated before
loading, and individual global blueprint errors are properly propagated
to the toast/console reporting instead of being silently swallowed.

## Review Focus

Two root causes were fixed:
1. `getGlobalSubgraphData` returned `""` on failure — this empty string
was set as `originalContent`, which is falsy, triggering the assertion
in `ComfyWorkflow.load()`.
2. `loadInstalledBlueprints` used an internal `Promise.allSettled` whose
results were discarded, so individual global blueprint failures never
reached the error reporting in `fetchSubgraphs`.

Fixes COM-15199

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9063-fix-handle-failed-global-subgraph-blueprint-loading-gracefully-30e6d73d3650818d9cc8ecf81cd0264e)
by [Unito](https://www.unito.io)
2026-02-21 16:30:01 -08:00
Alexander Brown
8f48b11f6a fix: resolve missing i18n key warnings (#9064)
## Summary

Fix multiple i18n missing key console warnings by correcting key paths
and adding missing translations.

## Changes

- **What**: 
- `ScrubableNumberInput`: Fixed references to non-existent
`g.ariaLabel.decrement`/`g.ariaLabel.increment` keys → use
`g.decrement`/`g.increment`
- `SidebarIcon`: Replaced `t()` with `st()` (safe translate) to prevent
double-translation when parent components pass pre-translated strings
- `en/main.json`: Added missing `menuLabels.Copy`, `menuLabels.Paste`,
`menuLabels.Select All` keys

## Review Focus

The `SidebarIcon` change from `t()` to `st()` is the key design
decision. `SidebarIcon` receives both i18n keys (from sidebar tabs via
`SideToolbar`) and pre-translated strings (from dedicated sidebar button
components). Using `st()` (which checks `te()` before translating)
handles both cases without warnings.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9064-fix-resolve-missing-i18n-key-warnings-30e6d73d3650816eaad3ce030f9c1d3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 15:26:37 -08:00
Christian Byrne
bb40ffae3c feat: add survey registry for feature survey configurations (#8355)
## Summary
Adds a centralized registry for feature survey configurations.

## Changes
- Add `surveyRegistry.ts` with `FEATURE_SURVEYS` record for survey
configs
- Add helper functions `getSurveyConfig()` and `getEnabledSurveys()`
- Export `FeatureId` type for type-safe feature references

## Part of Nightly Survey System
This is part 3 of a stacked PR chain:
1.  feat/feature-usage-tracker - useFeatureUsageTracker (merged in
#8189)
2.  feat/survey-eligibility - useSurveyEligibility (#8189, merged)
3. **feat/survey-config** - surveyRegistry.ts (this PR)
4. feat/survey-popover - NightlySurveyPopover.vue
5. feat/survey-integration - NightlySurveyController.vue

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8355-feat-add-survey-registry-for-feature-survey-configurations-2f66d73d365081faae6bda0c14c069d9)
by [Unito](https://www.unito.io)
2026-02-21 13:38:06 -08:00
Dante
de131133bd fix: make serverFeatureFlags reactive via Vue ref (#9051) 2026-02-21 18:43:58 +09:00
Benjamin Lu
17f34788dc fix: disable inspect for non-previewable assets (#8989)
## Summary
Prevent text/other assets from opening a blank fullscreen viewer by
restricting inspect/zoom to previewable media kinds.

## Changes
- Add `isPreviewableMediaType` helper in shared `formatUtil`.
- Gate inspect/zoom actions in `AssetsSidebarTab`, `MediaAssetCard`, and
`MediaAssetContextMenu` using an allowlist (`image`, `video`, `audio`,
`3D`).
- Build gallery items from previewable assets only.
- Add unit tests for `isPreviewableMediaType`.

## Why
`ResultGallery` only renders image/video/audio; text/other assets could
previously enter fullscreen with no renderable content.

## Review Focus
- Verify text/other assets no longer show Inspect and do not open
fullscreen.
- Verify image/video/audio behavior is unchanged.
- Verify 3D still opens the 3D viewer dialog.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8989-fix-disable-inspect-for-non-previewable-assets-30c6d73d36508103a9b9da4fe50236ea)
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-21 01:43:14 -08:00
Benjamin Lu
9184f9bce4 fix: support text and misc generated asset states (#8914)
## Summary
Align generated-asset state classification to a single shared source and
implement the missing text/misc states in both card and list previews.

## Changes
- **What**:
- Extended `getMediaTypeFromFilename` in
`packages/shared-frontend-utils` to return `text` and `other`, and
changed unknown/no-extension fallback from `image` to `other`.
- Added text extension handling (`txt`, `md`, `json`, `csv`, `yaml/yml`,
`xml`, `log`) and kept existing media kinds.
- Updated generated-assets UI to use shared media-type detection
directly (removed the local generated-assets classifier).
  - Added text and misc card preview components:
    - `text` -> `icon-[lucide--text]`
    - `other` -> `icon-[lucide--check-check]`
- Updated list-item preview behavior so only `image`/`video` use preview
media URLs; `text`/`other` use icon fallback.
- Widened media kind schema for asset display metadata to include `text`
and `other`.
- **Breaking**: No API breaking changes; internal media kind union
widened for frontend asset display paths.
- **Dependencies**: None.

## Review Focus
- Verify generated text assets render paragraph/text icon state in card
+ list.
- Verify unknown/misc assets consistently render double-check icon state
in card + list.
- Verify existing image/video/audio/3D behavior remains unchanged.

## Screenshots (if applicable)
<img width="282" height="158" alt="image"
src="https://github.com/user-attachments/assets/76cf2d1b-9d34-4c7c-92a1-50bbc55871e5"
/>
<img width="432" height="489" alt="image"
src="https://github.com/user-attachments/assets/024fece3-f241-484d-a37e-11948559ebbc"
/>
<img width="421" height="494" alt="image"
src="https://github.com/user-attachments/assets/ed64ba0c-bf46-4c3b-996e-4bc613ee029e"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8914-fix-support-text-and-misc-generated-asset-states-3096d73d365081f28ca7c32f306e4b50)
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-21 01:27:28 -08:00
Comfy Org PR Bot
ea7bbb744f 1.41.0 (#9059)
Minor version increment to 1.41.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9059-1-41-0-30e6d73d36508103b6cbef6d402f05de)
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-21 01:13:57 -08:00
116 changed files with 5380 additions and 3241 deletions

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:

View File

@@ -90,7 +90,6 @@ const preview: Preview = {
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' }
],
showName: true,
dynamicTitle: true
}
}

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

@@ -37,12 +37,9 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags &&
Object.keys(window.app.api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window.app.api.serverFeatureFlags
const flags = window.app?.api?.serverFeatureFlags?.value
if (flags && Object.keys(flags).length > 0) {
window.__capturedMessages!.serverFeatureFlags = flags
clearInterval(checkInterval)
}
}, 100)
@@ -96,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window.app!.api.serverFeatureFlags
return window.app!.api.serverFeatureFlags.value
})
// Verify we received real feature flags from the backend
@@ -129,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window.app!.api.serverFeatureFlags
window.app!.api.serverFeatureFlags = {
const original = window.app!.api.serverFeatureFlags.value
window.app!.api.serverFeatureFlags.value = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -147,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
// Restore original
window.app!.api.serverFeatureFlags = original
window.app!.api.serverFeatureFlags.value = original
return results
})
@@ -282,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined
) {
window.__appReadiness!.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
@@ -320,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined,
{
timeout: 10000
}
@@ -331,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags
currentFlags: window.app!.api.serverFeatureFlags.value
}
})

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

View File

@@ -1,23 +1,22 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
async function loadImageOnNode(comfyPage: ComfyPage) {
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
@@ -29,6 +28,7 @@ test.describe('Vue Nodes Image Preview', () => {
return imagePreview
}
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('opens mask editor from image preview button', async ({
comfyPage
}) => {
@@ -40,6 +40,7 @@ test.describe('Vue Nodes Image Preview', () => {
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)

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

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.40.10",
"version": "1.41.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -70,13 +70,14 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-table": "^2.10.4",
"@tiptap/extension-table-cell": "^2.10.4",
"@tiptap/extension-table-header": "^2.10.4",
"@tiptap/extension-table-row": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-table": "catalog:",
"@tiptap/extension-table-cell": "catalog:",
"@tiptap/extension-table-header": "catalog:",
"@tiptap/extension-table-row": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@xterm/addon-fit": "^0.10.0",
@@ -93,9 +94,9 @@
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "^0.6.0",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
getMediaTypeFromFilename,
highlightQuery,
isPreviewableMediaType,
truncateFilename
} from './formatUtil'
@@ -56,7 +57,8 @@ describe('formatUtil', () => {
{ filename: 'image.jpeg', expected: 'image' },
{ filename: 'animation.gif', expected: 'image' },
{ filename: 'web.webp', expected: 'image' },
{ filename: 'bitmap.bmp', expected: 'image' }
{ filename: 'bitmap.bmp', expected: 'image' },
{ filename: 'modern.avif', expected: 'image' }
]
it.for(imageTestCases)(
@@ -96,26 +98,37 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
})
})
describe('text files', () => {
it('should identify text file extensions correctly', () => {
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
expect(getMediaTypeFromFilename('data.json')).toBe('text')
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
})
})
describe('edge cases', () => {
it('should handle empty strings', () => {
expect(getMediaTypeFromFilename('')).toBe('image')
expect(getMediaTypeFromFilename('')).toBe('other')
})
it('should handle files without extensions', () => {
expect(getMediaTypeFromFilename('README')).toBe('image')
expect(getMediaTypeFromFilename('README')).toBe('other')
})
it('should handle unknown extensions', () => {
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
expect(getMediaTypeFromFilename('data.json')).toBe('image')
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
})
it('should handle files with multiple dots', () => {
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
})
it('should handle paths with directories', () => {
@@ -124,8 +137,8 @@ describe('formatUtil', () => {
})
it('should handle null and undefined gracefully', () => {
expect(getMediaTypeFromFilename(null)).toBe('image')
expect(getMediaTypeFromFilename(undefined)).toBe('image')
expect(getMediaTypeFromFilename(null)).toBe('other')
expect(getMediaTypeFromFilename(undefined)).toBe('other')
})
it('should handle special characters in filenames', () => {
@@ -184,4 +197,18 @@ describe('formatUtil', () => {
)
})
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
expect(isPreviewableMediaType('other')).toBe(false)
})
})
})

View File

@@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
const IMAGE_EXTENSIONS = [
'png',
'jpg',
'jpeg',
'gif',
'webp',
'bmp',
'avif',
'tif',
'tiff'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',
'markdown',
'json',
'csv',
'yaml',
'yml',
'xml',
'log'
] as const
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
type MediaType = (typeof MEDIA_TYPES)[number]
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
export type MediaType = (typeof MEDIA_TYPES)[number]
// Type guard helper for checking array membership
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
/**
* Truncates a filename while preserving the extension
@@ -543,20 +565,30 @@ export function truncateFilename(
/**
* Determines the media type from a filename's extension (singular form)
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
*/
export function getMediaTypeFromFilename(
filename: string | null | undefined
): MediaType {
if (!filename) return 'image'
if (!filename) return 'other'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
if (!ext) return 'other'
// Type-safe array includes check using type assertion
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
return 'image'
return 'other'
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
}

5063
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,12 @@ catalog:
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.2.6
'@nx/playwright': 22.2.6
'@nx/storybook': 22.2.4
'@nx/vite': 22.2.6
'@nx/eslint': 22.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -27,11 +27,19 @@ catalog:
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tiptap/core': ^2.27.2
'@tiptap/extension-link': ^2.27.2
'@tiptap/extension-table': ^2.27.2
'@tiptap/extension-table-cell': ^2.27.2
'@tiptap/extension-table-header': ^2.27.2
'@tiptap/extension-table-row': ^2.27.2
'@tiptap/pm': 2.27.2
'@tiptap/starter-kit': ^2.27.2
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
@@ -45,7 +53,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
@@ -55,24 +63,26 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.1.9
eslint-plugin-storybook: ^10.2.10
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^5.75.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
nx: 22.5.2
oxfmt: ^0.34.0
oxlint: ^1.49.0
oxlint-tsgolint: ^0.14.2
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
@@ -81,9 +91,9 @@ catalog:
primevue: ^4.2.5
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.1.9
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.1.12
tailwindcss: ^4.2.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
@@ -100,10 +110,10 @@ catalog:
vitest: ^4.0.16
vue: ^3.5.13
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
vue-router: ^4.4.3
vue-tsc: ^3.2.1
vue-tsc: ^3.2.5
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
@@ -130,4 +140,5 @@ onlyBuiltDependencies:
- oxc-resolver
overrides:
'@tiptap/pm': 2.27.2
'@types/eslint': '-'

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

@@ -169,6 +169,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -189,6 +190,7 @@ const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
@@ -262,7 +264,7 @@ const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
const { hasAnyError } = storeToRefs(executionErrorStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)

View File

@@ -6,7 +6,7 @@
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
@@ -51,7 +51,7 @@
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
:aria-label="t('g.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"

View File

@@ -64,17 +64,17 @@ import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
const { t } = useI18n()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const errorCountLabel = computed(() =>
@@ -90,7 +90,7 @@ const isVisible = computed(
)
function dismiss() {
executionStore.dismissErrorOverlay()
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
@@ -100,6 +100,6 @@ function seeErrors() {
}
rightSidePanelStore.openPanel('errors')
executionStore.dismissErrorOverlay()
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -70,7 +70,7 @@
:key="nodeData.id"
:node-data="nodeData"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
executionErrorStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
@@ -170,6 +170,7 @@ import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
@@ -196,6 +197,7 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
@@ -376,7 +378,7 @@ watch(
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
watch(
() => executionStore.lastNodeErrors,
() => executionErrorStore.lastNodeErrors,
(lastNodeErrors) => {
if (!comfyApp.graph) return

View File

@@ -14,7 +14,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -36,12 +36,12 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -98,7 +98,7 @@ type RightSidePanelTabList = Array<{
const hasDirectNodeError = computed(() =>
selectedNodes.value.some((node) =>
executionStore.activeGraphErrorNodeIds.has(String(node.id))
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
)
)
@@ -106,7 +106,7 @@ const hasContainerInternalError = computed(() => {
if (allErrorExecutionIds.value.length === 0) return false
return selectedNodes.value.some((node) => {
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
return executionStore.hasInternalErrorForNode(node.id)
return executionErrorStore.hasInternalErrorForNode(node.id)
})
})

View File

@@ -94,7 +94,7 @@ describe('TabErrors.vue', () => {
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
@@ -118,7 +118,7 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
@@ -143,7 +143,7 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
@@ -167,7 +167,7 @@ describe('TabErrors.vue', () => {
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
@@ -198,7 +198,7 @@ describe('TabErrors.vue', () => {
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',

View File

@@ -3,13 +3,14 @@ import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getRootParentNode
@@ -192,7 +193,7 @@ export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -223,7 +224,7 @@ export function useErrorGroups(
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionStore.allErrorExecutionIds) {
for (const execId of executionErrorStore.allErrorExecutionIds) {
const node = getNodeByExecutionId(app.rootGraph, execId)
if (node) map.set(execId, node)
}
@@ -262,10 +263,10 @@ export function useErrorGroups(
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
return
const error = executionStore.lastPromptError
const error = executionErrorStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
@@ -293,10 +294,10 @@ export function useErrorGroups(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastNodeErrors) return
if (!executionErrorStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
executionErrorStore.lastNodeErrors
)) {
addNodeErrorToGroup(
groupsMap,
@@ -316,9 +317,9 @@ export function useErrorGroups(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastExecutionError) return
if (!executionErrorStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const e = executionErrorStore.lastExecutionError
addNodeErrorToGroup(
groupsMap,
String(e.node_id),

View File

@@ -9,7 +9,7 @@ import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
@@ -62,7 +62,7 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
@@ -110,7 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
const hasDirectError = computed(() => {
if (!targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
return executionErrorStore.activeGraphErrorNodeIds.has(
String(targetNode.value.id)
)
})
const hasContainerInternalError = computed(() => {
@@ -119,7 +121,7 @@ const hasContainerInternalError = computed(() => {
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionStore.hasInternalErrorForNode(targetNode.value.id)
return executionErrorStore.hasInternalErrorForNode(targetNode.value.id)
})
const nodeHasError = computed(() => {

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

@@ -41,7 +41,7 @@
</div>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
st(label, label)
}}</span>
</div>
</Button>
@@ -50,12 +50,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { st } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const {
icon = '',
selected = false,
@@ -83,7 +81,7 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</script>
<style>

View File

@@ -112,6 +112,22 @@ const sampleAssets: AssetItem[] = [
created_at: baseTimestamp,
size: 134217728,
tags: []
},
{
id: 'asset-text-1',
name: 'generation-notes.txt',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 2048,
tags: []
},
{
id: 'asset-other-1',
name: 'workflow-payload.bin',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 4096,
tags: []
}
]
@@ -134,6 +150,16 @@ export const RunningAndGenerated: Story = {
render: renderAssetsSidebarListView
}
export const TextAndMiscGeneratedAssets: Story = {
args: {
assets: sampleAssets.filter((asset) =>
['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix))
),
jobs: []
},
render: renderAssetsSidebarListView
}
function renderAssetsSidebarListView(args: StoryArgs) {
return {
components: { AssetsSidebarListView },

View File

@@ -89,4 +89,21 @@ describe('AssetsSidebarListView', () => {
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
expect(assetListItem?.props('isVideoPreview')).toBe(true)
})
it('uses icon fallback for text assets even when preview_url exists', () => {
const textAsset = {
...buildAsset('text-asset', 'notes.txt'),
preview_url: '/api/view/notes.txt',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(textAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
})
})

View File

@@ -43,7 +43,7 @@
item.isChild && 'pl-6'
)
"
:preview-url="item.asset.preview_url"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
@@ -142,6 +142,14 @@ function isVideoAsset(asset: AssetItem): boolean {
return getAssetMediaType(asset) === 'video'
}
function getAssetPreviewUrl(asset: AssetItem): string {
const mediaType = getAssetMediaType(asset)
if (mediaType === 'image' || mediaType === 'video') {
return asset.preview_url || ''
}
return ''
}
function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {

View File

@@ -204,13 +204,22 @@ import {
} from '@vueuse/core'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
@@ -235,7 +244,11 @@ import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
formatDuration,
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
@@ -396,6 +409,12 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
)
)
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
const isBulkMode = computed(
@@ -421,12 +440,10 @@ watch(visibleAssets, (newAssets) => {
// so selection stays consistent with what this view can act on.
reconcileSelection(newAssets)
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
const newIndex = previewableVisibleAssets.value.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
)
if (newIndex !== -1) {
galleryActiveIndex.value = newIndex
}
galleryActiveIndex.value = newIndex
}
})
@@ -437,7 +454,7 @@ watch(galleryActiveIndex, (index) => {
})
const galleryItems = computed(() => {
return visibleAssets.value.map((asset) => {
return previewableVisibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
@@ -543,6 +560,9 @@ const handleDeleteSelected = async () => {
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (!isPreviewableMediaType(mediaType)) {
return
}
if (mediaType === '3D') {
const dialogStore = useDialogStore()
@@ -562,7 +582,9 @@ const handleZoomClick = (asset: AssetItem) => {
}
currentGalleryAssetId.value = asset.id
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
const index = previewableVisibleAssets.value.findIndex(
(a) => a.id === asset.id
)
if (index !== -1) {
galleryActiveIndex.value = index
}

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,11 @@ 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 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 +139,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',
{
@@ -324,11 +338,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

@@ -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

@@ -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,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

@@ -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

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "تسجيل الخروج",
"success": "تم تسجيل الخروج بنجاح",
"successDetail": "لقد تم تسجيل خروجك من حسابك."
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
"unsavedChangesTitle": "تغييرات غير محفوظة"
},
"signup": {
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",

View File

@@ -2529,6 +2529,14 @@
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"nightlySurvey": {
"title": "Help Us Improve",
"description": "You've been using this feature. Would you take a moment to share your feedback?",
"accept": "Sure, I'll help!",
"notNow": "Not Now",
"dontAskAgain": "Don't Ask Again",
"loadError": "Failed to load survey. Please try again later."
},
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Cerrar sesión",
"success": "Sesión cerrada correctamente",
"successDetail": "Has cerrado sesión en tu cuenta."
"successDetail": "Has cerrado sesión en tu cuenta.",
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán al cerrar sesión. ¿Quieres continuar?",
"unsavedChangesTitle": "Cambios no guardados"
},
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "خروج",
"success": "خروج با موفقیت انجام شد",
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید."
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
"unsavedChangesMessage": "شما تغییرات ذخیره‌نشده‌ای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
"unsavedChangesTitle": "تغییرات ذخیره‌نشده"
},
"signup": {
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Se déconnecter",
"success": "Déconnexion réussie",
"successDetail": "Vous avez été déconnecté de votre compte."
"successDetail": "Vous avez été déconnecté de votre compte.",
"unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous vous déconnectez. Voulez-vous continuer ?",
"unsavedChangesTitle": "Modifications non enregistrées"
},
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "ログアウト",
"success": "正常にサインアウトしました",
"successDetail": "アカウントからサインアウトしました。"
"successDetail": "アカウントからサインアウトしました。",
"unsavedChangesMessage": "サインアウトすると未保存の変更が失われます。続行しますか?",
"unsavedChangesTitle": "未保存の変更"
},
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "로그아웃",
"success": "성공적으로 로그아웃되었습니다",
"successDetail": "계정에서 로그아웃되었습니다."
"successDetail": "계정에서 로그아웃되었습니다.",
"unsavedChangesMessage": "저장되지 않은 변경 사항이 있습니다. 로그아웃하면 변경 사항이 사라집니다. 계속하시겠습니까?",
"unsavedChangesTitle": "저장되지 않은 변경 사항"
},
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Sair",
"success": "Logout realizado com sucesso",
"successDetail": "Você saiu da sua conta."
"successDetail": "Você saiu da sua conta.",
"unsavedChangesMessage": "Você tem alterações não salvas que serão perdidas ao sair. Deseja continuar?",
"unsavedChangesTitle": "Alterações não salvas"
},
"signup": {
"alreadyHaveAccount": "Já tem uma conta?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Выйти",
"success": "Вы успешно вышли из системы",
"successDetail": "Вы вышли из своей учетной записи."
"successDetail": "Вы вышли из своей учетной записи.",
"unsavedChangesMessage": "У вас есть несохранённые изменения, которые будут потеряны при выходе из системы. Вы хотите продолжить?",
"unsavedChangesTitle": "Несохранённые изменения"
},
"signup": {
"alreadyHaveAccount": "Уже есть аккаунт?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": ıkış Yap",
"success": "Başarıyla çıkış yapıldı",
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız."
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız.",
"unsavedChangesMessage": "Oturumu kapattığınızda kaydedilmemiş değişiklikleriniz kaybolacak. Devam etmek istiyor musunuz?",
"unsavedChangesTitle": "Kaydedilmemiş Değişiklikler"
},
"signup": {
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "登出",
"success": "成功登出",
"successDetail": "您已成功登出您的帳戶。"
"successDetail": "您已成功登出您的帳戶。",
"unsavedChangesMessage": "您有尚未儲存的變更,登出後這些變更將會遺失。您確定要繼續嗎?",
"unsavedChangesTitle": "未儲存的變更"
},
"signup": {
"alreadyHaveAccount": "已經有帳戶?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "退出登录",
"success": "成功退出登录",
"successDetail": "您已成功退出账户。"
"successDetail": "您已成功退出账户。",
"unsavedChangesMessage": "您有未保存的更改,注销后这些更改将会丢失。是否继续?",
"unsavedChangesTitle": "未保存的更改"
},
"signup": {
"alreadyHaveAccount": "已经有账户了?",

View File

@@ -141,6 +141,40 @@ export const AudioAsset: Story = {
}
}
export const TextAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
asset: {
...sampleAsset,
id: 'asset-5',
name: 'generation-notes.txt',
size: 2048,
preview_url: SAMPLE_MEDIA.image1
}
}
}
export const OtherAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
asset: {
...sampleAsset,
id: 'asset-6',
name: 'workflow-payload.bin',
size: 8192,
preview_url: SAMPLE_MEDIA.image1
}
}
}
export const LoadingState: Story = {
decorators: [
() => ({

View File

@@ -36,7 +36,7 @@
<!-- Content based on asset type -->
<component
:is="getTopComponent(fileKind)"
:is="getTopComponent(previewKind)"
v-else-if="asset && adaptedAsset"
:asset="adaptedAsset"
:context="{ type: assetType }"
@@ -59,6 +59,7 @@
>
<IconGroup background-class="bg-white">
<Button
v-if="canInspect"
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@@ -141,7 +142,8 @@ import {
formatDuration,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -152,17 +154,21 @@ import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
const mediaComponents = {
top: {
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
'3D': defineAsyncComponent(() => import('./Media3DTop.vue')),
text: defineAsyncComponent(() => import('./MediaTextTop.vue')),
other: defineAsyncComponent(() => import('./MediaOtherTop.vue'))
}
}
function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
function getTopComponent(kind: PreviewKind) {
return mediaComponents.top[kind] || mediaComponents.top.other
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
@@ -206,9 +212,15 @@ const assetType = computed(() => {
// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
return getMediaTypeFromFilename(asset?.name || '')
})
const previewKind = computed((): PreviewKind => {
return getMediaTypeFromFilename(asset?.name || '')
})
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
@@ -270,7 +282,7 @@ const showActionsOverlay = computed(() => {
})
const handleZoomClick = () => {
if (asset) {
if (asset && canInspect.value) {
emit('zoom', asset)
}
}

View File

@@ -39,6 +39,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -206,8 +207,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
// Individual mode: Show all menu options
// Inspect (if not 3D)
if (fileKind !== '3D') {
// Inspect
if (isPreviewableMediaType(fileKind)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',

View File

@@ -0,0 +1,9 @@
<template>
<div class="relative size-full overflow-hidden rounded">
<div
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
>
<i class="icon-[lucide--check-check] text-3xl text-base-foreground" />
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="relative size-full overflow-hidden rounded">
<div
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
>
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
</div>
</div>
</template>

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

@@ -3,7 +3,14 @@ import { z } from 'zod'
import { assetItemSchema } from './assetSchema'
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
const zMediaKindSchema = z.enum([
'video',
'audio',
'image',
'3D',
'text',
'other'
])
export type MediaKind = z.infer<typeof zMediaKindSchema>
const zDimensionsSchema = z.object({

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { iconForMediaType } from './mediaIconUtil'
describe('iconForMediaType', () => {
it('maps text and misc fallbacks correctly', () => {
expect(iconForMediaType('text')).toBe('icon-[lucide--text]')
expect(iconForMediaType('other')).toBe('icon-[lucide--check-check]')
})
it('preserves existing mappings for core media types', () => {
expect(iconForMediaType('image')).toBe('icon-[lucide--image]')
expect(iconForMediaType('video')).toBe('icon-[lucide--video]')
expect(iconForMediaType('audio')).toBe('icon-[lucide--music]')
expect(iconForMediaType('3D')).toBe('icon-[lucide--box]')
})
})

View File

@@ -8,6 +8,10 @@ export function iconForMediaType(mediaType: MediaKind): string {
return 'icon-[lucide--music]'
case '3D':
return 'icon-[lucide--box]'
case 'text':
return 'icon-[lucide--text]'
case 'other':
return 'icon-[lucide--check-check]'
default:
return 'icon-[lucide--image]'
}

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,194 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const POPOVER_SELECTOR = '[data-testid="nightly-survey-popover"]'
const mockIsNightly = vi.hoisted(() => ({ value: true }))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isNightly() {
return mockIsNightly.value
},
get isCloud() {
return mockIsCloud.value
},
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
describe('NightlySurveyPopover', () => {
const defaultConfig = {
featureId: 'test-feature',
typeformId: 'abc123',
triggerThreshold: 3,
delayMs: 100,
enabled: true
}
function setFeatureUsage(featureId: string, useCount: number) {
const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}')
existing[featureId] = {
useCount,
firstUsed: Date.now() - 1000,
lastUsed: Date.now()
}
localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing))
}
beforeEach(() => {
localStorage.clear()
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
mockIsNightly.value = true
mockIsCloud.value = false
mockIsDesktop.value = false
})
afterEach(() => {
localStorage.clear()
vi.useRealTimers()
document.body.innerHTML = ''
})
async function mountComponent(config = defaultConfig) {
const { default: NightlySurveyPopover } =
await import('./NightlySurveyPopover.vue')
return mount(NightlySurveyPopover, {
props: { config },
global: {
stubs: {
Teleport: true
}
},
attachTo: document.body
})
}
describe('visibility', () => {
it('shows popover after delay when eligible', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
})
it('does not show when not eligible', async () => {
setFeatureUsage('test-feature', 1)
const wrapper = await mountComponent()
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
it('does not show on cloud', async () => {
mockIsCloud.value = true
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
})
describe('user actions', () => {
it('emits shown event when displayed', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.emitted('shown')).toHaveLength(1)
})
it('emits dismissed when close button clicked', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const closeButton = wrapper.find('[aria-label="g.close"]')
await closeButton.trigger('click')
expect(wrapper.emitted('dismissed')).toHaveLength(1)
})
it('emits optedOut when opt out button clicked', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const buttons = wrapper.findAll('button')
const optOutButton = buttons.find((b) =>
b.text().includes('nightlySurvey.dontAskAgain')
)
expect(optOutButton).toBeDefined()
await optOutButton!.trigger('click')
expect(wrapper.emitted('optedOut')).toHaveLength(1)
})
})
describe('config', () => {
it('uses custom delay from config', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent({
...defaultConfig,
delayMs: 500
})
await nextTick()
await vi.advanceTimersByTimeAsync(400)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
})
it('does not show when config is disabled', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent({
...defaultConfig,
enabled: false
})
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
import { useSurveyEligibility } from './useSurveyEligibility'
const { config } = defineProps<{
config: FeatureSurveyConfig
}>()
const emit = defineEmits<{
shown: []
dismissed: []
optedOut: []
}>()
const { t } = useI18n()
const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
() => config
)
const TYPEFORM_SRC = 'https://embed.typeform.com/next/embed.js'
const isVisible = ref(false)
const typeformError = ref(false)
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
let showTimeout: ReturnType<typeof setTimeout> | null = null
const isValidTypeformId = computed(() =>
/^[A-Za-z0-9]+$/.test(config.typeformId)
)
const typeformId = computed(() =>
isValidTypeformId.value ? config.typeformId : ''
)
watch(
isEligible,
(eligible) => {
if (!eligible) {
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
return
}
if (isVisible.value || showTimeout) return
showTimeout = setTimeout(() => {
showTimeout = null
isVisible.value = true
emit('shown')
}, delayMs.value)
},
{ immediate: true }
)
onUnmounted(() => {
if (showTimeout) {
clearTimeout(showTimeout)
}
})
whenever(typeformRef, () => {
if (document.querySelector(`script[src="${TYPEFORM_SRC}"]`)) return
const scriptEl = document.createElement('script')
scriptEl.src = TYPEFORM_SRC
scriptEl.async = true
scriptEl.onerror = () => {
typeformError.value = true
}
document.head.appendChild(scriptEl)
})
function handleAccept() {
markSurveyShown()
}
function handleDismiss() {
isVisible.value = false
emit('dismissed')
}
function handleOptOut() {
optOut()
isVisible.value = false
emit('optedOut')
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transition duration-300 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="translate-x-full opacity-0"
>
<div
v-if="isVisible"
data-testid="nightly-survey-popover"
class="fixed bottom-4 right-4 z-[10000] w-80 rounded-lg border border-border-subtle bg-base-background p-4 shadow-lg"
>
<div class="mb-3 flex items-start justify-between">
<h3 class="text-sm font-medium text-text-primary">
{{ t('nightlySurvey.title') }}
</h3>
<button
class="text-text-muted hover:text-text-primary"
:aria-label="t('g.close')"
@click="handleDismiss"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<p class="mb-4 text-sm text-text-secondary">
{{ t('nightlySurvey.description') }}
</p>
<div v-if="typeformError" class="mb-4 text-sm text-danger">
{{ t('nightlySurvey.loadError') }}
</div>
<div
v-show="!typeformError && isValidTypeformId"
ref="typeformRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
class="min-h-[300px]"
/>
<div class="mt-4 flex flex-col gap-2">
<Button variant="primary" class="w-full" @click="handleAccept">
{{ t('nightlySurvey.accept') }}
</Button>
<div class="flex gap-2">
<Button
variant="textonly"
class="flex-1 text-xs"
@click="handleDismiss"
>
{{ t('nightlySurvey.notNow') }}
</Button>
<Button
variant="muted-textonly"
class="flex-1 text-xs"
@click="handleOptOut"
>
{{ t('nightlySurvey.dontAskAgain') }}
</Button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
import {
FEATURE_SURVEYS,
getEnabledSurveys,
getSurveyConfig
} from './surveyRegistry'
const TEST_FEATURE_ID = '__test-feature__'
const TEST_CONFIG: FeatureSurveyConfig = {
featureId: TEST_FEATURE_ID,
typeformId: 'test-form-123',
triggerThreshold: 5,
delayMs: 3000,
enabled: true
}
describe('surveyRegistry', () => {
let originalEntries: Record<string, FeatureSurveyConfig>
beforeEach(() => {
originalEntries = { ...FEATURE_SURVEYS }
FEATURE_SURVEYS[TEST_FEATURE_ID] = TEST_CONFIG
})
afterEach(() => {
for (const key of Object.keys(FEATURE_SURVEYS)) {
delete FEATURE_SURVEYS[key]
}
Object.assign(FEATURE_SURVEYS, originalEntries)
})
describe('getSurveyConfig', () => {
it('returns undefined for unknown feature', () => {
expect(getSurveyConfig('nonexistent-feature')).toBeUndefined()
})
it('returns config for registered feature', () => {
const config = getSurveyConfig(TEST_FEATURE_ID)
expect(config).toEqual(TEST_CONFIG)
})
})
describe('getEnabledSurveys', () => {
it('includes surveys with enabled: true', () => {
const enabled = getEnabledSurveys()
expect(enabled).toContainEqual(TEST_CONFIG)
})
it('includes surveys where enabled is undefined', () => {
const implicitlyEnabled: FeatureSurveyConfig = {
featureId: '__implicit__',
typeformId: 'form-456'
}
FEATURE_SURVEYS['__implicit__'] = implicitlyEnabled
const enabled = getEnabledSurveys()
expect(enabled).toContainEqual(implicitlyEnabled)
})
it('excludes surveys with enabled: false', () => {
const disabledConfig: FeatureSurveyConfig = {
featureId: '__disabled__',
typeformId: 'form-789',
enabled: false
}
FEATURE_SURVEYS['__disabled__'] = disabledConfig
const enabled = getEnabledSurveys()
expect(enabled).not.toContainEqual(disabledConfig)
})
})
})

View File

@@ -0,0 +1,19 @@
import type { FeatureSurveyConfig } from './useSurveyEligibility'
/**
* Registry of all feature surveys.
* Add new surveys here when targeting specific features for feedback.
*/
export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {}
export function getSurveyConfig(
featureId: string
): FeatureSurveyConfig | undefined {
return FEATURE_SURVEYS[featureId]
}
export function getEnabledSurveys(): FeatureSurveyConfig[] {
return Object.values(FEATURE_SURVEYS).filter(
(config) => config.enabled !== false
)
}

View File

@@ -6,7 +6,7 @@ import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
interface FeatureSurveyConfig {
export interface FeatureSurveyConfig {
/** Feature identifier. Must remain static after initialization. */
featureId: string
typeformId: string

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

@@ -106,8 +106,13 @@ export class ComfyWorkflow extends UserFile {
await super.load({ force })
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
if (!this.originalContent) {
throw new Error('[ASSERT] Workflow content should be loaded')
if (this.originalContent == null) {
throw new Error(
`[ASSERT] Workflow content should be loaded for '${this.path}'`
)
}
if (this.originalContent.trim().length === 0) {
throw new Error(`Workflow content is empty for '${this.path}'`)
}
const initialState = JSON.parse(this.originalContent)

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

@@ -22,6 +22,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -29,6 +30,7 @@ import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
@@ -79,7 +81,7 @@ function nodeToNodeData(node: LGraphNode) {
return {
...nodeData,
//note lastNodeErrors uses exeuctionid, node.id is execution for root
hasErrors: !!executionStore.lastNodeErrors?.[node.id],
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,

View File

@@ -246,7 +246,7 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isTransparent } from '@/utils/colorUtil'
@@ -293,9 +293,9 @@ const isSelected = computed(() => {
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const hasExecutionError = computed(
() => executionStore.lastExecutionErrorNodeId === nodeData.id
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
const hasAnyError = computed((): boolean => {
@@ -303,7 +303,7 @@ const hasAnyError = computed((): boolean => {
hasExecutionError.value ||
nodeData.hasErrors ||
error ||
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
(executionErrorStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
)
})

View File

@@ -101,7 +101,7 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -116,7 +116,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { bringNodeToFront } = useNodeZIndex()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -170,7 +170,7 @@ interface ProcessedWidget {
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const nodeErrors = executionStore.lastNodeErrors?.[nodeData.id ?? '']
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeData.id ?? '']
const nodeId = nodeData.id
const { widgets } = nodeData

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'
@@ -28,10 +26,6 @@ function mountComponent(
placeholder?: string
) {
return mount(WidgetTextarea, {
global: {
plugins: [PrimeVue],
components: { Textarea }
},
props: {
widget,
modelValue,

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

@@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { api } from '@/scripts/api'
@@ -38,7 +39,7 @@ describe('API Feature Flags', () => {
})
// Reset API state
api.serverFeatureFlags = {}
api.serverFeatureFlags.value = {}
// Mock getClientFeatureFlags to return test feature flags
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
@@ -102,7 +103,7 @@ describe('API Feature Flags', () => {
await initPromise
// Check that server features were stored
expect(api.serverFeatureFlags).toEqual({
expect(api.serverFeatureFlags.value).toEqual({
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
@@ -144,14 +145,14 @@ describe('API Feature Flags', () => {
await initPromise
// Server features should remain empty
expect(api.serverFeatureFlags).toEqual({})
expect(api.serverFeatureFlags.value).toEqual({})
})
})
describe('Feature checking methods', () => {
beforeEach(() => {
// Set up some test features
api.serverFeatureFlags = {
api.serverFeatureFlags.value = {
supports_preview_metadata: true,
async_execution: false,
capabilities: ['isolated_nodes', 'dynamic_models']
@@ -208,12 +209,61 @@ describe('API Feature Flags', () => {
describe('Integration with preview messages', () => {
it('should affect preview message handling based on feature support', () => {
// Test with metadata support
api.serverFeatureFlags = { supports_preview_metadata: true }
api.serverFeatureFlags.value = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Test without metadata support
api.serverFeatureFlags = {}
api.serverFeatureFlags.value = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
})
describe('Reactivity', () => {
it('should trigger computed updates when serverFeatureFlags changes', async () => {
api.serverFeatureFlags.value = {}
const flag = computed(() =>
api.getServerFeature('supports_preview_metadata', false)
)
expect(flag.value).toBe(false)
api.serverFeatureFlags.value = { supports_preview_metadata: true }
await nextTick()
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

@@ -2,8 +2,10 @@ import { promiseTimeout, until } from '@vueuse/core'
import axios from 'axios'
import { get } from 'es-toolkit/compat'
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
@@ -340,7 +342,7 @@ export class ComfyApi extends EventTarget {
/**
* Feature flags received from the backend server.
*/
serverFeatureFlags: Record<string, unknown> = {}
serverFeatureFlags = ref<Record<string, unknown>>({})
/**
* The auth token for the comfy org account if the user is logged in.
@@ -695,10 +697,10 @@ export class ComfyApi extends EventTarget {
break
case 'feature_flags':
// Store server feature flags
this.serverFeatureFlags = msg.data
this.serverFeatureFlags.value = msg.data
console.log(
'Server feature flags received:',
this.serverFeatureFlags
this.serverFeatureFlags.value
)
this.dispatchCustomEvent('feature_flags', msg.data)
break
@@ -1180,9 +1182,16 @@ export class ComfyApi extends EventTarget {
async getGlobalSubgraphData(id: string): Promise<string> {
const resp = await api.fetchApi('/global_subgraphs/' + id)
if (resp.status !== 200) return ''
if (resp.status !== 200) {
throw new Error(
`Failed to fetch global subgraph '${id}': ${resp.status} ${resp.statusText}`
)
}
const subgraph: GlobalSubgraphData = await resp.json()
return subgraph?.data ?? ''
if (!subgraph?.data) {
throw new Error(`Global subgraph '${id}' returned empty data`)
}
return subgraph.data as string
}
async getGlobalSubgraphs(): Promise<Record<string, GlobalSubgraphData>> {
const resp = await api.fetchApi('/global_subgraphs')
@@ -1291,7 +1300,9 @@ export class ComfyApi extends EventTarget {
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
return get(this.serverFeatureFlags, featureName) === true
const override = getDevOverride<boolean>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName) === true
}
/**
@@ -1301,7 +1312,9 @@ export class ComfyApi extends EventTarget {
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
return get(this.serverFeatureFlags, featureName, defaultValue) as T
const override = getDevOverride<T>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
}
/**
@@ -1309,7 +1322,7 @@ export class ComfyApi extends EventTarget {
* @returns Copy of all server feature flags
*/
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags }
return { ...this.serverFeatureFlags.value }
}
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {

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