Compare commits

...

30 Commits

Author SHA1 Message Date
Alexander Brown
d6d540c6c0 refactor: replace inline skeletonGridStyle with Tailwind utility class
Amp-Thread-ID: https://ampcode.com/threads/T-019c739d-19f4-76ff-9dad-568b4922bdab
Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 17:59:57 -08:00
Alexander Brown
611a1e2ece fix: address review feedback for folder loading state
- Gate folderLoading by isInFolderView so it only affects UI in folder view
- Surface useAsyncState errors via toast and exit folder on failure
- Use design token (bg-secondary-background) in Skeleton component
- Add i18n keys for folder view error messages

Amp-Thread-ID: https://ampcode.com/threads/T-019c71fd-6654-7410-a3e1-e6e9915c9a88
Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 17:55:06 -08:00
Alexander Brown
9c313c66a3 fix: show skeleton loading state in asset folder view
Replace empty "No generated files found" flash with skeleton cards
when opening a multi-output job's folder view. The async resolution
of outputs via `resolveOutputAssetItems` now tracks loading state
using VueUse's `useAsyncState`, and the skeleton count matches the
expected output count from metadata.

- Add shadcn/vue Skeleton component
- Replace ProgressSpinner with skeleton grid matching asset card layout
- Use `useAsyncState` for folder asset resolution
- Wire `folderLoading` into `showLoadingState` / `showEmptyState`

Amp-Thread-ID: https://ampcode.com/threads/T-019c71fd-6654-7410-a3e1-e6e9915c9a88
Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 17:55:05 -08:00
Alexander Brown
8099cce232 feat: bulk asset export with ZIP download (#8712)
## Summary

Adds bulk asset export with ZIP download for cloud users. When selecting
2+ assets and clicking download, the frontend now requests a server-side
ZIP export instead of triggering individual file downloads.

## Changes

### New files
- **`AssetExportProgressDialog.vue`** — HoneyToast-based progress dialog
showing per-job export status with progress percentages, error
indicators, and a manual re-download button for completed exports
- **`assetExportStore.ts`** — Pinia store that tracks export jobs,
handles `asset_export` WebSocket events for real-time progress, polls
stale exports via the task API as a fallback, and auto-triggers ZIP
download on completion

### Modified files
- **`useMediaAssetActions.ts`** — `downloadMultipleAssets` now routes to
ZIP export (via `createAssetExport`) in cloud mode when 2+ assets are
selected; single assets and OSS mode still use direct download
- **`assetService.ts`** — Added `createAssetExport()` and
`getExportDownloadUrl()` endpoints
- **`apiSchema.ts`** — Added `AssetExportWsMessage` type for the
WebSocket event
- **`api.ts`** — Wired up `asset_export` WebSocket event
- **`GraphView.vue`** — Mounted `AssetExportProgressDialog`
- **`main.json`** — Added i18n keys for export toast UI

## How it works

1. User selects multiple assets and clicks download
2. Frontend calls `POST /assets/export` with asset/job IDs
3. Backend creates a ZIP task and streams progress via `asset_export`
WebSocket events
4. `AssetExportProgressDialog` shows real-time progress
5. On completion, the ZIP is auto-downloaded via a presigned URL from
`GET /assets/exports/{name}`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8712-feat-bulk-asset-export-with-ZIP-download-3006d73d365081839ec3dd3e7b0d3b77)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-18 16:36:59 -08:00
Christian Byrne
27d4a34435 fix: sync node.imgs for legacy context menu in Vue Nodes mode (#8143)
## Summary

Fixes missing "Copy Image", "Open Image", "Save Image", and "Open in
Mask Editor" context menu options on SaveImage nodes when Vue Nodes mode
is enabled.

## Changes

- Add `syncLegacyNodeImgs` store method to sync loaded image elements to
`node.imgs`
- Call sync on image load in ImagePreview component
- Simplify mask editor handling to call composable directly

## Technical Details

- Only runs when `vueNodesMode` is enabled (no impact on legacy mode)
- Reuses already-loaded `<img>` element from Vue (no duplicate network
requests)
- Store owns the sync logic, component just hands off the element

Supersedes #7416

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8143-fix-sync-node-imgs-for-legacy-context-menu-in-Vue-Nodes-mode-2ec6d73d365081c59d42cd1722779b61)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 16:34:45 -08:00
Terry Jia
e1e560403e feat: reuse WidgetInputNumberInput for BoundingBox numeric inputs (#8895)
## Summary
Make WidgetInputNumberInput usable without the widget system by making
the widget prop optional and adding simple min/max/step/disabled props.

BoundingBox now uses this component instead of a separate
ScrubableNumberInput

## Screenshots (if applicable)
<img width="828" height="1393" alt="image"
src="https://github.com/user-attachments/assets/68e012cf-baae-4a53-b4f8-70917cf05554"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8895-feat-add-scrub-drag-to-adjust-to-BoundingBox-numeric-inputs-3086d73d36508194b4b5e9bc823b34d1)
by [Unito](https://www.unito.io)
2026-02-18 16:32:03 -08:00
Johnpaul Chiwetelu
aff0ebad50 fix: reload template workflows when locale changes (#8963)
## Summary
- Templates were fetched once with the initial locale and cached behind
an `isLoaded` guard. Changing language updated i18n UI strings but never
re-fetched locale-specific template data (names, descriptions) from the
server.
- Extracts core template fetching into `fetchCoreTemplates()` and adds a
`watch` on `i18n.global.locale` to re-fetch when the language changes.

## Test plan
- [ ] Open the templates panel
- [ ] Change language in settings (e.g. English -> French)
- [ ] Verify template names and descriptions update without a page
refresh
- [ ] Verify initial load still works correctly

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8963-fix-reload-template-workflows-when-locale-changes-30b6d73d36508178a2f8c2c8947b5955)
by [Unito](https://www.unito.io)
2026-02-18 15:59:37 -08:00
Christian Byrne
44dc208339 fix: app mode gets stale assets history (#8918)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8918-app-mode-fix-stale-assets-history-3096d73d36508114b81df071d7289c23)
by [Unito](https://www.unito.io)
2026-02-18 15:29:00 -08:00
Christian Byrne
388c21a88d fix: lint-format CI failing on fork PRs due to missing secret (#8948)
## Summary

Fall back to `github.token` when `PR_GH_TOKEN` secret is unavailable on
fork PRs, fixing checkout failure.

## Changes

- **What**: The `ci-lint-format` workflow uses `secrets.PR_GH_TOKEN` for
checkout. Repository secrets are not available to workflows triggered by
fork PRs, causing `Input required and not supplied: token` errors (e.g.
[run
22124451916](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/22124451916)).
The fix uses `github.token` as a fallback for fork PRs — this is always
available with read-only access, which is sufficient since the workflow
already skips commit/push for forks.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8948-fix-lint-format-CI-failing-on-fork-PRs-due-to-missing-secret-30b6d73d365081dfb78cf05362a6653c)
by [Unito](https://www.unito.io)
2026-02-18 15:28:48 -08:00
Alexander Brown
b28f46d237 Regenerate images (#8959)
```



```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8959-Regenerate-image-30b6d73d3650811e9116cb7c6c9002cb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-18 11:28:47 -08:00
Johnpaul Chiwetelu
2900e5e52e fix: asset browser filters stick when navigating categories (#8945)
## Summary

File format and base model filters in the asset browser persisted when
navigating to categories that don't contain matching assets, showing
empty results with no way to clear the filter.

## Changes

- **What**: Apply the same scope-aware filtering pattern from the
template selector dialog. Selected filters that don't exist in the
current category become inactive (excluded from filtering) but are
preserved so they reactivate when navigating back. Uses writable
computeds in `AssetFilterBar` (matching
`WorkflowTemplateSelectorDialog`) and active filter intersection in
`useAssetBrowser` (matching `useTemplateFiltering`).

## Before



https://github.com/user-attachments/assets/5c61e844-7ea0-489c-9c44-e0864dc916bc





## After


https://github.com/user-attachments/assets/8372e174-107c-41e2-b8cf-b7ef59fe741b



## Review Focus

The pattern mirrors `selectedModelObjects`/`selectedUseCaseObjects` in
`WorkflowTemplateSelectorDialog.vue` and `activeModels`/`activeUseCases`
in `useTemplateFiltering.ts`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8945-fix-asset-browser-filters-stick-when-navigating-categories-30b6d73d365081609ac5c3982a1a03fc)
by [Unito](https://www.unito.io)
2026-02-18 12:55:05 +01:00
Comfy Org PR Bot
07e64a7f44 1.40.7 (#8944)
Patch version increment to 1.40.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8944-1-40-7-30b6d73d365081679aa8cba674700980)
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-18 02:23:34 -08:00
Benjamin Lu
34e21f3267 fix(queue): address follow-up review comments from #8740 (#8880)
## Summary
Address follow-up review feedback from #8740 by consolidating
completed-banner thumbnail fields and tightening queue count
sanitization.

## Changes
- **What**:
- Consolidated completed notification thumbnail data to `thumbnailUrls`
only by removing legacy `thumbnailUrl` support from the queue banner
notification type and renderer.
- Updated queue notification banner stories to use `thumbnailUrls`
consistently.
- Simplified `sanitizeCount` to treat any non-positive or non-number
value as the fallback count (`1`).

## Review Focus
Confirm the completed notification payload shape is now consistently
`thumbnailUrls` and no consumer relies on `thumbnailUrl`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8880-fix-queue-address-follow-up-review-comments-from-8740-3076d73d365081719a70d860691c5f05)
by [Unito](https://www.unito.io)
2026-02-17 21:26:02 -08:00
jaeone94
1349fffbce Feat/errors tab panel (#8807)
## Summary

Add a dedicated **Errors tab** to the Right Side Panel that displays
prompt-level, node validation, and runtime execution errors in a
unified, searchable, grouped view — replacing the need to rely solely on
modal dialogs for error inspection.

## Changes

- **What**:
  - **New components** (`errors/` directory):
- `TabErrors.vue` — Main error tab with search, grouping by class type,
and canvas navigation (locate node / enter subgraph).
- `ErrorNodeCard.vue` — Renders a single error card with node ID badge,
title, action buttons, and error details.
- `types.ts` — Shared type definitions (`ErrorItem`, `ErrorCardData`,
`ErrorGroup`).
- **`executionStore.ts`** — Added `PromptError` interface,
`lastPromptError` ref and `hasAnyError` computed getter. Clears
`lastPromptError` alongside existing error state on execution start and
graph clear.
- **`rightSidePanelStore.ts`** — Registered `'errors'` as a valid tab
value.
- **`app.ts`** — On prompt submission failure (`PromptExecutionError`),
stores prompt-level errors (when no node errors exist) into
`lastPromptError`. On both runtime execution error and prompt error,
deselects all nodes and opens the errors tab automatically.
- **`RightSidePanel.vue`** — Shows the `'errors'` tab (with ⚠ icon) when
errors exist and no node is selected. Routes to `TabErrors` component.
- **`TopMenuSection.vue`** — Highlights the action bar with a red border
when any error exists, using `hasAnyError`.
- **`SectionWidgets.vue`** — Detects per-node errors by matching
execution IDs to graph node IDs. Shows an error icon (⚠) and "See Error"
button that navigates to the errors tab.
- **`en/main.json`** — Added i18n keys: `errors`, `noErrors`,
`enterSubgraph`, `seeError`, `promptErrors.*`, and `errorHelp*`.
- **Testing**: 6 unit tests (`TabErrors.test.ts`) covering
prompt/node/runtime errors, search filtering, and clipboard copy.
- **Storybook**: 7 stories (`ErrorNodeCard.stories.ts`) for badge
visibility, subgraph buttons, multiple errors, runtime tracebacks, and
prompt-only errors.
- **Breaking**: None
- **Dependencies**: None — uses only existing project dependencies
(`vue-i18n`, `pinia`, `primevue`)

## Related Work

> **Note**: Upstream PR #8603 (`New bottom button and badges`)
introduced a separate `TabError.vue` (singular) that shows per-node
errors when a specific node is selected. Our `TabErrors.vue` (plural)
provides the **global error overview** — a different scope. The two tabs
coexist:
> - `'error'` (singular) → appears when a node with errors is selected →
shows only that node's errors
> - `'errors'` (plural) → appears when no node is selected and errors
exist → shows all errors grouped by class type
>
> A future consolidation of these two tabs may be desirable after design
review.

## Architecture

```
executionStore
├── lastPromptError: PromptError | null     ← NEW (prompt-level errors without node IDs)
├── lastNodeErrors: Record<string, NodeError>  (existing)
├── lastExecutionError: ExecutionError         (existing)
└── hasAnyError: ComputedRef<boolean>       ← NEW (centralized error detection)

TabErrors.vue (errors tab - global view)
├── errorGroups: ComputedRef<ErrorGroup[]>  ← normalizes all 3 error sources
├── filteredGroups                          ← search-filtered view
├── locateNode()                            ← pan canvas to node
├── enterSubgraph()                         ← navigate into subgraph
└── ErrorNodeCard.vue                       ← per-node card with copy/locate actions

types.ts
├── ErrorItem      { message, details?, isRuntimeError? }
├── ErrorCardData  { id, title, nodeId?, errors[] }
└── ErrorGroup     { title, cards[], priority }
```

## Review Focus

1. **Error normalization logic** (`TabErrors.vue` L75–150): Three
different error sources (prompt, node validation, runtime) are
normalized into a common `ErrorGroup → ErrorCardData → ErrorItem`
hierarchy. Edge cases to verify:
- Prompt errors with known vs unknown types (known types use localized
descriptions)
   - Multiple errors on the same node (grouped into one card)
   - Runtime errors with long tracebacks (capped height with scroll)

2. **Canvas navigation** (`TabErrors.vue` L210–250): The `locateNode`
and `enterSubgraph` functions navigate to potentially nested subgraphs.
The double `requestAnimationFrame` is required due to LiteGraph's
asynchronous subgraph switching — worth verifying this timing is
sufficient.

3. **Store getter consolidation**: `hasAnyError` replaces duplicated
logic in `TopMenuSection` and `RightSidePanel`. Confirm that the
reactive dependency chain works correctly (it depends on 3 separate
refs).

4. **Coexistence with upstream `TabError.vue`**: The singular `'error'`
tab (upstream, PR #8603) and our plural `'errors'` tab serve different
purposes but share similar naming. Consider whether a unified approach
is preferred.

## Test Results

```
✓ renders "no errors" state when store is empty
✓ renders prompt-level errors (Group title = error message)
✓ renders node validation errors grouped by class_type
✓ renders runtime execution errors from WebSocket
✓ filters errors based on search query
✓ calls copyToClipboard when copy button is clicked

Test Files  1 passed (1)
     Tests  6 passed (6)
```

## Screenshots (if applicable)
<img width="1238" height="1914" alt="image"
src="https://github.com/user-attachments/assets/ec39b872-cca1-4076-8795-8bc7c05dc665"
/>
<img width="669" height="1028" alt="image"
src="https://github.com/user-attachments/assets/bdcaa82a-34b0-46a5-a08f-14950c5a479b"
/>
<img width="644" height="1005" alt="image"
src="https://github.com/user-attachments/assets/ffef38c6-8f42-4c01-a0de-11709d54b638"
/>
<img width="672" height="505" alt="image"
src="https://github.com/user-attachments/assets/5cff7f57-8d79-4808-a71e-9ad05bab6e17"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8807-Feat-errors-tab-panel-3046d73d36508127981ac670a70da467)
by [Unito](https://www.unito.io)
2026-02-17 21:01:15 -08:00
Terry Jia
cde872fcf7 fix: eliminate visual shaking when adjusting crop region (#8896)
## Summary
Replace the dual-image approach (dimmed <img> + background-image in crop
box) with a single <img> and a box-shadow overlay on the crop box. The
previous approach required two independently positioned images to be
pixel-perfectly aligned, which caused visible jitter from sub-pixel
rounding differences, especially when zoomed in.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8896-fix-eliminate-visual-shaking-when-adjusting-crop-region-3086d73d365081309703d3ab0d4ad44d)
by [Unito](https://www.unito.io)
2026-02-17 16:56:46 -08:00
Terry Jia
596df0f0c6 fix: resolve ImageCrop input image through subgraph nodes (#8899)
## Summary
When ImageCrop's input comes from a subgraph node, getInputNode returns
the subgraph node itself which has no image outputs.
Use resolveSubgraphOutputLink to trace through to the actual source node
(e.g. LoadImage) inside the subgraph.

Use canvas.graph (the currently active graph/subgraph) as the primary
lookup, falling back to rootGraph. When the ImageCrop node is inside a
subgraph, rootGraph cannot find it since it only contains root-level
nodes.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/c3995f7c-6bcd-41fe-bc41-cfd87f9be94a


after


https://github.com/user-attachments/assets/ac660f58-6e6a-46ad-a441-84c7b88d28e2

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8899-fix-resolve-ImageCrop-input-image-through-subgraph-nodes-3086d73d36508172a759d7747190591f)
by [Unito](https://www.unito.io)
2026-02-17 16:56:24 -08:00
Johnpaul Chiwetelu
d3c0e331eb fix: detect video output from data in Nodes 2.0 (#8943)
## Summary

- Fixes SaveWebM node showing "Error loading image" in Vue nodes mode
- Extracts `isAnimatedOutput`/`isVideoOutput` utility functions from
inline logic in `unsafeUpdatePreviews` so both the litegraph canvas
renderer and Vue nodes renderer can detect video output directly from
execution data
- Uses output-based detection in `imagePreviewStore.isImageOutputs` to
avoid applying image preview format conversion to video files

## Background

In Vue nodes mode, `nodeMedia` relied on `node.previewMediaType` to
determine if output is video. This property is only set via
`onDrawBackground` → `unsafeUpdatePreviews` in the litegraph canvas
path, which doesn't run in Vue nodes mode. This caused webm output to
render via `<img>` instead of `<video>`.

## Before


https://github.com/user-attachments/assets/36f8a033-0021-4351-8f82-d19e3faa80c2


## After


https://github.com/user-attachments/assets/6558d261-d70e-4968-9637-6c24532e23ac
## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] `pnpm test:unit` passes (4500 tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8943-fix-detect-video-output-from-data-in-Vue-nodes-mode-30a6d73d365081e98e91d6d1dcc88785)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 16:17:23 -08:00
Dante
b47414a52f fix: prevent duplicate node search filters (#8935)
## Summary

- Add duplicate check in `addFilter` to prevent identical filter chips
(same `filterDef.id` and `value`) from being added to the node search
box

## Related Issue

- Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/3559

## Changes

- `NodeSearchBoxPopover.vue`: Guard `addFilter` with `isDuplicate` check
comparing `filterDef.id` and `value`
- `NodeSearchBoxPopover.test.ts`: Add unit tests covering duplicate
prevention, distinct id, and distinct value cases

## QA

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] `pnpm format:check` passes
- [x] Unit tests pass (4/4)
- [x] Bug reproduced with Playwright before fix

### as-is
<img width="719" height="269" alt="스크린샷 2026-02-17 오후 5 45 48"
src="https://github.com/user-attachments/assets/403bf53a-53dd-4257-945f-322717f304b3"
/>

### to-be
<img width="765" height="291" alt="스크린샷 2026-02-17 오후 5 44 25"
src="https://github.com/user-attachments/assets/7995b15e-d071-4955-b054-5e0ca7c5c5bf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8935-fix-prevent-duplicate-node-search-filters-30a6d73d3650816797cfcc524228f270)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:53:08 -08:00
Simula_r
631d484901 refactor: workspaces DDD (#8921)
## Summary

Refactor: workspaces related functionality into DDD structure.

Note: this is the 1st PR of 2 more refactoring.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8921-refactor-DDD-3096d73d3650812bb7f6eb955f042663)
by [Unito](https://www.unito.io)
2026-02-17 12:28:47 -08:00
Christian Byrne
e83e396c09 feat: gate node replacement loading on server feature flag (#8750)
## Summary

Gates the node replacement store's `load()` call behind the
`node_replacements` server feature flag, so the frontend only calls
`/api/node_replacements` when the backend advertises support.

## Changes

- Added `NODE_REPLACEMENTS = 'node_replacements'` to `ServerFeatureFlag`
enum
- Added `nodeReplacementsEnabled` getter to `useFeatureFlags()`
- Added `api.serverSupportsFeature('node_replacements')` guard in
`useNodeReplacementStore.load()`

## Context

Without this guard, the frontend would attempt to fetch node
replacements from backends that don't support the endpoint, causing 404
errors.

Companion backend PR: https://github.com/Comfy-Org/ComfyUI/pull/12362

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8750-feat-gate-node-replacement-loading-on-server-feature-flag-3026d73d365081ec9246d77ad88f5bdc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 11:39:07 -08:00
Benjamin Lu
821c1e74ff fix: use gtag get for checkout attribution (#8930)
## Summary

Replace checkout attribution GA identity sourcing from
`window.__ga_identity__` with GA4 `gtag('get', ...)` calls keyed by
remote config measurement ID.

## Changes

- **What**:
  - Add typed global `gtag` get definitions and shared GA field types.
- Fetch `client_id`, `session_id`, and `session_number` via `gtag('get',
measurementId, field, callback)` with timeout-based fallback.
- Normalize numeric GA values to strings before emitting checkout
attribution metadata.
- Update checkout attribution tests to mock `gtag` retrieval and verify
requested fields + numeric normalization.
  - Add `ga_measurement_id` to remote config typings.

## Review Focus

Validate the `gtag('get', ...)` retrieval path and failure handling
(`undefined` fallback on timeout/errors) and confirm analytics field
names match GA4 expectations.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8930-fix-use-gtag-get-for-checkout-attribution-30a6d73d365081dcb773da945daceee6)
by [Unito](https://www.unito.io)
2026-02-17 02:43:34 -08:00
Comfy Org PR Bot
d06cc0819a 1.40.6 (#8927)
Patch version increment to 1.40.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8927-1-40-6-30a6d73d365081498d88d11c5f24a0ed)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-17 02:22:09 -08:00
pythongosssss
f5f5a77435 Add support for dragging in multiple workflow files at once (#8757)
## Summary

Allows users to drag in multiple files that are/have embedded workflows
and loads each of them as tabs.
Previously it would only load the first one.

## Changes

- **What**: 
- process all files from drop event
- add defered errors so you don't get errors for non-visible workflows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8757-Add-support-for-dragging-in-multiple-workflow-files-at-once-3026d73d365081c096e9dfb18ba01253)
by [Unito](https://www.unito.io)
2026-02-16 23:45:22 -08:00
Jin Yi
efe78b799f [feat] Node replacement UI (#8604)
## Summary
Add node replacement UI to the missing nodes dialog. Users can select
and replace deprecated/missing nodes with compatible alternatives
directly from the dialog.

## Changes
- Classify missing nodes into **Replaceable** (quick fix) and **Install
Required** sections
- Add select-all checkbox + per-node checkboxes for batch replacement
- `useNodeReplacement` composable handles in-place node replacement on
the graph:
  - Simple replacement (configure+copy) for nodes without mapping
  - Input/output connection remapping for nodes with mapping
  - Widget value transfer via `old_widget_ids`
  - Dot-notation input handling for Autogrow/DynamicCombo
  - Undo/redo support via `changeTracker` (try/finally)
  - Title and properties preservation
- Footer UX: "Skip for Now" button when all nodes are replaceable (cloud
+ OSS)
- Auto-close dialog when all replaceable nodes are replaced and no
non-replaceable remain
- Settings navigation link from "Don't show again" checkbox
- 505-line unit test suite for `useNodeReplacement`

## Review Focus
- `useNodeReplacement.ts` — core graph manipulation logic
- `MissingNodesContent.vue` — checkbox selection state management
- `MissingNodesFooter.vue` — conditional button rendering (cloud vs OSS
vs all-replaceable)


[screen-capture.webm](https://github.com/user-attachments/assets/7dae891c-926c-4f26-987f-9637c4a2ca16)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8604-feat-Node-replacement-UI-2fd6d73d36508148a371dabb8f4115af)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-16 23:33:41 -08:00
Benjamin Lu
e70484d596 fix: move queue assets action into filter controls (#8926)
## Summary

Move the queue overlay "Show assets" action into the filter controls as
an icon button, so the action is available inline with other list
controls while keeping existing behavior.

## Changes

- **What**:
- Remove the full-width "Show assets" button from
`QueueOverlayExpanded`.
- Add a secondary icon button in `JobFiltersBar` with tooltip +
aria-label and emit `showAssets` on click.
- Wire `showAssets` from `JobFiltersBar` through `QueueOverlayExpanded`
to the existing handler.
- Add `JobFiltersBar` unit coverage to verify `showAssets` is emitted
when the icon button is clicked.

## Review Focus

- Verify the icon button placement in the filter row is sensible and
discoverable.
- Verify clicking the new button opens the assets panel as before.
- Verify tooltip and accessibility label copy are correct.

## Screenshots (if applicable)
Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3924-38560&m=dev
<img width="349" height="52" alt="Screenshot 2026-02-16 at 4 53 34 PM"
src="https://github.com/user-attachments/assets/347772d6-5536-457a-a65f-de251e35a0e4"
/>
2026-02-16 18:19:16 -08:00
Benjamin Lu
3dba245dd3 fix: move clear queued controls into queue header (#8920)
## Summary
- Move queued-count summary and clear-queued action into the Queue Overlay header so controls remain visible while expanded content scrolls.

## What changed
- `QueueOverlayExpanded.vue`
  - Passes `queuedCount` and `clearQueued` through to the header.
  - Removes duplicated summary/action content from the lower section.
- `QueueOverlayHeader.vue`
  - Accepts new header data/actions for queued count and clear behavior.
  - Renders queued summary and clear button beside the title.
  - Adjusts layout to support persistent header actions.
- Updated header unit tests to cover queued summary rendering and clear action behavior.

## Testing
- Header unit tests were updated for the new behavior.
- No additional test execution was requested.

## Notes
- UI composition change only; queue execution semantics are unchanged.

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

<img width="356" height="59" alt="Screenshot 2026-02-16 at 3 30 44 PM" src="https://github.com/user-attachments/assets/987e42bd-9e24-4e65-9158-3f96b5338199" />
2026-02-16 18:03:52 -08:00
Benjamin Lu
2ca0c30cf7 fix: localize queue overlay running and queued summary (#8919)
## Summary
- Localize QueueProgressOverlay header counts with dedicated i18n pluralization keys for running and queued jobs.
- Replace the previous aggregate active-job wording with a translated running/queued summary.

## What changed
- Updated `QueueProgressOverlay.vue` to derive `runningJobsLabel`, `queuedJobsLabel`, and `runningQueuedSummary` via `useI18n`.
- Added `QueueProgressOverlay.test.ts` coverage for expanded-header text in both active and empty queue states.
- Added new English locale keys in `src/locales/en/main.json`:
  - `runningJobsLabel`
  - `queuedJobsLabel`
  - `runningQueuedSummary`

## Testing
- `Storybook Build Status` passed.
- `Playwright Tests` were still running at the last check; merge should wait for completion.

## Notes
- Behavioral scope is limited to queue overlay header text/rendering.

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

<img width="356" height="59" alt="Screenshot 2026-02-16 at 3 30 44 PM" src="https://github.com/user-attachments/assets/987e42bd-9e24-4e65-9158-3f96b5338199" />
2026-02-16 17:48:47 -08:00
Benjamin Lu
c8ba5f7300 fix: add pulsing active-jobs indicator on queue button (#8915)
## Summary

Add a small pulsing blue indicator dot to the top-right of the `N
active` queue button when there are active jobs.

## Changes

- **What**: Reused `StatusBadge` (`variant="dot"`) in `TopMenuSection`
as a top-right indicator on the queue toggle button, shown only when
`activeJobsCount > 0` and animated with `animate-pulse`.
- **What**: Added tests to verify the indicator appears for nonzero
active jobs and is hidden when there are no active jobs.

## Review Focus

- Dot positioning on the queue button (`-top-0.5 -right-0.5`) across top
menu layouts.
- Indicator visibility behavior tied to `activeJobsCount > 0`.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/9bdb7675-3e58-485b-abdd-446a76b2dafc

won't be shown on 0 active, I was just testing locally

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8915-fix-add-pulsing-active-jobs-indicator-on-queue-button-3096d73d36508181abf5c27662e0d9ae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-17 01:32:33 +00:00
AustinMroz
39cc8ab97a A heavy-handed fix for middlemouse pan (#8865)
Sometimes, middle mouse clicks would fail to initiate a canvas pan,
depending on the target of the initial pan. This PR adds a capturing
event handler to the transform pane that forwards the pointer event to
canvas if
- It is a middle mouse click
- The target element is not a focused text element

Resolves #6911

While testing this, I encountered infrequent cases of "some nodes
unintentionally translating continually to the left". Reproduction was
too unreliable to properly track down, but did appear unrelated to this
PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8865-A-heavy-handed-fix-for-middlemouse-pan-3076d73d365081ea9a4ddd5786fc647a)
by [Unito](https://www.unito.io)
2026-02-16 15:40:36 -08:00
Alexander Brown
2ee0a1337c fix: prevent XSS vulnerability in context menu labels (#8887)
Replace innerHTML with textContent when setting context menu item labels
to prevent XSS attacks via malicious filenames. This fixes a security
vulnerability where filenames like "<img src=x onerror=alert()>" could
execute arbitrary JavaScript when displayed in dropdowns.

https://claude.ai/code/session_01LALt1HEgGvpWD7hhqcp2Gu

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8887-fix-prevent-XSS-vulnerability-in-context-menu-labels-3086d73d365081ccbe3cdb35cd7e5cb1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-16 15:31:00 -08:00
157 changed files with 7771 additions and 616 deletions

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ secrets.PR_GH_TOKEN }}
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

View File

@@ -0,0 +1,205 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,186 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,86 @@
{
"id": "save-image-and-webm-test",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 100],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1, 2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 11,
"type": "SaveImage",
"pos": [450, 100],
"size": [210, 270],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 12,
"type": "SaveWEBM",
"pos": [450, 450],
"size": [210, 368],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", "vp9", 6, 32]
}
],
"links": [
[1, 10, 0, 11, 0, "IMAGE"],
[2, 10, 0, 12, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -215,6 +215,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
})
test('Does not add duplicate filter with same type and value', async ({
comfyPage
}) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.removeFilter(0)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,42 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Save Image and WEBM preview',
{ tag: ['@screenshot', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Can preview both SaveImage and SaveWEBM outputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30000
})
// Wait for SaveWEBM to render a video inside .video-preview
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
timeout: 30000
})
await expect(comfyPage.page).toHaveScreenshot(
'save-image-and-webm-preview.png'
)
})
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'
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.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
}
test('opens mask editor from image preview button', async ({ comfyPage }) => {
const imagePreview = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu.getByText('Open Image')).toBeVisible()
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
})

25
global.d.ts vendored
View File

@@ -10,9 +10,28 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
client_id: string | number | undefined
session_id: string | number | undefined
session_number: string | number | undefined
}
interface GtagFunction {
<TField extends GtagGetFieldName>(
command: 'get',
targetId: string,
fieldName: TField,
callback: (value: GtagGetFieldValueMap[TField]) => void
): void
(...args: unknown[]): void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -36,12 +55,8 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
}

View File

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

View File

@@ -215,6 +215,17 @@ describe('TopMenuSection', () => {
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
})
it('hides the active jobs indicator when no jobs are active', () => {
const wrapper = createWrapper()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {

View File

@@ -36,7 +36,14 @@
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
:class="
cn(
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
@@ -60,7 +67,7 @@
? isQueueOverlayExpanded
: undefined
"
class="px-3"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
@@ -68,6 +75,12 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
@@ -139,6 +152,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -161,6 +175,7 @@ import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@/utils/tailwindUtil'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
@@ -245,6 +260,8 @@ const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -3,49 +3,26 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({

View File

@@ -0,0 +1,175 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
v-bind="inputAttrs"
:value="displayValue ?? modelValue"
:disabled
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
</Button>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
step = 1,
disabled = false,
hideButtons = false,
displayValue,
parseValue
} = defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
hideButtons?: boolean
displayValue?: string
parseValue?: (raw: string) => number | undefined
inputAttrs?: Record<string, unknown>
}>()
const { t } = useI18n()
const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(container, () => {
if (textEdit.value) textEdit.value = false
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
const raw = target.value.trim()
const parsed = parseValue
? parseValue(raw)
: raw === ''
? undefined
: Number(raw)
if (parsed != null && !isNaN(parsed)) {
modelValue.value = clamp(parsed)
} else {
target.value = displayValue ?? String(modelValue.value)
}
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
function handlePointerUp() {
if (!dragging.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
@@ -14,32 +14,210 @@
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- Missing Nodes List Wrapper -->
<div
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<!-- QUICK FIX AVAILABLE Section -->
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<Button
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
variant="primary"
size="md"
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
})
}}
</Button>
</div>
<!-- Replaceable nodes list -->
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<span class="text-xs">
{{ node.label }}
</span>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
<!-- Select All row (sticky header) -->
<div
:class="
cn(
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
"
@click="toggleSelectAll"
@keydown.enter.prevent="toggleSelectAll"
@keydown.space.prevent="toggleSelectAll"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
isAllSelected || isSomeSelected
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isAllSelected"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
<!-- Replaceable node items -->
<div
v-for="node in replaceableNodes"
:key="node.label"
:class="
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'true'
: 'false'
"
@click="toggleNode(node.label)"
@keydown.enter.prevent="toggleNode(node.label)"
@keydown.space.prevent="toggleNode(node.label)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
</span>
</div>
</div>
</div>
</div>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
<!-- MANUAL INSTALLATION REQUIRED Section -->
<div
v-if="nonReplaceableNodes.length > 0"
class="flex max-h-[200px] flex-col gap-2"
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
</div>
<!-- Non-replaceable nodes list -->
<div
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
:key="node.label"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
</div>
<Button
v-if="node.action"
variant="destructive-textonly"
size="sm"
@click="node.action.callback"
>
{{ node.action.text }}
</Button>
</div>
</div>
</div>
<!-- Bottom instruction box -->
<div
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<p class="m-0 text-xs leading-5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{
$t('nodeReplacement.redHighlight')
}}</span>
</template>
</i18n-t>
</p>
</div>
</div>
@@ -47,23 +225,39 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const props = defineProps<{
const { missingNodeTypes } = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
interface ProcessedNode {
label: string
hint?: string
action?: { text: string; callback: () => void }
isReplaceable: boolean
replacement?: NodeReplacement
}
const replacedTypes = ref<Set<string>>(new Set())
const uniqueNodes = computed<ProcessedNode[]>(() => {
const seenTypes = new Set<string>()
return missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
@@ -75,10 +269,81 @@ const uniqueNodes = computed(() => {
return {
label: node.type,
hint: node.hint,
action: node.action
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
}
}
return { label: node }
return { label: node, isReplaceable: false }
})
})
const replaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => n.isReplaceable)
)
const pendingNodes = computed(() =>
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
)
const nonReplaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => !n.isReplaceable)
)
// Selection state - all pending nodes selected by default
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
const isAllSelected = computed(
() =>
pendingNodes.value.length > 0 &&
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
)
const isSomeSelected = computed(
() => selectedTypes.value.size > 0 && !isAllSelected.value
)
function toggleNode(label: string) {
if (replacedTypes.value.has(label)) return
const next = new Set(selectedTypes.value)
if (next.has(label)) {
next.delete(label)
} else {
next.add(label)
}
selectedTypes.value = next
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedTypes.value = new Set()
} else {
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
}
}
function handleReplaceSelected() {
const selected = missingNodeTypes.filter((node) => {
const type = typeof node === 'object' ? node.type : node
return selectedTypes.value.has(type)
})
const result = replaceNodesInPlace(selected)
const nextReplaced = new Set(replacedTypes.value)
const nextSelected = new Set(selectedTypes.value)
for (const type of result) {
nextReplaced.add(type)
nextSelected.delete(type)
}
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)
)
if (allReplaced && nonReplaceableNodes.value.length === 0) {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
}
</script>

View File

@@ -30,8 +30,18 @@
</i18n-t>
</div>
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleGotItClick">
{{ $t('nodeReplacement.skipForNow') }}
</Button>
</div>
<!-- Cloud mode: Learn More + Got It buttons -->
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<div
v-else-if="isCloud"
class="flex w-full items-center justify-between gap-2"
>
<Button
variant="textonly"
size="sm"
@@ -48,9 +58,9 @@
}}</Button>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<!-- OSS mode: Manager buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="openManager">{{
<Button variant="textonly" @click="handleOpenManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
@@ -82,12 +92,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -109,6 +124,12 @@ function openShowMissingNodesSetting() {
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
function handleOpenManager() {
managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -128,15 +149,29 @@ const showInstallAllButton = computed(() => {
return managerState.shouldShowInstallButton.value
})
const openManager = async () => {
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
const hasNonReplaceableNodes = computed(
() =>
missingNodeTypes?.some(
(n) =>
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
) ?? false
)
// Computed to check if all missing nodes have been installed
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
const hadMissingPacks = ref(false)
watch(
missingNodePacks,
(packs) => {
if (packs && packs.length > 0) hadMissingPacks.value = true
},
{ immediate: true }
)
// Only consider "all installed" when packs transitioned from non-empty to empty
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
const allMissingNodesInstalled = computed(() => {
if (!hadMissingPacks.value) return false
return (
!isLoading.value &&
!isInstalling.value &&

View File

@@ -60,6 +60,9 @@
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@pointerdown.capture="forwardPanEvent"
@pointerup.capture="forwardPanEvent"
@pointermove.capture="forwardPanEvent"
>
<!-- Vue nodes rendered based on graph nodes -->
<LGraphNode
@@ -114,6 +117,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -160,6 +164,7 @@ import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -540,4 +545,13 @@ onMounted(async () => {
onUnmounted(() => {
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
!isMiddlePointerInput(e)
)
return
canvasInteractions.forwardEventToCanvas(e)
}
</script>

View File

@@ -67,18 +67,6 @@ describe('HoneyToast', () => {
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
@@ -127,11 +115,6 @@ describe('HoneyToast', () => {
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -26,13 +26,13 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
)
"
>

View File

@@ -28,7 +28,7 @@
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none brightness-50"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
@@ -36,14 +36,12 @@
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
>
<div class="pointer-events-none size-full" :style="cropImageStyle" />
</div>
/>
<div
v-for="handle in resizeHandles"
@@ -131,7 +129,6 @@ const {
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,

View File

@@ -52,7 +52,7 @@ export const Completed: Story = {
args: args({
type: 'completed',
count: 1,
thumbnailUrl: thumbnail('4dabf7')
thumbnailUrls: [thumbnail('4dabf7')]
})
}
@@ -97,7 +97,7 @@ export const Gallery: Story = {
const completed = args({
type: 'completed',
count: 1,
thumbnailUrl: thumbnail('ff6b6b')
thumbnailUrls: [thumbnail('ff6b6b')]
})
const completedMultiple = args({
type: 'completed',

View File

@@ -71,11 +71,6 @@ const thumbnailUrls = computed(() => {
if (notification.type !== 'completed') {
return []
}
if (typeof notification.thumbnailUrl === 'string') {
return notification.thumbnailUrl.length > 0
? [notification.thumbnailUrl]
: []
}
return notification.thumbnailUrls?.slice(0, 2) ?? []
})

View File

@@ -4,46 +4,17 @@
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
@clear-history="$emit('clearHistory')"
@clear-queued="$emit('clearQueued')"
/>
<div class="flex items-center justify-between px-3">
<Button
class="grow gap-1 justify-center"
variant="secondary"
size="sm"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</Button>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<Button
v-if="queuedCount > 0"
class="ml-2"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<JobFiltersBar
:selected-job-tab="selectedJobTab"
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
@@ -71,9 +42,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
JobGroup,
JobListItem,
@@ -112,8 +81,6 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)

View File

@@ -40,6 +40,8 @@ const i18n = createI18n({
sideToolbar: {
queueProgressOverlay: {
running: 'running',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
moreOptions: 'More options',
clearHistory: 'Clear history'
}
@@ -54,6 +56,7 @@ const mountHeader = (props = {}) =>
headerTitle: 'Job queue',
showConcurrentIndicator: true,
concurrentWorkflowCount: 2,
queuedCount: 3,
...props
},
global: {
@@ -80,6 +83,25 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('shows queued summary and emits clear queued', async () => {
const wrapper = mountHeader({ queuedCount: 4 })
expect(wrapper.text()).toContain('4')
expect(wrapper.text()).toContain('queued')
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
})
it('hides clear queued button when queued count is zero', () => {
const wrapper = mountHeader({ queuedCount: 0 })
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
false
)
})
it('toggles popover and emits clear history', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')

View File

@@ -1,8 +1,8 @@
<template>
<div
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
class="flex h-12 items-center gap-2 border-b border-interface-stroke px-2"
>
<div class="px-2 text-[14px] font-normal text-text-primary">
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
<span>{{ headerTitle }}</span>
<span
v-if="showConcurrentIndicator"
@@ -17,6 +17,25 @@
</span>
</span>
</div>
<div
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
>
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<Button
v-if="queuedCount > 0"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
<div v-if="!isCloud" class="flex items-center gap-1">
<Button
v-tooltip.top="moreTooltipConfig"
@@ -78,10 +97,12 @@ defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
}>()
const emit = defineEmits<{
(e: 'clearHistory'): void
(e: 'clearQueued'): void
}>()
const { t } = useI18n()

View File

@@ -0,0 +1,99 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import { i18n } from '@/i18n'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const QueueOverlayExpandedStub = defineComponent({
name: 'QueueOverlayExpanded',
props: {
headerTitle: {
type: String,
required: true
}
},
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl({
id,
status,
create_time: 0,
priority: 0
})
}
const mountComponent = (
runningTasks: TaskItemImpl[],
pendingTasks: TaskItemImpl[]
) => {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
const queueStore = useQueueStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
return mount(QueueProgressOverlay, {
props: {
expanded: true
},
global: {
plugins: [pinia, i18n],
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
},
directives: {
tooltip: () => {}
}
}
})
}
describe('QueueProgressOverlay', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('shows expanded header with running and queued labels', () => {
const wrapper = mountComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
],
[createTask('pending-1', 'pending')]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'2 running, 1 queued'
)
})
it('shows only running label when queued count is zero', () => {
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
)
})
it('shows job queue title when there are no active jobs', () => {
const wrapper = mountComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
})
})

View File

@@ -92,7 +92,7 @@ const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
}>()
const { t } = useI18n()
const { t, n } = useI18n()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
@@ -126,7 +126,6 @@ const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isExecuting = computed(() => !executionStore.isIdle)
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
const overlayState = computed<OverlayState>(() => {
if (isExpanded.value) return 'expanded'
@@ -156,11 +155,34 @@ const bottomRowClass = computed(
: 'opacity-0 pointer-events-none'
}`
)
const headerTitle = computed(() =>
hasActiveJob.value
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
: t('sideToolbar.queueProgressOverlay.jobQueue')
const runningJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
count: n(runningCount.value)
})
)
const queuedJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
count: n(queuedCount.value)
})
)
const headerTitle = computed(() => {
if (!hasActiveJob.value) {
return t('sideToolbar.queueProgressOverlay.jobQueue')
}
if (queuedCount.value === 0) {
return runningJobsLabel.value
}
if (runningCount.value === 0) {
return queuedJobsLabel.value
}
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
running: runningJobsLabel.value,
queued: queuedJobsLabel.value
})
})
const concurrentWorkflowCount = computed(
() => executionStore.runningWorkflowCount

View File

@@ -0,0 +1,79 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
expose({
hide: () => undefined,
toggle: (_event: Event) => undefined
})
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
import JobFiltersBar from '@/components/queue/job/JobFiltersBar.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
all: 'All',
completed: 'Completed'
},
queue: {
jobList: {
sortMostRecent: 'Most recent',
sortTotalGenerationTime: 'Total generation time'
}
},
sideToolbar: {
queueProgressOverlay: {
filterJobs: 'Filter jobs',
filterBy: 'Filter by',
sortJobs: 'Sort jobs',
sortBy: 'Sort by',
showAssets: 'Show assets',
showAssetsPanel: 'Show assets panel',
filterAllWorkflows: 'All workflows',
filterCurrentWorkflow: 'Current workflow'
}
}
}
}
})
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
}
})
const showAssetsButton = wrapper.get(
'button[aria-label="Show assets panel"]'
)
await showAssetsButton.trigger('click')
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
})

View File

@@ -127,6 +127,15 @@
</template>
</div>
</Popover>
<Button
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
@@ -150,6 +159,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
@@ -165,6 +175,9 @@ const filterTooltipConfig = computed(() =>
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
// This can be removed when cloud implements /jobs and we switch to it.
const showWorkflowFilter = !isCloud

View File

@@ -33,6 +33,7 @@ import {
useFlatAndCategorizeSelectedItems
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
@@ -40,6 +41,8 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError } = storeToRefs(executionStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -102,7 +105,10 @@ const selectedNodeErrors = computed(() =>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
if (
selectedNodeErrors.value.length &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('g.error'),
value: 'error',
@@ -110,6 +116,18 @@ const tabs = computed<RightSidePanelTabList>(() => {
})
}
if (
hasAnyError.value &&
!hasSelection.value &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('rightSidePanel.errors'),
value: 'errors',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
flattedItems.value.length > 1
@@ -298,7 +316,8 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabErrors v-if="activeTab === 'errors'" />
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
details: 'Input: text\nExpected: STRING'
}
]
}
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
details: ''
},
{
message: 'Value "NaN" is not a valid number for "strength".',
details: 'Expected: FLOAT [0.0 .. 1.0]'
}
]
}
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
details: [
'Traceback (most recent call last):',
' File "ksampler.py", line 142, in sample',
' samples = model.apply(latent)',
'RuntimeError: CUDA out of memory.'
].join('\n'),
isRuntimeError: true
}
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
errors: [
{
message:
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
}
}

View File

@@ -0,0 +1,110 @@
<template>
<div class="overflow-hidden">
<!-- Card Header (Node ID & Actions) -->
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
>
#{{ card.nodeId }}
</span>
<span
v-if="card.nodeTitle"
class="flex-1 text-sm text-muted-foreground truncate font-medium"
>
{{ card.nodeTitle }}
</span>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
@click.stop="emit('locateNode', card.nodeId ?? '')"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
</div>
<!-- Multiple Errors within one Card -->
<div class="divide-y divide-interface-stroke/20 space-y-4">
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex flex-col gap-3"
>
<!-- Error Message -->
<p
v-if="error.message"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
>
{{ error.message }}
</p>
<!-- Traceback / Details -->
<div
v-if="error.details"
:class="
cn(
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
>
{{ error.details }}
</p>
</div>
<Button
variant="secondary"
size="sm"
class="w-full justify-center gap-2 h-8 text-[11px]"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
{{ t('g.copy') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
const { card, showNodeIdBadge = false } = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
const { t } = useI18n()
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',
[error.message, error.details].filter(Boolean).join('\n\n')
)
}
</script>

View File

@@ -0,0 +1,218 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
forEachNode: vi.fn()
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
}
}
}
}
}
})
})
function mountComponent(initialState = {}) {
return mount(TabErrors, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
stubs: {
FormSearchInput: {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
PropertiesAccordionItem: {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button><slot /></button>'
}
}
}
})
}
it('renders "no errors" state when store is empty', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('No errors')
})
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
// Group title should be the raw message from store
expect(wrapper.text()).toContain('Server Error: No outputs')
// Item message should be localized desc
expect(wrapper.text()).toContain('Prompt has no outputs')
// Details should not be rendered for prompt errors
expect(wrapper.text()).not.toContain('Error details')
})
it('renders node validation errors grouped by class_type', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
errors: [
{ message: 'Required input is missing', details: 'Input: text' }
]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('#6')
expect(wrapper.text()).toContain('CLIP Text Encode')
expect(wrapper.text()).toContain('Required input is missing')
})
it('renders runtime execution errors from WebSocket', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
})
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
expect(wrapper.text()).toContain('Line 1')
})
it('filters errors based on search query', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
errors: [{ message: 'Missing text input' }]
},
'2': {
class_type: 'KSampler',
errors: [{ message: 'Out of memory' }]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('KSampler')
const searchInput = wrapper.find('input')
await searchInput.setValue('Missing text input')
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('calls copyToClipboard when copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
}
}
})
// Find the copy button (rendered inside ErrorNodeCard)
const copyButtons = wrapper.findAll('button')
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
})

View File

@@ -0,0 +1,167 @@
<template>
<div class="flex flex-col h-full min-w-0">
<!-- Search bar -->
<div
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
>
<FormSearchInput v-model="searchQuery" />
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto min-w-0">
<div
v-if="filteredGroups.length === 0"
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div v-else>
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<i
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{ group.title }}
</span>
<span
v-if="group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
</div>
</template>
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
@locate-node="focusNode"
@enter-subgraph="enterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
</PropertiesAccordionItem>
</div>
</div>
<!-- Fixed Footer: Help Links -->
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
class="m-0 text-sm text-muted-foreground leading-tight break-words"
>
<template #github>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="openGitHubIssues"
>
{{ t('rightSidePanel.errorHelpGithub') }}
</Button>
</template>
<template #support>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="contactSupport"
>
{{ t('rightSidePanel.errorHelpSupport') }}
</Button>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorGroups } from './useErrorGroups'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const rightSidePanelStore = useRightSidePanelStore()
const searchQuery = ref('')
const settingStore = useSettingStore()
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
const { filteredGroups } = useErrorGroups(searchQuery, t)
const collapseState = reactive<Record<string, boolean>>({})
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
for (const group of filteredGroups.value) {
const hasMatch = group.cards.some(
(card) => card.graphNodeId === graphNodeId
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -0,0 +1,21 @@
export interface ErrorItem {
message: string
details?: string
isRuntimeError?: boolean
}
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}

View File

@@ -0,0 +1,236 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup } from './types'
import { isNodeExecutionId } from '@/types/nodeIdentification'
interface GroupEntry {
priority: number
cards: Map<string, ErrorCardData>
}
interface ErrorSearchItem {
groupIndex: number
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableMessage: string
searchableDetails: string
}
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
function resolveNodeInfo(nodeId: string): {
title: string
graphNodeId: string | undefined
} {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
title: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined
}
}
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
}
function processPromptError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
) {
if (!executionStore.lastPromptError) return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
cards.set('__prompt__', {
id: '__prompt__',
title: groupTitle,
errors: [
{
message: isKnown
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
: error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `node-${nodeId}`,
title: nodeError.class_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) continue
card.errors.push(
...nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
}))
)
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const nodeId = String(e.node_id)
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `exec-${nodeId}`,
title: e.node_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push({
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
})
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
})
}
function buildErrorGroups(
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
): ErrorGroup[] {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, executionStore, t)
processNodeErrors(groupsMap, executionStore)
processExecutionError(groupsMap, executionStore)
return toSortedGroups(groupsMap)
}
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
if (!query) return groups
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]!
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]!
searchableList.push({
groupIndex: gi,
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query)
const matchedCardKeys = new Set(
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const errorGroups = computed<ErrorGroup[]>(() =>
buildErrorGroups(executionStore, t)
)
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
return searchErrorGroups(errorGroups.value, query)
})
return {
errorGroups,
filteredGroups
}
}

View File

@@ -12,6 +12,10 @@ import type {
} 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 { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -60,6 +64,8 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
@@ -104,6 +110,11 @@ const targetNode = computed<LGraphNode | null>(() => {
return allSameNode ? widgets.value[0].node : null
})
const nodeHasError = computed(() => {
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -122,6 +133,13 @@ function handleLocateNode() {
}
}
function navigateToErrorTab() {
if (!targetNode.value) return
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
widget.value = value
widget.callback?.(value)
@@ -162,9 +180,20 @@ defineExpose({
:tooltip
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<span class="truncate">
<i
v-if="nodeHasError"
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span
:class="
cn(
'truncate',
nodeHasError && 'text-destructive-background-hover'
)
"
>
<slot name="label">
{{ displayLabel }}
</slot>
@@ -177,6 +206,15 @@ defineExpose({
{{ parentGroup.title }}
</span>
</span>
<Button
v-if="nodeHasError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}
</Button>
<Button
v-if="!isEmpty"
variant="textonly"

View File

@@ -0,0 +1,173 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const mockStoreRefs = vi.hoisted(() => ({
visible: { value: false },
newSearchBoxEnabled: { value: true }
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('pinia', async () => {
const actual = await vi.importActual('pinia')
return {
...(actual as Record<string, unknown>),
storeToRefs: () => mockStoreRefs
}
})
vi.mock('@/stores/workspace/searchBoxStore', () => ({
useSearchBoxStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
getCanvasCenter: vi.fn(() => [0, 0]),
addNodeOnGraph: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: null,
getCanvas: vi.fn(() => ({
linkConnector: {
events: new EventTarget(),
renderLinks: []
}
}))
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeSearchService: {
nodeFilters: [],
inputTypeFilter: {},
outputTypeFilter: {}
}
})
}))
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
template: '<div class="node-search-box" />'
})
function createFilter(
id: string,
value: string
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
return {
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
value
}
}
describe('NodeSearchBoxPopover', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
beforeEach(() => {
setActivePinia(createPinia())
mockStoreRefs.visible.value = false
})
const mountComponent = () => {
return mount(NodeSearchBoxPopover, {
global: {
plugins: [i18n, PrimeVue],
stubs: {
NodeSearchBox: NodeSearchBoxStub,
Dialog: {
template: '<div><slot name="container" /></div>',
props: ['visible', 'modal', 'dismissableMask', 'pt']
}
}
}
})
}
describe('addFilter duplicate prevention', () => {
it('should add a filter when no duplicates exist', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
const filters = searchBox.props('filters') as FuseFilterWithValue<
ComfyNodeDefImpl,
string
>[]
expect(filters).toHaveLength(1)
expect(filters[0]).toEqual(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'outputType' }),
value: 'IMAGE'
})
)
})
it('should not add a duplicate filter with same id and value', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(searchBox.props('filters')).toHaveLength(1)
})
it('should allow filters with same id but different values', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
await wrapper.vm.$nextTick()
expect(searchBox.props('filters')).toHaveLength(2)
})
it('should allow filters with different ids but same value', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(searchBox.props('filters')).toHaveLength(2)
})
})
})

View File

@@ -71,7 +71,12 @@ function getNewNodeLocation(): Point {
}
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value.push(filter)
const isDuplicate = nodeFilters.value.some(
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
)
if (!isDuplicate) {
nodeFilters.value.push(filter)
}
}
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value = nodeFilters.value.filter(

View File

@@ -79,8 +79,21 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
>
<div
v-for="n in skeletonCount"
:key="`skeleton-${n}`"
class="flex flex-col gap-2 p-2"
>
<Skeleton class="aspect-square w-full rounded-lg" />
<div class="flex flex-col gap-1">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
</div>
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
@@ -206,6 +219,7 @@
<script setup lang="ts">
import {
useAsyncState,
useDebounceFn,
useElementHover,
useResizeObserver,
@@ -213,7 +227,6 @@ import {
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -225,6 +238,7 @@ const Load3dViewerContent = () =>
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
@@ -237,6 +251,7 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
@@ -260,6 +275,7 @@ const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
@@ -376,7 +392,24 @@ const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const DEFAULT_SKELETON_COUNT = 6
const skeletonCount = computed(() =>
expectedFolderCount.value > 0
? expectedFolderCount.value
: DEFAULT_SKELETON_COUNT
)
const {
state: folderAssets,
isLoading: folderLoading,
error: folderError,
execute: loadFolderAssets
} = useAsyncState(
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
resolveOutputAssetItems(metadata, options),
[] as AssetItem[],
{ immediate: false, resetOnExecute: true }
)
// Base assets before search filtering
const baseAssets = computed(() => {
@@ -414,9 +447,13 @@ const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)
const isFolderLoading = computed(
() => isInFolderView.value && folderLoading.value
)
const showLoadingState = computed(
() =>
loading.value &&
(loading.value || isFolderLoading.value) &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -424,6 +461,7 @@ const showLoadingState = computed(
const showEmptyState = computed(
() =>
!loading.value &&
!isFolderLoading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -599,27 +637,25 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
expectedFolderCount.value = metadata.outputCount ?? 0
let folderItems: AssetItem[] = []
try {
folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
if (folderError.value) {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
})
} catch (error) {
console.error('Failed to resolve outputs for folder view:', error)
exitFolderView()
}
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = folderItems
}
const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
expectedFolderCount.value = 0
folderAssets.value = []
searchQuery.value = ''
}

View File

@@ -69,7 +69,7 @@ import Skeleton from 'primevue/skeleton'
import { computed, defineAsyncComponent, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -80,7 +80,8 @@ import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
() =>
import('../../platform/workspace/components/CurrentUserPopoverWorkspace.vue')
)
const { showArrow = true, compact = false } = defineProps<{

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('animate-pulse rounded-md bg-secondary-background', className)"
/>
</template>

View File

@@ -18,7 +18,7 @@ import type {
SubscriptionInfo
} from './types'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from './useWorkspaceBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
/**
* Unified billing context that automatically switches between legacy (user-scoped)

View File

@@ -0,0 +1,56 @@
import { nextTick } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return
if (canvas.graph !== targetGraph) {
canvas.subgraph = targetGraph.isRootGraph
? undefined
: (targetGraph as Subgraph)
canvas.setGraph(targetGraph)
await nextTick()
// Double RAF to wait for LiteGraph's internal canvas frame cycle
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
}
}
export function useFocusNode() {
const canvasStore = useCanvasStore()
async function focusNode(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode,
enterSubgraph
}
}

View File

@@ -23,7 +23,6 @@ type QueueQueuedNotification = {
type QueueCompletedNotification = {
type: 'completed'
count: number
thumbnailUrl?: string
thumbnailUrls?: string[]
}
@@ -38,7 +37,7 @@ export type QueueNotificationBanner =
| QueueFailedNotification
const sanitizeCount = (value: number | undefined) => {
if (value === undefined || Number.isNaN(value) || value <= 0) {
if (!(typeof value === 'number' && value > 0)) {
return 1
}
return Math.floor(value)

View File

@@ -20,7 +20,8 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled'
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
}
/**
@@ -96,6 +97,9 @@ export function useFeatureFlags() {
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
)
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
}
})

View File

@@ -155,11 +155,18 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const getInputImageUrl = (): string | null => {
if (!node.value) return null
const inputNode = node.value.getInputNode(0)
let sourceNode = node.value.getInputNode(0)
if (!sourceNode) return null
if (!inputNode) return null
if (sourceNode.isSubgraphNode()) {
const link = node.value.getInputLink(0)
if (!link) return null
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
sourceNode = resolved?.outputNode ?? null
if (!sourceNode) return null
}
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
if (urls?.length) {
return urls[0]
@@ -236,17 +243,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
height: `${cropHeight.value * scaleFactor.value}px`
}))
const cropImageStyle = computed(() => {
if (!imageUrl.value) return {}
return {
backgroundImage: `url(${imageUrl.value})`,
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
backgroundRepeat: 'no-repeat'
}
})
interface ResizeHandle {
direction: ResizeDirection
class: string
@@ -562,7 +558,10 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value = app.rootGraph?.getNodeById(nodeId) || null
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
}
updateImageUrl()
@@ -595,7 +594,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,

View File

@@ -201,11 +201,10 @@ describe('pasteImageNodes', () => {
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const fileList = createDataTransfer([file1, file2]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
[file1, file2]
)
expect(createNode).toHaveBeenCalledTimes(2)
@@ -217,11 +216,9 @@ describe('pasteImageNodes', () => {
})
it('should handle empty file list', async () => {
const fileList = createDataTransfer([]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
[]
)
expect(createNode).not.toHaveBeenCalled()

View File

@@ -96,7 +96,7 @@ export async function pasteImageNode(
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: FileList
fileList: File[]
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []

View File

@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -5,6 +7,38 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.innerHTML = options.title
element.textContent = options.title
root.append(element)
}
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const innerHtml = name === null ? '' : String(name)
const label = name === null ? '' : String(name)
if (typeof value === 'string') {
element.innerHTML = innerHtml
element.textContent = label
} else {
element.innerHTML = value?.title ?? innerHtml
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
if (value.disabled) {
disabled = true

View File

@@ -1898,6 +1898,25 @@
"outputs": "المُخرجات",
"type": "النوع"
},
"nodeReplacement": {
"compatibleAlternatives": "بدائل متوافقة",
"installMissingNodes": "تثبيت العقد المفقودة",
"installationRequired": "التثبيت مطلوب",
"instructionMessage": "يجب عليك تثبيت هذه العقد أو استبدالها ببدائل مثبتة لتشغيل سير العمل. العقد المفقودة مميزة باللون {red} على اللوحة. بعض العقد لا يمكن استبدالها ويجب تثبيتها عبر مدير العقد.",
"notReplaceable": "التثبيت مطلوب",
"openNodeManager": "فتح مدير العقد",
"quickFixAvailable": "إصلاح سريع متاح",
"redHighlight": "أحمر",
"replaceFailed": "فشل في استبدال العقد",
"replaceSelected": "استبدال المحدد ({count})",
"replaceWarning": "سيؤدي هذا إلى تعديل سير العمل بشكل دائم. احفظ نسخة أولاً إذا لم تكن متأكدًا.",
"replaceable": "قابل للاستبدال",
"replaced": "تم الاستبدال",
"replacedAllNodes": "تم استبدال {count} نوع/أنواع من العقد",
"replacedNode": "تم استبدال العقدة: {nodeType}",
"selectAll": "تحديد الكل",
"skipForNow": "تخطي الآن"
},
"nodeTemplates": {
"enterName": "أدخل الاسم",
"saveAsTemplate": "حفظ كقالب"
@@ -2414,8 +2433,11 @@
"moreOptions": "خيارات إضافية",
"noActiveJobs": "لا توجد مهام نشطة",
"preview": "معاينة",
"queuedJobsLabel": "{count} في الانتظار",
"queuedSuffix": "في الانتظار",
"running": "قيد التشغيل",
"runningJobsLabel": "{count} قيد التشغيل",
"runningQueuedSummary": "{running} قيد التشغيل، {queued} في الانتظار",
"showAssets": "عرض الأصول",
"showAssetsPanel": "عرض لوحة الأصول",
"sortBy": "ترتيب حسب",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "يطبق توجيه الانتباه المعياري على النماذج، مما يتيح استخدام المطالبات السلبية على النماذج المقطرة/schnell.",
"display_name": "توجيه الانتباه المعياري",
"inputs": {
"model": {
"name": "النموذج",
"tooltip": "النموذج الذي سيتم تطبيق NAG عليه."
},
"nag_alpha": {
"name": "معامل المزج",
"tooltip": "معامل المزج للانتباه المعياري. القيمة 1.0 تعني استبدال كامل، 0.0 تعني عدم وجود تأثير."
},
"nag_scale": {
"name": "عامل مقياس التوجيه",
"tooltip": "عامل مقياس التوجيه. القيم الأعلى تدفع أبعد عن المطالبة السلبية."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "النموذج المعدل مع تفعيل NAG."
}
}
},
"NormalizeImages": {
"display_name": "تطبيع الصور",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "ينتج صورًا باستخدام نماذج Recraft V4 أو V4 Pro.",
"display_name": "Recraft V4 تحويل النص إلى صورة",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم في التوليد."
},
"model_size": {
"name": "الحجم"
},
"n": {
"name": "عدد الصور",
"tooltip": "عدد الصور المراد إنشاؤها."
},
"negative_prompt": {
"name": "المطالبة السلبية",
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
},
"prompt": {
"name": "المطالبة",
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
},
"recraft_controls": {
"name": "عناصر تحكم Recraft",
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "ينتج SVG باستخدام نماذج Recraft V4 أو V4 Pro.",
"display_name": "Recraft V4 تحويل النص إلى متجه",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم في التوليد."
},
"model_size": {
"name": "الحجم"
},
"n": {
"name": "عدد الصور",
"tooltip": "عدد الصور المراد إنشاؤها."
},
"negative_prompt": {
"name": "المطالبة السلبية",
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
},
"prompt": {
"name": "المطالبة",
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
},
"recraft_controls": {
"name": "عناصر تحكم Recraft",
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "ينشئ SVG بشكل متزامن من صورة إدخال.",
"display_name": "إعادة صياغة تحويل الصورة إلى متجه",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "إنشاء فيديو من إطار بداية، إطار نهاية، ونص توجيهي.",
"display_name": "توليد فيديو من إطار البداية/النهاية باستخدام Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"end_frame": {
"name": "إطار النهاية"
},
"first_frame": {
"name": "إطار البداية"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم لتوليد الفيديو."
},
"model_audio": {
"name": "الصوت"
},
"model_duration": {
"name": "المدة"
},
"model_resolution": {
"name": "الدقة"
},
"prompt": {
"name": "النص التوجيهي",
"tooltip": "وصف النص التوجيهي (بحد أقصى ٢٠٠٠ حرف)."
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "إنشاء فيديو من نص.",
"display_name": "توليد فيديو من نص Vidu Q3",

View File

@@ -743,6 +743,10 @@
"filterText": "Text"
},
"backToAssets": "Back to all assets",
"folderView": {
"errorSummary": "Failed to load outputs",
"errorDetail": "Could not retrieve outputs for this job. Please try again."
},
"searchAssets": "Search Assets",
"labels": {
"queue": "Queue",
@@ -814,6 +818,9 @@
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsShort": "{count} active | {count} active",
"activeJobsSuffix": "active jobs",
"runningJobsLabel": "{count} running",
"queuedJobsLabel": "{count} queued",
"runningQueuedSummary": "{running}, {queued}",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
"viewJobHistory": "View active jobs (right-click to clear queue)",
@@ -1564,6 +1571,7 @@
"MiniMax": "MiniMax",
"model_specific": "model_specific",
"Moonvalley Marey": "Moonvalley Marey",
"": "",
"OpenAI": "OpenAI",
"Sora": "Sora",
"cond pair": "cond pair",
@@ -1588,7 +1596,6 @@
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
"": "",
"camera": "camera",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
@@ -2814,9 +2821,10 @@
"insertAllAssetsAsNodes": "Insert all assets as nodes",
"openWorkflowAll": "Open all workflows",
"exportWorkflowAll": "Export all workflows",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
@@ -2900,6 +2908,25 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
},
"nodeReplacement": {
"quickFixAvailable": "Quick Fix Available",
"installationRequired": "Installation Required",
"compatibleAlternatives": "Compatible Alternatives",
"replaceable": "Replaceable",
"replaced": "Replaced",
"notReplaceable": "Install Required",
"selectAll": "Select All",
"replaceSelected": "Replace Selected ({count})",
"replacedNode": "Replaced node: {nodeType}",
"replacedAllNodes": "Replaced {count} node type(s)",
"replaceFailed": "Failed to replace nodes",
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
"redHighlight": "red",
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",
@@ -2955,6 +2982,21 @@
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs",
"errors": "Errors",
"noErrors": "No errors",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"promptErrors": {
"prompt_no_outputs": {
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
},
"no_prompt": {
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
}
},
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",
"errorHelpSupport": "contact our support",
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters"
},
@@ -2978,6 +3020,20 @@
"failed": "Failed"
}
},
"exportToast": {
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",
"exportCompleted": "ZIP download ready",
"exportFailedSingle": "Failed to create ZIP export",
"downloadExport": "Download export",
"downloadFailed": "Failed to download \"{name}\"",
"retryDownload": "Retry download"
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",

View File

@@ -10399,6 +10399,32 @@
}
}
},
"NAGuidance": {
"display_name": "Normalized Attention Guidance",
"description": "Applies Normalized Attention Guidance to models, enabling negative prompts on distilled/schnell models.",
"inputs": {
"model": {
"name": "model",
"tooltip": "The model to apply NAG to."
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "The guidance scale factor. Higher values push further from the negative prompt."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Blending factor for the normalized attention. 1.0 is full replacement, 0.0 is no effect."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "The patched model with NAG enabled."
}
}
},
"NormalizeImages": {
"display_name": "Normalize Images",
"inputs": {
@@ -11647,7 +11673,7 @@
},
"RecraftStyleV3InfiniteStyleLibrary": {
"display_name": "Recraft Style - Infinite Style Library",
"description": "Select style based on preexisting UUID from Recraft's Infinite Style Library.",
"description": "Choose style based on preexisting UUID from Recraft's Infinite Style Library.",
"inputs": {
"style_id": {
"name": "style_id",
@@ -11773,6 +11799,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"display_name": "Recraft V4 Text to Image",
"description": "Generates images using Recraft V4 or V4 Pro models.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "An optional text description of undesired elements on an image."
},
"model": {
"name": "model",
"tooltip": "The model to use for generation."
},
"n": {
"name": "n",
"tooltip": "The number of images to generate."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
},
"control_after_generate": {
"name": "control after generate"
},
"model_size": {
"name": "size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"display_name": "Recraft V4 Text to Vector",
"description": "Generates SVG using Recraft V4 or V4 Pro models.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "An optional text description of undesired elements on an image."
},
"model": {
"name": "model",
"tooltip": "The model to use for generation."
},
"n": {
"name": "n",
"tooltip": "The number of images to generate."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
},
"control_after_generate": {
"name": "control after generate"
},
"model_size": {
"name": "size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"display_name": "Recraft Vectorize Image",
"description": "Generates SVG synchronously from an input image.",
@@ -15984,6 +16092,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"display_name": "Vidu Q3 Start/End Frame-to-Video Generation",
"description": "Generate a video from a start frame, an end frame, and a prompt.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Model to use for video generation."
},
"first_frame": {
"name": "first_frame"
},
"end_frame": {
"name": "end_frame"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt description (max 2000 characters)."
},
"seed": {
"name": "seed"
},
"control_after_generate": {
"name": "control after generate"
},
"model_audio": {
"name": "audio"
},
"model_duration": {
"name": "duration"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"display_name": "Vidu Q3 Text-to-Video Generation",
"description": "Generate video from a text prompt.",

View File

@@ -285,8 +285,8 @@
"name": "Show API node pricing badge"
},
"Comfy_NodeReplacement_Enabled": {
"name": "Enable automatic node replacement",
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
"name": "Enable node replacement suggestions",
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",
@@ -350,6 +350,10 @@
"name": "Batch count limit",
"tooltip": "The maximum number of tasks added to the queue at one button click"
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "Show errors tab in side panel",
"tooltip": "When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance."
},
"Comfy_Sidebar_Location": {
"name": "Sidebar location",
"options": {

View File

@@ -1898,6 +1898,25 @@
"outputs": "Salidas",
"type": "Tipo"
},
"nodeReplacement": {
"compatibleAlternatives": "Alternativas compatibles",
"installMissingNodes": "Instalar nodos faltantes",
"installationRequired": "Instalación requerida",
"instructionMessage": "Debes instalar estos nodos o reemplazarlos por alternativas instaladas para ejecutar el flujo de trabajo. Los nodos faltantes están resaltados en {red} en el lienzo. Algunos nodos no se pueden intercambiar y deben instalarse mediante el Administrador de Nodos.",
"notReplaceable": "Instalación requerida",
"openNodeManager": "Abrir Administrador de Nodos",
"quickFixAvailable": "Solución rápida disponible",
"redHighlight": "rojo",
"replaceFailed": "Error al reemplazar nodos",
"replaceSelected": "Reemplazar seleccionados ({count})",
"replaceWarning": "Esto modificará permanentemente el flujo de trabajo. Guarda una copia primero si no estás seguro.",
"replaceable": "Reemplazable",
"replaced": "Reemplazado",
"replacedAllNodes": "Reemplazados {count} tipo(s) de nodo",
"replacedNode": "Nodo reemplazado: {nodeType}",
"selectAll": "Seleccionar todo",
"skipForNow": "Omitir por ahora"
},
"nodeTemplates": {
"enterName": "Introduzca el nombre",
"saveAsTemplate": "Guardar como plantilla"
@@ -2414,8 +2433,11 @@
"moreOptions": "Más opciones",
"noActiveJobs": "No hay trabajos activos",
"preview": "Vista previa",
"queuedJobsLabel": "{count} en cola",
"queuedSuffix": "en cola",
"running": "en ejecución",
"runningJobsLabel": "{count} en ejecución",
"runningQueuedSummary": "{running}, {queued}",
"showAssets": "Mostrar recursos",
"showAssetsPanel": "Mostrar panel de recursos",
"sortBy": "Ordenar por",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "Aplica la Guía de Atención Normalizada a los modelos, permitiendo prompts negativos en modelos distilled/schnell.",
"display_name": "Guía de Atención Normalizada",
"inputs": {
"model": {
"name": "modelo",
"tooltip": "El modelo al que se aplicará NAG."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Factor de mezcla para la atención normalizada. 1.0 es reemplazo total, 0.0 sin efecto."
},
"nag_scale": {
"name": "escala_nag",
"tooltip": "El factor de escala de la guía. Valores más altos alejan más del prompt negativo."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "El modelo modificado con NAG habilitado."
}
}
},
"NormalizeImages": {
"display_name": "Normalizar Imágenes",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Genera imágenes usando los modelos Recraft V4 o V4 Pro.",
"display_name": "Recraft V4 Texto a Imagen",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la generación."
},
"model_size": {
"name": "tamaño"
},
"n": {
"name": "n",
"tooltip": "El número de imágenes a generar."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Genera SVG usando los modelos Recraft V4 o V4 Pro.",
"display_name": "Recraft V4 Texto a Vector",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la generación."
},
"model_size": {
"name": "tamaño"
},
"n": {
"name": "n",
"tooltip": "El número de imágenes a generar."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Genera SVG de forma sincrónica a partir de una imagen de entrada.",
"display_name": "Recraft Vectorizar Imagen",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Genera un video a partir de un fotograma inicial, un fotograma final y un prompt.",
"display_name": "Generación de video de inicio/fin de Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"end_frame": {
"name": "fotograma final"
},
"first_frame": {
"name": "fotograma inicial"
},
"model": {
"name": "modelo",
"tooltip": "Modelo a utilizar para la generación de video."
},
"model_audio": {
"name": "audio"
},
"model_duration": {
"name": "duración"
},
"model_resolution": {
"name": "resolución"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción del prompt (máximo 2000 caracteres)."
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Genera un video a partir de un prompt de texto.",
"display_name": "Generación de video de texto a video Vidu Q3",

View File

@@ -1898,6 +1898,25 @@
"outputs": "خروجی‌ها",
"type": "نوع"
},
"nodeReplacement": {
"compatibleAlternatives": "گزینه‌های سازگار",
"installMissingNodes": "نصب نودهای مفقود",
"installationRequired": "نصب مورد نیاز است",
"instructionMessage": "برای اجرای workflow باید این نودها را نصب یا با گزینه‌های نصب‌شده جایگزین کنید. نودهای مفقود با رنگ {red} روی بوم مشخص شده‌اند. برخی نودها قابل تعویض نیستند و باید از طریق Node Manager نصب شوند.",
"notReplaceable": "نیاز به نصب",
"openNodeManager": "باز کردن Node Manager",
"quickFixAvailable": "رفع سریع در دسترس است",
"redHighlight": "قرمز",
"replaceFailed": "جایگزینی نودها ناموفق بود",
"replaceSelected": "جایگزینی انتخاب‌شده‌ها ({count})",
"replaceWarning": "این کار workflow را به طور دائمی تغییر می‌دهد. اگر مطمئن نیستید، ابتدا یک نسخه ذخیره کنید.",
"replaceable": "قابل جایگزینی",
"replaced": "جایگزین شد",
"replacedAllNodes": "{count} نوع نود جایگزین شد",
"replacedNode": "نود جایگزین شد: {nodeType}",
"selectAll": "انتخاب همه",
"skipForNow": "فعلاً رد شود"
},
"nodeTemplates": {
"enterName": "نام را وارد کنید",
"saveAsTemplate": "ذخیره به عنوان قالب"
@@ -2425,8 +2444,11 @@
"moreOptions": "گزینه‌های بیشتر",
"noActiveJobs": "کار فعالی وجود ندارد",
"preview": "پیش‌نمایش",
"queuedJobsLabel": "{count} در صف",
"queuedSuffix": "در صف",
"running": "در حال اجرا",
"runningJobsLabel": "{count} در حال اجرا",
"runningQueuedSummary": "{running}، {queued}",
"showAssets": "نمایش دارایی‌ها",
"showAssetsPanel": "نمایش پنل دارایی‌ها",
"sortBy": "مرتب‌سازی بر اساس",

View File

@@ -10340,6 +10340,32 @@
}
}
},
"NAGuidance": {
"description": "راهنمای توجه نرمال‌سازی‌شده را به مدل‌ها اعمال می‌کند و امکان استفاده از پرامپت منفی را در مدل‌های distilled/schnell فراهم می‌سازد.",
"display_name": "راهنمای توجه نرمال‌سازی‌شده",
"inputs": {
"model": {
"name": "مدل",
"tooltip": "مدلی که NAG بر روی آن اعمال می‌شود."
},
"nag_alpha": {
"name": "ضریب ترکیب",
"tooltip": "ضریب ترکیب برای توجه نرمال‌سازی‌شده. مقدار ۱.۰ به معنای جایگزینی کامل و ۰.۰ بدون تأثیر است."
},
"nag_scale": {
"name": "مقیاس راهنما",
"tooltip": "ضریب مقیاس راهنما. مقادیر بالاتر فاصله بیشتری از پرامپت منفی ایجاد می‌کند."
},
"nag_tau": {
"name": "تاو"
}
},
"outputs": {
"0": {
"tooltip": "مدل اصلاح‌شده با فعال‌سازی NAG."
}
}
},
"NormalizeImages": {
"display_name": "نرمال‌سازی تصاویر",
"inputs": {
@@ -11714,6 +11740,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "تولید تصویر با استفاده از مدل‌های Recraft V4 یا V4 Pro.",
"display_name": "تبدیل متن به تصویر Recraft V4",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدل مورد استفاده برای تولید."
},
"model_size": {
"name": "اندازه"
},
"n": {
"name": "تعداد",
"tooltip": "تعداد تصاویر تولیدی."
},
"negative_prompt": {
"name": "پرامپت منفی",
"tooltip": "توضیح متنی اختیاری برای عناصر نامطلوب در تصویر."
},
"prompt": {
"name": "پرامپت",
"tooltip": "پرامپت برای تولید تصویر. حداکثر ۱۰٬۰۰۰ کاراکتر."
},
"recraft_controls": {
"name": "کنترل‌های Recraft",
"tooltip": "کنترل‌های اختیاری بیشتر بر تولید از طریق node کنترل‌های Recraft."
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "تولید SVG با استفاده از مدل‌های Recraft V4 یا V4 Pro.",
"display_name": "تبدیل متن به وکتور Recraft V4",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدل مورد استفاده برای تولید."
},
"model_size": {
"name": "اندازه"
},
"n": {
"name": "تعداد",
"tooltip": "تعداد تصاویر تولیدی."
},
"negative_prompt": {
"name": "پرامپت منفی",
"tooltip": "توضیح متنی اختیاری برای عناصر نامطلوب در تصویر."
},
"prompt": {
"name": "پرامپت",
"tooltip": "پرامپت برای تولید تصویر. حداکثر ۱۰٬۰۰۰ کاراکتر."
},
"recraft_controls": {
"name": "کنترل‌های Recraft",
"tooltip": "کنترل‌های اختیاری بیشتر بر تولید از طریق node کنترل‌های Recraft."
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "تولید SVG به صورت همزمان از یک تصویر ورودی.",
"display_name": "وکتورسازی تصویر Recraft",
@@ -15859,6 +15967,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "تولید یک ویدیو از یک فریم آغازین، یک فریم پایانی و یک پرامپت.",
"display_name": "تولید ویدیو از فریم آغازین/پایانی Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"end_frame": {
"name": "فریم پایانی"
},
"first_frame": {
"name": "فریم آغازین"
},
"model": {
"name": "مدل",
"tooltip": "مدلی که برای تولید ویدیو استفاده می‌شود."
},
"model_audio": {
"name": "صدا"
},
"model_duration": {
"name": "مدت زمان"
},
"model_resolution": {
"name": "وضوح"
},
"prompt": {
"name": "پرامپت",
"tooltip": "توضیح پرامپت (حداکثر ۲۰۰۰ کاراکتر)."
},
"seed": {
"name": "بذر"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "تولید ویدیو از یک پرامپت متنی.",
"display_name": "تولید ویدیو از متن Vidu Q3",

View File

@@ -1898,6 +1898,25 @@
"outputs": "Sorties",
"type": "Type"
},
"nodeReplacement": {
"compatibleAlternatives": "Alternatives compatibles",
"installMissingNodes": "Installer les nœuds manquants",
"installationRequired": "Installation requise",
"instructionMessage": "Vous devez installer ces nœuds ou les remplacer par des alternatives installées pour exécuter le workflow. Les nœuds manquants sont surlignés en {red} sur le canevas. Certains nœuds ne peuvent pas être remplacés et doivent être installés via le Gestionnaire de nœuds.",
"notReplaceable": "Installation requise",
"openNodeManager": "Ouvrir le Gestionnaire de nœuds",
"quickFixAvailable": "Correction rapide disponible",
"redHighlight": "rouge",
"replaceFailed": "Échec du remplacement des nœuds",
"replaceSelected": "Remplacer la sélection ({count})",
"replaceWarning": "Cela modifiera définitivement le workflow. Sauvegardez une copie si vous nêtes pas sûr.",
"replaceable": "Remplaçable",
"replaced": "Remplacé",
"replacedAllNodes": "{count} type(s) de nœud remplacé(s)",
"replacedNode": "Nœud remplacé : {nodeType}",
"selectAll": "Tout sélectionner",
"skipForNow": "Ignorer pour linstant"
},
"nodeTemplates": {
"enterName": "Entrez le nom",
"saveAsTemplate": "Enregistrer comme modèle"
@@ -2414,8 +2433,11 @@
"moreOptions": "Plus doptions",
"noActiveJobs": "Aucun travail actif",
"preview": "Aperçu",
"queuedJobsLabel": "{count} en file dattente",
"queuedSuffix": "en file dattente",
"running": "en cours",
"runningJobsLabel": "{count} en cours",
"runningQueuedSummary": "{running} en cours, {queued} en file",
"showAssets": "Afficher les ressources",
"showAssetsPanel": "Afficher le panneau des ressources",
"sortBy": "Trier par",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "Applique le Guidage dAttention Normalisée aux modèles, permettant lutilisation de prompts négatifs sur les modèles distilled/schnell.",
"display_name": "Guidage dAttention Normalisée",
"inputs": {
"model": {
"name": "modèle",
"tooltip": "Le modèle auquel appliquer NAG."
},
"nag_alpha": {
"name": "alpha_nag",
"tooltip": "Facteur de fusion pour lattention normalisée. 1,0 correspond à un remplacement total, 0,0 à aucun effet."
},
"nag_scale": {
"name": "facteur_nag",
"tooltip": "Le facteur déchelle du guidage. Des valeurs plus élevées éloignent davantage du prompt négatif."
},
"nag_tau": {
"name": "tau_nag"
}
},
"outputs": {
"0": {
"tooltip": "Le modèle modifié avec NAG activé."
}
}
},
"NormalizeImages": {
"display_name": "Normaliser les images",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Génère des images à laide des modèles Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texte vers Image",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Le modèle à utiliser pour la génération."
},
"model_size": {
"name": "taille"
},
"n": {
"name": "n",
"tooltip": "Nombre dimages à générer."
},
"negative_prompt": {
"name": "prompt_négatif",
"tooltip": "Description textuelle optionnelle des éléments indésirables sur une image."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt pour la génération dimage. Maximum 10 000 caractères."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Contrôles supplémentaires optionnels sur la génération via le nœud Recraft Controls."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Génère des SVG à laide des modèles Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texte vers Vectoriel",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Le modèle à utiliser pour la génération."
},
"model_size": {
"name": "taille"
},
"n": {
"name": "n",
"tooltip": "Nombre dimages à générer."
},
"negative_prompt": {
"name": "prompt_négatif",
"tooltip": "Description textuelle optionnelle des éléments indésirables sur une image."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt pour la génération dimage. Maximum 10 000 caractères."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Contrôles supplémentaires optionnels sur la génération via le nœud Recraft Controls."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Génère un SVG de manière synchrone à partir d'une image d'entrée.",
"display_name": "Vectoriser une image avec Recraft",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Générez une vidéo à partir d'une image de début, d'une image de fin et d'une invite.",
"display_name": "Génération vidéo Vidu Q3 à partir d'une image de début/fin",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"end_frame": {
"name": "image de fin"
},
"first_frame": {
"name": "image de début"
},
"model": {
"name": "modèle",
"tooltip": "Modèle à utiliser pour la génération vidéo."
},
"model_audio": {
"name": "audio"
},
"model_duration": {
"name": "durée"
},
"model_resolution": {
"name": "résolution"
},
"prompt": {
"name": "invite",
"tooltip": "Description de l'invite (2000 caractères max)."
},
"seed": {
"name": "graine"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Générez une vidéo à partir dune invite textuelle.",
"display_name": "Génération de vidéo à partir de texte Vidu Q3",

View File

@@ -1898,6 +1898,25 @@
"outputs": "出力",
"type": "タイプ"
},
"nodeReplacement": {
"compatibleAlternatives": "互換性のある代替案",
"installMissingNodes": "不足ノードをインストール",
"installationRequired": "インストールが必要",
"instructionMessage": "ワークフローを実行するには、これらのノードをインストールするか、インストール済みの代替ノードに置き換える必要があります。足りないノードはキャンバス上で{red}でハイライトされています。一部のードは置き換えできず、Node Managerからインストールする必要があります。",
"notReplaceable": "インストールが必要",
"openNodeManager": "Node Managerを開く",
"quickFixAvailable": "クイック修正可能",
"redHighlight": "赤",
"replaceFailed": "ノードの置き換えに失敗しました",
"replaceSelected": "選択したものを置き換え ({count})",
"replaceWarning": "この操作はワークフローを永久に変更します。心配な場合は、先にコピーを保存してください。",
"replaceable": "置き換え可能",
"replaced": "置き換え済み",
"replacedAllNodes": "{count} 種類のノードを置き換えました",
"replacedNode": "置き換えたノード: {nodeType}",
"selectAll": "すべて選択",
"skipForNow": "今はスキップ"
},
"nodeTemplates": {
"enterName": "名前を入力",
"saveAsTemplate": "テンプレートとして保存"
@@ -2414,8 +2433,11 @@
"moreOptions": "その他のオプション",
"noActiveJobs": "アクティブなジョブはありません",
"preview": "プレビュー",
"queuedJobsLabel": "{count} キュー中",
"queuedSuffix": "キュー済み",
"running": "実行中",
"runningJobsLabel": "{count} 実行中",
"runningQueuedSummary": "{running} 実行中、{queued} キュー中",
"showAssets": "アセットを表示",
"showAssetsPanel": "アセットパネルを表示",
"sortBy": "並べ替え条件",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "モデルに正規化アテンションガイダンスを適用し、distilled/schnellモデルでネガティブプロンプトを有効にします。",
"display_name": "正規化アテンションガイダンス",
"inputs": {
"model": {
"name": "model",
"tooltip": "NAGを適用するモデル。"
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "正規化アテンションのブレンド係数。1.0は完全な置換、0.0は効果なし。"
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "ガイダンスのスケール係数。値が高いほどネガティブプロンプトからさらに離れます。"
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "NAGが有効化されたパッチ済みモデル。"
}
}
},
"NormalizeImages": {
"display_name": "画像を正規化",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Recraft V4またはV4 Proモデルを使用して画像を生成します。",
"display_name": "Recraft V4 テキストから画像生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "model",
"tooltip": "生成に使用するモデル。"
},
"model_size": {
"name": "サイズ"
},
"n": {
"name": "n",
"tooltip": "生成する画像の枚数。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "画像に含めたくない要素のテキスト説明(任意)。"
},
"prompt": {
"name": "prompt",
"tooltip": "画像生成用のプロンプト。最大10,000文字。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controlsードによる追加の生成コントロール任意。"
},
"seed": {
"name": "seed",
"tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Recraft V4またはV4 Proモデルを使用してSVGを生成します。",
"display_name": "Recraft V4 テキストからベクター生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "model",
"tooltip": "生成に使用するモデル。"
},
"model_size": {
"name": "サイズ"
},
"n": {
"name": "n",
"tooltip": "生成する画像の枚数。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "画像に含めたくない要素のテキスト説明(任意)。"
},
"prompt": {
"name": "prompt",
"tooltip": "画像生成用のプロンプト。最大10,000文字。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controlsードによる追加の生成コントロール任意。"
},
"seed": {
"name": "seed",
"tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "入力画像からSVGを同期的に生成します。",
"display_name": "Recraft ベクトル化画像",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "開始フレーム、終了フレーム、およびプロンプトから動画を生成します。",
"display_name": "Vidu Q3 開始/終了フレームからの動画生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"end_frame": {
"name": "終了フレーム"
},
"first_frame": {
"name": "開始フレーム"
},
"model": {
"name": "モデル",
"tooltip": "動画生成に使用するモデル。"
},
"model_audio": {
"name": "オーディオ"
},
"model_duration": {
"name": "再生時間"
},
"model_resolution": {
"name": "解像度"
},
"prompt": {
"name": "プロンプト",
"tooltip": "プロンプトの説明最大2000文字。"
},
"seed": {
"name": "シード"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "テキストプロンプトから動画を生成します。",
"display_name": "Vidu Q3 テキストから動画生成",

View File

@@ -1898,6 +1898,25 @@
"outputs": "출력",
"type": "유형"
},
"nodeReplacement": {
"compatibleAlternatives": "호환 가능한 대안",
"installMissingNodes": "누락된 노드 설치",
"installationRequired": "설치 필요",
"instructionMessage": "워크플로를 실행하려면 이 노드를 설치하거나 설치된 대안으로 교체해야 합니다. 누락된 노드는 캔버스에서 {red}로 강조 표시됩니다. 일부 노드는 교체할 수 없으므로 Node Manager를 통해 설치해야 합니다.",
"notReplaceable": "설치 필요",
"openNodeManager": "Node Manager 열기",
"quickFixAvailable": "빠른 수정 가능",
"redHighlight": "빨간색",
"replaceFailed": "노드 교체 실패",
"replaceSelected": "선택한 항목 교체 ({count})",
"replaceWarning": "이 작업은 워크플로를 영구적으로 수정합니다. 확실하지 않으면 먼저 복사본을 저장하세요.",
"replaceable": "교체 가능",
"replaced": "교체됨",
"replacedAllNodes": "{count}개 노드 유형 교체됨",
"replacedNode": "교체된 노드: {nodeType}",
"selectAll": "전체 선택",
"skipForNow": "일단 건너뛰기"
},
"nodeTemplates": {
"enterName": "이름 입력",
"saveAsTemplate": "템플릿으로 저장"
@@ -2414,8 +2433,11 @@
"moreOptions": "더 많은 옵션",
"noActiveJobs": "활성 작업 없음",
"preview": "미리보기",
"queuedJobsLabel": "{count}개 대기 중",
"queuedSuffix": "대기 중",
"running": "실행 중",
"runningJobsLabel": "{count}개 실행 중",
"runningQueuedSummary": "{running} 실행 중, {queued} 대기 중",
"showAssets": "에셋 보기",
"showAssetsPanel": "에셋 패널 보기",
"sortBy": "정렬 기준",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "정규화된 어텐션 가이던스를 모델에 적용하여, distilled/schnell 모델에서 네거티브 프롬프트를 사용할 수 있게 합니다.",
"display_name": "정규화된 어텐션 가이던스",
"inputs": {
"model": {
"name": "model",
"tooltip": "NAG를 적용할 모델입니다."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "정규화된 어텐션의 블렌딩 계수입니다. 1.0은 완전 대체, 0.0은 효과 없음입니다."
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "가이던스 스케일 계수입니다. 값이 높을수록 네거티브 프롬프트에서 더 멀어집니다."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "NAG가 활성화된 패치된 모델입니다."
}
}
},
"NormalizeImages": {
"display_name": "이미지 정규화",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Recraft V4 또는 V4 Pro 모델을 사용하여 이미지를 생성합니다.",
"display_name": "Recraft V4 텍스트-이미지",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "생성에 사용할 모델입니다."
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "생성할 이미지의 개수입니다."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "이미지에서 원하지 않는 요소에 대한 선택적 텍스트 설명입니다."
},
"prompt": {
"name": "prompt",
"tooltip": "이미지 생성을 위한 프롬프트입니다. 최대 10,000자까지 입력 가능합니다."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controls 노드를 통한 추가 생성 제어(선택 사항)입니다."
},
"seed": {
"name": "seed",
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Recraft V4 또는 V4 Pro 모델을 사용하여 SVG를 생성합니다.",
"display_name": "Recraft V4 텍스트-벡터",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "생성에 사용할 모델입니다."
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "생성할 이미지의 개수입니다."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "이미지에서 원하지 않는 요소에 대한 선택적 텍스트 설명입니다."
},
"prompt": {
"name": "prompt",
"tooltip": "이미지 생성을 위한 프롬프트입니다. 최대 10,000자까지 입력 가능합니다."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controls 노드를 통한 추가 생성 제어(선택 사항)입니다."
},
"seed": {
"name": "seed",
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "입력 이미지로부터 SVG를 동기적으로 생성합니다.",
"display_name": "Recraft 벡터 생성 (이미지 → 벡터)",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "시작 프레임, 종료 프레임, 프롬프트를 사용하여 비디오를 생성합니다.",
"display_name": "Vidu Q3 시작/종료 프레임-투-비디오 생성",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"end_frame": {
"name": "종료 프레임"
},
"first_frame": {
"name": "시작 프레임"
},
"model": {
"name": "모델",
"tooltip": "비디오 생성을 위해 사용할 모델입니다."
},
"model_audio": {
"name": "오디오"
},
"model_duration": {
"name": "길이"
},
"model_resolution": {
"name": "해상도"
},
"prompt": {
"name": "프롬프트",
"tooltip": "프롬프트 설명 (최대 2000자)."
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "텍스트 프롬프트로부터 비디오를 생성합니다.",
"display_name": "Vidu Q3 텍스트-비디오 생성",

View File

@@ -249,8 +249,8 @@
"name": "API 노드 가격 배지 표시"
},
"Comfy_NodeReplacement_Enabled": {
"name": "자동 노드 교체 활성화",
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
"name": "노드 교체 제안 활성화",
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",

View File

@@ -1898,6 +1898,25 @@
"outputs": "Saídas",
"type": "Tipo"
},
"nodeReplacement": {
"compatibleAlternatives": "Alternativas Compatíveis",
"installMissingNodes": "Instalar Nós Ausentes",
"installationRequired": "Instalação Necessária",
"instructionMessage": "Você deve instalar esses nós ou substituí-los por alternativas já instaladas para executar o fluxo de trabalho. Nós ausentes estão destacados em {red} na tela. Alguns nós não podem ser trocados e devem ser instalados pelo Gerenciador de Nós.",
"notReplaceable": "Instalação Necessária",
"openNodeManager": "Abrir Gerenciador de Nós",
"quickFixAvailable": "Correção Rápida Disponível",
"redHighlight": "vermelho",
"replaceFailed": "Falha ao substituir nós",
"replaceSelected": "Substituir Selecionados ({count})",
"replaceWarning": "Isso modificará permanentemente o fluxo de trabalho. Salve uma cópia antes se não tiver certeza.",
"replaceable": "Substituível",
"replaced": "Substituído",
"replacedAllNodes": "Substituídos {count} tipo(s) de nó",
"replacedNode": "Nó substituído: {nodeType}",
"selectAll": "Selecionar Tudo",
"skipForNow": "Pular por enquanto"
},
"nodeTemplates": {
"enterName": "Digite o nome",
"saveAsTemplate": "Salvar como modelo"
@@ -2425,8 +2444,11 @@
"moreOptions": "Mais opções",
"noActiveJobs": "Nenhum trabalho ativo",
"preview": "Pré-visualização",
"queuedJobsLabel": "{count} na fila",
"queuedSuffix": "na fila",
"running": "executando",
"runningJobsLabel": "{count} em execução",
"runningQueuedSummary": "{running} em execução, {queued} na fila",
"showAssets": "Mostrar ativos",
"showAssetsPanel": "Mostrar painel de ativos",
"sortBy": "Ordenar por",

View File

@@ -10340,6 +10340,32 @@
}
}
},
"NAGuidance": {
"description": "Aplica Orientação de Atenção Normalizada aos modelos, permitindo prompts negativos em modelos distilled/schnell.",
"display_name": "Orientação de Atenção Normalizada",
"inputs": {
"model": {
"name": "modelo",
"tooltip": "O modelo ao qual aplicar o NAG."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Fator de mesclagem para a atenção normalizada. 1.0 é substituição total, 0.0 não tem efeito."
},
"nag_scale": {
"name": "escala_nag",
"tooltip": "O fator de escala da orientação. Valores mais altos afastam mais do prompt negativo."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "O modelo modificado com NAG ativado."
}
}
},
"NormalizeImages": {
"display_name": "Normalizar Imagens",
"inputs": {
@@ -11714,6 +11740,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Gera imagens usando os modelos Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texto para Imagem",
"inputs": {
"control_after_generate": {
"name": "controle após gerar"
},
"model": {
"name": "modelo",
"tooltip": "O modelo a ser usado para geração."
},
"model_size": {
"name": "tamanho"
},
"n": {
"name": "n",
"tooltip": "O número de imagens a serem geradas."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Uma descrição opcional em texto de elementos indesejados em uma imagem."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para a geração da imagem. Máximo de 10.000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionais opcionais sobre a geração via o nó Recraft Controls."
},
"seed": {
"name": "semente",
"tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Gera SVG usando os modelos Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texto para Vetor",
"inputs": {
"control_after_generate": {
"name": "controle após gerar"
},
"model": {
"name": "modelo",
"tooltip": "O modelo a ser usado para geração."
},
"model_size": {
"name": "tamanho"
},
"n": {
"name": "n",
"tooltip": "O número de imagens a serem geradas."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Uma descrição opcional em texto de elementos indesejados em uma imagem."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para a geração da imagem. Máximo de 10.000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionais opcionais sobre a geração via o nó Recraft Controls."
},
"seed": {
"name": "semente",
"tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Gera SVG de forma síncrona a partir de uma imagem de entrada.",
"display_name": "Recraft Vetorizar Imagem",
@@ -15859,6 +15967,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Gere um vídeo a partir de um quadro inicial, um quadro final e um prompt.",
"display_name": "Geração de Vídeo Quadro Inicial/Final Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "controle após gerar"
},
"end_frame": {
"name": "quadro final"
},
"first_frame": {
"name": "quadro inicial"
},
"model": {
"name": "modelo",
"tooltip": "Modelo a ser usado para geração de vídeo."
},
"model_audio": {
"name": "áudio"
},
"model_duration": {
"name": "duração"
},
"model_resolution": {
"name": "resolução"
},
"prompt": {
"name": "prompt",
"tooltip": "Descrição do prompt (máx. 2000 caracteres)."
},
"seed": {
"name": "semente"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Gere um vídeo a partir de um prompt de texto.",
"display_name": "Geração de Vídeo de Texto para Vídeo Vidu Q3",

View File

@@ -1898,6 +1898,25 @@
"outputs": "Выходы",
"type": "Тип"
},
"nodeReplacement": {
"compatibleAlternatives": "Совместимые альтернативы",
"installMissingNodes": "Установить отсутствующие узлы",
"installationRequired": "Требуется установка",
"instructionMessage": "Вам необходимо установить эти узлы или заменить их установленными альтернативами, чтобы запустить рабочий процесс. Отсутствующие узлы выделены {red} на холсте. Некоторые узлы нельзя заменить, их нужно установить через Менеджер узлов.",
"notReplaceable": "Требуется установка",
"openNodeManager": "Открыть Менеджер узлов",
"quickFixAvailable": "Доступно быстрое исправление",
"redHighlight": "красным",
"replaceFailed": "Не удалось заменить узлы",
"replaceSelected": "Заменить выбранные ({count})",
"replaceWarning": "Это действие навсегда изменит рабочий процесс. Сохраните копию, если не уверены.",
"replaceable": "Можно заменить",
"replaced": "Заменено",
"replacedAllNodes": "Заменено {count} типов(а) узлов",
"replacedNode": "Заменённый узел: {nodeType}",
"selectAll": "Выбрать все",
"skipForNow": "Пропустить сейчас"
},
"nodeTemplates": {
"enterName": "Введите название",
"saveAsTemplate": "Сохранить как шаблон"
@@ -2414,8 +2433,11 @@
"moreOptions": "Больше опций",
"noActiveJobs": "Нет активных заданий",
"preview": "Предпросмотр",
"queuedJobsLabel": "{count} в очереди",
"queuedSuffix": "в очереди",
"running": "выполняется",
"runningJobsLabel": "{count} выполняется",
"runningQueuedSummary": "{running}, {queued}",
"showAssets": "Показать ассеты",
"showAssetsPanel": "Показать панель ассетов",
"sortBy": "Сортировать по",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "Применяет нормализованное управление вниманием к моделям, позволяя использовать негативные подсказки на distilled/schnell моделях.",
"display_name": "Нормализованное управление вниманием",
"inputs": {
"model": {
"name": "model",
"tooltip": "Модель, к которой применяется NAG."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Коэффициент смешивания для нормализованного внимания. 1.0 — полная замена, 0.0 — без эффекта."
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "Коэффициент масштаба управления. Более высокие значения сильнее отдаляют от негативной подсказки."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "Патченная модель с включённым NAG."
}
}
},
"NormalizeImages": {
"display_name": "Нормализовать изображения",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Генерирует изображения с помощью моделей Recraft V4 или V4 Pro.",
"display_name": "Recraft V4: текст в изображение",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "Модель, используемая для генерации."
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "Количество изображений для генерации."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Необязательное текстовое описание нежелательных элементов на изображении."
},
"prompt": {
"name": "prompt",
"tooltip": "Подсказка для генерации изображения. Максимум 10 000 символов."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Необязательные дополнительные параметры управления генерацией через узел Recraft Controls."
},
"seed": {
"name": "seed",
"tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты не детерминированы независимо от сида."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Генерирует SVG с помощью моделей Recraft V4 или V4 Pro.",
"display_name": "Recraft V4: текст в вектор",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "Модель, используемая для генерации."
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "Количество изображений для генерации."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Необязательное текстовое описание нежелательных элементов на изображении."
},
"prompt": {
"name": "prompt",
"tooltip": "Подсказка для генерации изображения. Максимум 10 000 символов."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Необязательные дополнительные параметры управления генерацией через узел Recraft Controls."
},
"seed": {
"name": "seed",
"tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты не детерминированы независимо от сида."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Генерирует SVG синхронно из входного изображения.",
"display_name": "Recraft Векторизация Изображения",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Создайте видео, используя начальный кадр, конечный кадр и текстовый запрос.",
"display_name": "Vidu Q3 Генерация видео по начальному и конечному кадрам",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"end_frame": {
"name": "конечный кадр"
},
"first_frame": {
"name": "начальный кадр"
},
"model": {
"name": "модель",
"tooltip": "Модель для генерации видео."
},
"model_audio": {
"name": "аудио"
},
"model_duration": {
"name": "длительность"
},
"model_resolution": {
"name": "разрешение"
},
"prompt": {
"name": "промпт",
"tooltip": "Описание промпта (максимум 2000 символов)."
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Создайте видео по текстовой подсказке.",
"display_name": "Vidu Q3: генерация видео по тексту",

View File

@@ -1898,6 +1898,25 @@
"outputs": ıktılar",
"type": "Tür"
},
"nodeReplacement": {
"compatibleAlternatives": "Uyumlu Alternatifler",
"installMissingNodes": "Eksik Düğümleri Yükle",
"installationRequired": "Kurulum Gerekli",
"instructionMessage": "İş akışını çalıştırmak için bu düğümleri yüklemeli veya yüklü alternatiflerle değiştirmelisiniz. Eksik düğümler tuvalde {red} ile vurgulanır. Bazı düğümler değiştirilemez ve Node Manager üzerinden yüklenmelidir.",
"notReplaceable": "Kurulum Gerekli",
"openNodeManager": "Node Manager'ı Aç",
"quickFixAvailable": "Hızlı Düzeltme Mevcut",
"redHighlight": "kırmızı",
"replaceFailed": "Düğümler değiştirilemedi",
"replaceSelected": "Seçilenleri Değiştir ({count})",
"replaceWarning": "Bu işlem iş akışını kalıcı olarak değiştirecek. Emin değilseniz önce bir kopyasını kaydedin.",
"replaceable": "Değiştirilebilir",
"replaced": "Değiştirildi",
"replacedAllNodes": "{count} düğüm türü değiştirildi",
"replacedNode": "Değiştirilen düğüm: {nodeType}",
"selectAll": "Tümünü Seç",
"skipForNow": "Şimdilik Atla"
},
"nodeTemplates": {
"enterName": "İsim girin",
"saveAsTemplate": "Şablon olarak kaydet"
@@ -2414,8 +2433,11 @@
"moreOptions": "Daha fazla seçenek",
"noActiveJobs": "Aktif iş yok",
"preview": "Önizleme",
"queuedJobsLabel": "{count} kuyruğa alındı",
"queuedSuffix": "kuyrukta",
"running": "çalışıyor",
"runningJobsLabel": "{count} çalışıyor",
"runningQueuedSummary": "{running}, {queued}",
"showAssets": "Varlıkları göster",
"showAssetsPanel": "Varlık panelini göster",
"sortBy": "Sırala",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "Modellere Normalize Edilmiş Dikkat Yönlendirmesi uygular, distilled/schnell modellerde negatif istemlere olanak tanır.",
"display_name": "Normalize Edilmiş Dikkat Yönlendirmesi",
"inputs": {
"model": {
"name": "model",
"tooltip": "NAG uygulanacak model."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Normalize edilmiş dikkat için karıştırma faktörü. 1.0 tam değişim, 0.0 hiçbir etki yok."
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "Yönlendirme ölçek faktörü. Daha yüksek değerler, negatif istemden daha fazla uzaklaştırır."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "NAG etkinleştirilmiş yamalı model."
}
}
},
"NormalizeImages": {
"display_name": "Görüntüleri Normalleştir",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Recraft V4 veya V4 Pro modelleriyle görseller üretir.",
"display_name": "Recraft V4 Metinden Görsele",
"inputs": {
"control_after_generate": {
"name": "üretimden sonra kontrol"
},
"model": {
"name": "model",
"tooltip": "Üretim için kullanılacak model."
},
"model_size": {
"name": "boyut"
},
"n": {
"name": "n",
"tooltip": "Üretilecek görsel sayısı."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Bir görselde istenmeyen ögelerin isteğe bağlı metin açıklaması."
},
"prompt": {
"name": "prompt",
"tooltip": "Görsel üretimi için istem. En fazla 10.000 karakter."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controls düğümüyle üretim üzerinde isteğe bağlı ek kontroller."
},
"seed": {
"name": "seed",
"tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; tohuma bakılmaksızın gerçek sonuçlar deterministik değildir."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Recraft V4 veya V4 Pro modelleriyle SVG üretir.",
"display_name": "Recraft V4 Metinden Vektöre",
"inputs": {
"control_after_generate": {
"name": "üretimden sonra kontrol"
},
"model": {
"name": "model",
"tooltip": "Üretim için kullanılacak model."
},
"model_size": {
"name": "boyut"
},
"n": {
"name": "n",
"tooltip": "Üretilecek görsel sayısı."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Bir görselde istenmeyen ögelerin isteğe bağlı metin açıklaması."
},
"prompt": {
"name": "prompt",
"tooltip": "Görsel üretimi için istem. En fazla 10.000 karakter."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controls düğümüyle üretim üzerinde isteğe bağlı ek kontroller."
},
"seed": {
"name": "seed",
"tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; tohuma bakılmaksızın gerçek sonuçlar deterministik değildir."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Bir giriş görüntüsünden eşzamanlı olarak SVG oluşturur.",
"display_name": "Recraft Görüntüyü Vektörleştir",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Bir başlangıç karesi, bir bitiş karesi ve bir komut istemi ile video oluşturun.",
"display_name": "Vidu Q3 Başlangıç/Bitiş Kareden Videoya Oluşturma",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"end_frame": {
"name": "bitiş karesi"
},
"first_frame": {
"name": "ilk kare"
},
"model": {
"name": "model",
"tooltip": "Video oluşturmak için kullanılacak model."
},
"model_audio": {
"name": "ses"
},
"model_duration": {
"name": "süre"
},
"model_resolution": {
"name": "çözünürlük"
},
"prompt": {
"name": "komut istemi",
"tooltip": "Komut istemi açıklaması (en fazla 2000 karakter)."
},
"seed": {
"name": "tohum"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Bir metin isteminden video oluşturun.",
"display_name": "Vidu Q3 Metinden Videoya Üretim",

View File

@@ -1898,6 +1898,25 @@
"outputs": "輸出",
"type": "類型"
},
"nodeReplacement": {
"compatibleAlternatives": "相容替代方案",
"installMissingNodes": "安裝缺少的節點",
"installationRequired": "需要安裝",
"instructionMessage": "您必須安裝這些節點,或以已安裝的替代方案進行替換,才能執行工作流程。缺少的節點會在畫布上以{red}標示。有些節點無法替換,必須透過節點管理器安裝。",
"notReplaceable": "需要安裝",
"openNodeManager": "開啟節點管理器",
"quickFixAvailable": "可用快速修復",
"redHighlight": "紅色",
"replaceFailed": "替換節點失敗",
"replaceSelected": "替換所選 ({count})",
"replaceWarning": "這將永久修改工作流程。如有疑慮,請先儲存副本。",
"replaceable": "可替換",
"replaced": "已替換",
"replacedAllNodes": "已替換 {count} 種節點類型",
"replacedNode": "已替換節點:{nodeType}",
"selectAll": "全選",
"skipForNow": "暫時略過"
},
"nodeTemplates": {
"enterName": "輸入名稱",
"saveAsTemplate": "儲存為範本"
@@ -2414,8 +2433,11 @@
"moreOptions": "更多選項",
"noActiveJobs": "沒有執行中作業",
"preview": "預覽",
"queuedJobsLabel": "{count} 已排隊",
"queuedSuffix": "已排入佇列",
"running": "執行中",
"runningJobsLabel": "{count} 執行中",
"runningQueuedSummary": "{running} 執行中, {queued} 已排隊",
"showAssets": "顯示資產",
"showAssetsPanel": "顯示資產面板",
"sortBy": "排序依據",

View File

@@ -10338,6 +10338,32 @@
}
}
},
"NAGuidance": {
"description": "對模型應用標準化注意力引導讓蒸餾schnell 模型支援負面提示。",
"display_name": "標準化注意力引導",
"inputs": {
"model": {
"name": "model",
"tooltip": "要套用 NAG 的模型。"
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "標準化注意力的混合係數。1.0 為完全取代0.0 則無效果。"
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "引導強度係數。數值越高,越遠離負面提示。"
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "已啟用 NAG 的修補模型。"
}
}
},
"NormalizeImages": {
"display_name": "標準化圖片",
"inputs": {
@@ -11712,6 +11738,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "使用 Recraft V4 或 V4 Pro 模型產生圖像。",
"display_name": "Recraft V4 文字轉圖像",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "用於生成的模型。"
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "要生成的圖像數量。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "可選的圖像中不希望出現元素的文字描述。"
},
"prompt": {
"name": "prompt",
"tooltip": "圖像生成提示語。最多 10,000 字元。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "可選,透過 Recraft Controls 節點進行額外生成控制。"
},
"seed": {
"name": "seed",
"tooltip": "決定節點是否重新執行的種子值;實際結果無論種子如何都不保證可重現。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "使用 Recraft V4 或 V4 Pro 模型產生 SVG。",
"display_name": "Recraft V4 文字轉向量圖",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "用於生成的模型。"
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "要生成的圖像數量。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "可選的圖像中不希望出現元素的文字描述。"
},
"prompt": {
"name": "prompt",
"tooltip": "圖像生成提示語。最多 10,000 字元。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "可選,透過 Recraft Controls 節點進行額外生成控制。"
},
"seed": {
"name": "seed",
"tooltip": "決定節點是否重新執行的種子值;實際結果無論種子如何都不保證可重現。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "從輸入圖片同步產生 SVG。",
"display_name": "Recraft 向量化圖片",
@@ -15846,6 +15954,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "根據起始影格、結束影格與提示詞生成影片。",
"display_name": "Vidu Q3 起始/結束影格轉影片生成",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"end_frame": {
"name": "結束影格"
},
"first_frame": {
"name": "起始影格"
},
"model": {
"name": "模型",
"tooltip": "用於影片生成的模型。"
},
"model_audio": {
"name": "音訊"
},
"model_duration": {
"name": "時長"
},
"model_resolution": {
"name": "解析度"
},
"prompt": {
"name": "提示詞",
"tooltip": "提示描述(最多 2000 字元)。"
},
"seed": {
"name": "隨機種子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "根據文字提示生成影片。",
"display_name": "Vidu Q3 文字轉影片生成",

View File

@@ -1898,6 +1898,25 @@
"outputs": "输出",
"type": "类型"
},
"nodeReplacement": {
"compatibleAlternatives": "兼容替代项",
"installMissingNodes": "安装缺失节点",
"installationRequired": "需要安装",
"instructionMessage": "您必须安装这些节点或用已安装的替代项替换它们,才能运行工作流。缺失的节点会在画布上以{red}高亮显示。有些节点无法替换,必须通过节点管理器安装。",
"notReplaceable": "需要安装",
"openNodeManager": "打开节点管理器",
"quickFixAvailable": "可用快速修复",
"redHighlight": "红色",
"replaceFailed": "替换节点失败",
"replaceSelected": "替换已选({count}",
"replaceWarning": "此操作将永久修改工作流。如不确定,请先保存副本。",
"replaceable": "可替换",
"replaced": "已替换",
"replacedAllNodes": "已替换 {count} 种节点类型",
"replacedNode": "已替换节点:{nodeType}",
"selectAll": "全选",
"skipForNow": "暂时跳过"
},
"nodeTemplates": {
"enterName": "输入名称",
"saveAsTemplate": "另存为模板"
@@ -2425,8 +2444,11 @@
"moreOptions": "更多设置",
"noActiveJobs": "无活跃任务",
"preview": "预览",
"queuedJobsLabel": "{count} 个已排队",
"queuedSuffix": "已执行",
"running": "运行中",
"runningJobsLabel": "{count} 个正在运行",
"runningQueuedSummary": "{running}{queued}",
"showAssets": "显示资产",
"showAssetsPanel": "显示资产面板",
"sortBy": "排序方式",

View File

@@ -10340,6 +10340,32 @@
}
}
},
"NAGuidance": {
"description": "对模型应用归一化注意力引导,使蒸馏/快速模型支持负向提示。",
"display_name": "归一化注意力引导",
"inputs": {
"model": {
"name": "model",
"tooltip": "要应用NAG的模型。"
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "归一化注意力的混合因子。1.0为完全替换0.0为无效果。"
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "引导缩放因子。数值越大,越远离负向提示。"
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "已启用NAG的修补模型。"
}
}
},
"NormalizeImages": {
"display_name": "规格化图像",
"inputs": {
@@ -11714,6 +11740,88 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "使用Recraft V4或V4 Pro模型生成图像。",
"display_name": "Recraft V4 文本转图像",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "用于生成的模型。"
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "要生成的图像数量。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "可选的图像中不希望出现元素的文本描述。"
},
"prompt": {
"name": "prompt",
"tooltip": "用于图像生成的提示词。最多10,000个字符。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "通过Recraft Controls节点对生成过程的可选附加控制。"
},
"seed": {
"name": "seed",
"tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "使用Recraft V4或V4 Pro模型生成SVG。",
"display_name": "Recraft V4 文本转矢量",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "用于生成的模型。"
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "要生成的图像数量。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "可选的图像中不希望出现元素的文本描述。"
},
"prompt": {
"name": "prompt",
"tooltip": "用于图像生成的提示词。最多10,000个字符。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "通过Recraft Controls节点对生成过程的可选附加控制。"
},
"seed": {
"name": "seed",
"tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "从输入图像同步生成 SVG。",
"display_name": "Recraft 矢量化图像",
@@ -15859,6 +15967,46 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "根据起始帧、结束帧和提示词生成视频。",
"display_name": "Vidu Q3 起始/结束帧到视频生成",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"end_frame": {
"name": "结束帧"
},
"first_frame": {
"name": "起始帧"
},
"model": {
"name": "模型",
"tooltip": "用于视频生成的模型。"
},
"model_audio": {
"name": "音频"
},
"model_duration": {
"name": "时长"
},
"model_resolution": {
"name": "分辨率"
},
"prompt": {
"name": "提示词",
"tooltip": "提示描述最多2000字符。"
},
"seed": {
"name": "种子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "根据文本提示生成视频。",
"display_name": "Vidu Q3 文本转视频生成",

View File

@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetExport } from '@/stores/assetExportStore'
import { useAssetExportStore } from '@/stores/assetExportStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const assetExportStore = useAssetExportStore()
const visible = computed(() => assetExportStore.hasExports)
const isExpanded = ref(false)
const exportJobs = computed(() => assetExportStore.exportList)
const failedJobs = computed(() =>
assetExportStore.finishedExports.filter((e) => e.status === 'failed')
)
const isInProgress = computed(() => assetExportStore.hasActiveExports)
const currentJobName = computed(() => {
const activeJob = exportJobs.value.find((job) => job.status === 'running')
return activeJob?.exportName || t('exportToast.preparingExport')
})
function jobDisplayName(job: AssetExport): string {
if (job.status === 'failed') return job.error || t('exportToast.exportError')
return job.exportName || t('exportToast.preparingExport')
}
const completedCount = computed(() => assetExportStore.finishedExports.length)
const totalCount = computed(() => exportJobs.value.length)
const footerLabel = computed(() => {
if (isInProgress.value) return currentJobName.value
if (failedJobs.value.length > 0)
return t('exportToast.exportFailed', { count: failedJobs.value.length })
return t('exportToast.allExportsCompleted')
})
const footerIconClass = computed(() => {
if (isInProgress.value)
return 'icon-[lucide--loader-circle] animate-spin text-muted-foreground'
if (failedJobs.value.length > 0)
return 'icon-[lucide--circle-alert] text-destructive-background'
return 'icon-[lucide--check-circle] text-jade-600'
})
const tooltipConfig = computed(() => ({
value: footerLabel.value,
disabled: isExpanded.value,
pt: { root: { class: 'z-10000!' } }
}))
function progressPercent(job: AssetExport): number {
return Math.round(job.progress * 100)
}
function closeDialog() {
assetExportStore.clearFinishedExports()
isExpanded.value = false
}
</script>
<template>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h3 class="text-sm font-bold text-base-foreground">
{{ t('exportToast.exportingAssets') }}
</h3>
</div>
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<div
v-for="job in exportJobs"
:key="job.taskId"
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
job.status === 'completed' && 'opacity-50'
)
"
>
<div class="min-w-0 flex-1">
<span
:class="
cn(
'block truncate text-sm',
job.status === 'failed'
? 'text-destructive-background'
: 'text-base-foreground'
)
"
>
{{ jobDisplayName(job) }}
</span>
<span
v-if="job.assetsTotal > 0"
class="text-xs text-muted-foreground"
>
{{ job.assetsAttempted }}/{{ job.assetsTotal }}
</span>
</div>
<div class="flex items-center gap-2">
<template v-if="job.status === 'failed'">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
</template>
<template
v-else-if="job.status === 'completed' && job.downloadError"
>
<span
class="text-xs text-destructive-background truncate max-w-32"
>
{{ job.downloadError }}
</span>
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('exportToast.retryDownload')"
@click.stop="assetExportStore.triggerDownload(job, true)"
>
<i
class="icon-[lucide--rotate-ccw] size-4 text-destructive-background"
/>
</Button>
</template>
<template v-else-if="job.status === 'completed'">
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('exportToast.downloadExport')"
@click.stop="assetExportStore.triggerDownload(job, true)"
>
<i
class="icon-[lucide--download] size-4 text-success-background"
/>
</Button>
</template>
<template v-else-if="job.status === 'running'">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-base-foreground">
{{ progressPercent(job) }}%
</span>
</template>
<template v-else>
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</div>
<div
v-if="exportJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<span class="text-sm text-muted-foreground">
{{
t('exportToast.noExportsInQueue', {
filter: t('progressToast.filter.all')
})
}}
</span>
</div>
</div>
</template>
<template #footer="{ toggle }">
<div
class="flex flex-1 min-w-0 h-12 items-center justify-between gap-2 border-t border-border-default px-4"
>
<div class="flex min-w-0 flex-1 items-center gap-2 text-sm">
<i
v-tooltip.top="tooltipConfig"
:class="cn('size-4 shrink-0', footerIconClass)"
/>
<span
:class="
cn(
'truncate font-bold text-base-foreground transition-all duration-300 overflow-hidden',
isExpanded ? 'min-w-0 flex-1' : 'w-0'
)
"
>
{{ footerLabel }}
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="isInProgress"
:class="
cn(
'text-sm text-muted-foreground transition-all duration-300 overflow-hidden',
isExpanded ? 'whitespace-nowrap' : 'w-0'
)
"
>
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
})
}}
</span>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
/>
</Button>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
</template>

View File

@@ -9,7 +9,7 @@
>
<MultiSelect
v-if="availableFileFormats.length > 0"
v-model="fileFormats"
v-model="activeFileFormatObjects"
:label="$t('assetBrowser.fileFormats')"
:options="availableFileFormats"
class="min-w-32"
@@ -19,7 +19,7 @@
<MultiSelect
v-if="availableBaseModels.length > 0"
v-model="baseModels"
v-model="activeBaseModelObjects"
:label="$t('assetBrowser.baseModels')"
:options="availableBaseModels"
class="min-w-32"
@@ -83,22 +83,45 @@ const { assets = [], showOwnershipFilter = false } = defineProps<{
showOwnershipFilter?: boolean
}>()
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const selectedFileFormats = ref<SelectOption[]>([])
const selectedBaseModels = ref<SelectOption[]>([])
const sortBy = ref<AssetSortOption>('recent')
const ownership = ref<OwnershipOption>('all')
const { availableFileFormats, availableBaseModels, ownershipOptions } =
useAssetFilterOptions(() => assets)
// Only show selected items that exist in the current scope
const activeFileFormatObjects = computed({
get() {
return selectedFileFormats.value.filter((opt) =>
availableFileFormats.value.some((a) => a.value === opt.value)
)
},
set(value: SelectOption[]) {
selectedFileFormats.value = value
}
})
const activeBaseModelObjects = computed({
get() {
return selectedBaseModels.value.filter((opt) =>
availableBaseModels.value.some((a) => a.value === opt.value)
)
},
set(value: SelectOption[]) {
selectedBaseModels.value = value
}
})
const emit = defineEmits<{
filterChange: [filters: AssetFilterState]
}>()
function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
fileFormats: activeFileFormatObjects.value.map((opt) => opt.value),
baseModels: activeBaseModelObjects.value.map((opt) => opt.value),
sortBy: sortBy.value,
ownership: ownership.value
})

View File

@@ -5,6 +5,12 @@ import { nextTick, ref } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => {
const translations: Record<string, string> = {
@@ -736,6 +742,90 @@ describe('useAssetBrowser', () => {
expect(contentTitle.value).toBe('Assets')
})
it('ignores stale file format filter when navigating to category without that format', async () => {
const assets = [
createApiAsset({
id: 'ckpt-safetensors',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}),
createApiAsset({
id: 'lora-pt',
name: 'lora.pt',
tags: ['models', 'loras']
}),
createApiAsset({
id: 'lora-pt-2',
name: 'lora2.pt',
tags: ['models', 'loras']
})
]
const { selectedNavItem, updateFilters, filteredAssets } =
useAssetBrowser(ref(assets))
// Select safetensors filter while viewing checkpoints
selectedNavItem.value = 'checkpoints'
updateFilters({
sortBy: 'recent',
fileFormats: ['safetensors'],
baseModels: [],
ownership: 'all'
})
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].id).toBe('ckpt-safetensors')
// Navigate to loras category which has no .safetensors files
selectedNavItem.value = 'loras'
await nextTick()
// Should show all loras, not empty (stale filter should be ignored)
expect(filteredAssets.value).toHaveLength(2)
})
it('ignores stale base model filter when navigating to category without that model', async () => {
const assets = [
createApiAsset({
id: 'ckpt-sdxl',
name: 'model.safetensors',
tags: ['models', 'checkpoints'],
user_metadata: { base_model: 'SDXL' }
}),
createApiAsset({
id: 'lora-sd15',
name: 'lora.pt',
tags: ['models', 'loras'],
user_metadata: { base_model: 'SD1.5' }
})
]
const { selectedNavItem, updateFilters, filteredAssets } =
useAssetBrowser(ref(assets))
// Select SDXL base model filter while viewing checkpoints
selectedNavItem.value = 'checkpoints'
updateFilters({
sortBy: 'recent',
fileFormats: [],
baseModels: ['SDXL'],
ownership: 'all'
})
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].id).toBe('ckpt-sdxl')
// Navigate to loras which has no SDXL models
selectedNavItem.value = 'loras'
await nextTick()
// Should show all loras, not empty
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].id).toBe('lora-sd15')
})
it('groups models by top-level folder name', () => {
const assets = [
createApiAsset({

View File

@@ -10,6 +10,7 @@ import type {
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import {
filterByBaseModels,
filterByCategory,
@@ -192,6 +193,22 @@ export function useAssetBrowser(
return assets.value.filter(filterByCategory(selectedCategory.value))
})
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
categoryFilteredAssets
)
const activeFileFormats = computed(() =>
filters.value.fileFormats.filter((f) =>
availableFileFormats.value.some((opt) => opt.value === f)
)
)
const activeBaseModels = computed(() =>
filters.value.baseModels.filter((m) =>
availableBaseModels.value.some((opt) => opt.value === m)
)
)
const fuseOptions: UseFuseOptions<AssetItem> = {
fuseOptions: {
keys: [
@@ -223,8 +240,8 @@ export function useAssetBrowser(
const filteredAssets = computed(() => {
const filtered = searchFiltered.value
.filter(filterByFileFormats(filters.value.fileFormats))
.filter(filterByBaseModels(filters.value.baseModels))
.filter(filterByFileFormats(activeFileFormats.value))
.filter(filterByBaseModels(activeBaseModels.value))
.filter(filterByOwnership(selectedOwnership.value))
const sortedAssets = sortAssets(filtered, filters.value.sortBy)

View File

@@ -20,6 +20,8 @@ import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { isResultItemType } from '@/utils/typeGuardUtil'
import { useAssetExportStore } from '@/stores/assetExportStore'
import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
@@ -73,7 +75,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }),
detail: t('mediaAsset.selection.downloadsStarted', 1),
life: 2000
})
} catch (error) {
@@ -87,16 +89,26 @@ export function useMediaAssetActions() {
}
/**
* Download multiple assets at once
* @param assets Array of assets to download
* Download multiple assets at once.
* In cloud mode with 2+ assets, creates a ZIP export via the backend.
* Falls back to individual downloads in OSS mode or for single assets.
*/
const downloadMultipleAssets = (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
const hasMultiOutputJobs = assets.some((a) => {
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
return typeof count === 'number' && count > 1
})
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
void downloadMultipleAssetsAsZip(assets)
return
}
try {
assets.forEach((asset) => {
const filename = asset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
@@ -104,9 +116,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', {
count: assets.length
}),
detail: t('mediaAsset.selection.downloadsStarted', assets.length),
life: 2000
})
} catch (error) {
@@ -120,6 +130,62 @@ export function useMediaAssetActions() {
}
}
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()
try {
const jobIds: string[] = []
const assetIds: string[] = []
const jobAssetNameFilters: Record<string, string[]> = {}
for (const asset of assets) {
if (getAssetType(asset) === 'output') {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = metadata?.promptId || asset.id
if (!jobIds.includes(promptId)) {
jobIds.push(promptId)
}
if (metadata?.promptId && asset.name) {
if (!jobAssetNameFilters[metadata.promptId]) {
jobAssetNameFilters[metadata.promptId] = []
}
if (!jobAssetNameFilters[metadata.promptId].includes(asset.name)) {
jobAssetNameFilters[metadata.promptId].push(asset.name)
}
}
} else {
assetIds.push(asset.id)
}
}
const result = await assetService.createAssetExport({
...(jobIds.length > 0 ? { job_ids: jobIds } : {}),
...(assetIds.length > 0 ? { asset_ids: assetIds } : {}),
...(Object.keys(jobAssetNameFilters).length > 0
? { job_asset_name_filters: jobAssetNameFilters }
: {}),
naming_strategy: 'preserve'
})
assetExportStore.trackExport(result.task_id)
toast.add({
severity: 'info',
summary: t('exportToast.exportStarted'),
detail: t('mediaAsset.selection.exportStarted', assets.length),
life: 3000
})
} catch (error) {
console.error('Failed to create asset export:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('exportToast.exportFailedSingle'),
life: 3000
})
}
}
const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
@@ -580,9 +646,10 @@ export function useMediaAssetActions() {
summary: t('g.success'),
detail: isSingle
? t('mediaAsset.assetDeletedSuccessfully')
: t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: succeeded
}),
: t(
'mediaAsset.selection.assetsDeletedSuccessfully',
succeeded
),
life: 2000
})
} else if (succeeded === 0) {

View File

@@ -31,6 +31,17 @@ interface AssetRequestOptions extends PaginationOptions {
includePublic?: boolean
}
interface AssetExportOptions {
job_ids?: string[]
asset_ids?: string[]
naming_strategy?:
| 'group_by_job_id'
| 'prepend_job_id'
| 'preserve'
| 'asset_id'
job_asset_name_filters?: Record<string, string[]>
}
/**
* Maps CivitAI validation error codes to localized error messages
*/
@@ -153,6 +164,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
const ASSETS_ENDPOINT = '/assets'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -689,6 +701,34 @@ function createAssetService() {
return result.data
}
async function createAssetExport(
params: AssetExportOptions
): Promise<{ task_id: string; status: string; message?: string }> {
const res = await api.fetchApi(ASSETS_EXPORT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(`Failed to create asset export: ${res.status}`)
}
return await res.json()
}
async function getExportDownloadUrl(
exportName: string
): Promise<{ url: string; expires_at?: string }> {
const res = await api.fetchApi(`/assets/exports/${exportName}`)
if (!res.ok) {
throw new Error(`Failed to get export download URL: ${res.status}`)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -703,7 +743,9 @@ function createAssetService() {
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64,
uploadAssetAsync
uploadAssetAsync,
createAssetExport,
getExportDownloadUrl
}
}

View File

@@ -80,7 +80,7 @@ import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()

View File

@@ -23,7 +23,7 @@ export const useSubscriptionDialog = () => {
const component = useWorkspaceVariant
? defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContentWorkspace.vue')
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
)
: defineAsyncComponent(
() =>

View File

@@ -15,6 +15,18 @@ vi.mock('./nodeReplacementService', () => ({
fetchNodeReplacements: vi.fn()
}))
const mockNodeReplacementsEnabled = vi.hoisted(() => ({ value: true }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: {
get nodeReplacementsEnabled() {
return mockNodeReplacementsEnabled.value
}
}
}))
}))
function mockSettingStore(enabled: boolean) {
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: vi.fn().mockImplementation((key: string) => {
@@ -27,9 +39,10 @@ function mockSettingStore(enabled: boolean) {
})
}
function createStore(enabled = true) {
function createStore(enabled = true, featureEnabled = true) {
setActivePinia(createPinia())
mockSettingStore(enabled)
mockNodeReplacementsEnabled.value = featureEnabled
return useNodeReplacementStore()
}
@@ -38,6 +51,7 @@ describe('useNodeReplacementStore', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodeReplacementsEnabled.value = true
store = createStore(true)
})
@@ -257,5 +271,15 @@ describe('useNodeReplacementStore', () => {
expect(fetchNodeReplacements).not.toHaveBeenCalled()
expect(store.isLoaded).toBe(false)
})
it('should not call API when server feature flag is disabled', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore(true, false)
await store.load()
expect(fetchNodeReplacements).not.toHaveBeenCalled()
expect(store.isLoaded).toBe(false)
})
})
})

View File

@@ -3,6 +3,7 @@ import type { NodeReplacement, NodeReplacementResponse } from './types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { fetchNodeReplacements } from './nodeReplacementService'
@@ -14,8 +15,12 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
settingStore.get('Comfy.NodeReplacement.Enabled')
)
const { flags } = useFeatureFlags()
async function load() {
if (!isEnabled.value || isLoaded.value) return
if (!flags.nodeReplacementsEnabled) return
try {
replacements.value = await fetchNodeReplacements()
isLoaded.value = true

View File

@@ -0,0 +1,654 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeReplacement } from './types'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn(),
registered_node_types: {}
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null },
sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '')
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: {
changeTracker: {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
}
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
import { app } from '@/scripts/app'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodeReplacement } from './useNodeReplacement'
function createMockLink(
id: number,
originId: number,
originSlot: number,
targetId: number,
targetSlot: number
) {
return {
id,
origin_id: originId,
origin_slot: originSlot,
target_id: targetId,
target_slot: targetSlot,
type: 'IMAGE'
}
}
function createMockGraph(
nodes: LGraphNode[],
links: ReturnType<typeof createMockLink>[] = []
): LGraph {
const linksMap = new Map(links.map((l) => [l.id, l]))
return {
_nodes: nodes,
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
links: linksMap,
updateExecutionOrder: vi.fn(),
setDirtyCanvas: vi.fn()
} as unknown as LGraph
}
function createPlaceholderNode(
id: number,
type: string,
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
graph?: LGraph
): LGraphNode {
return {
id,
type,
pos: [100, 200],
size: [200, 100],
order: 0,
mode: 0,
flags: {},
has_errors: true,
last_serialization: {
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
},
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
graph: graph ?? null,
serialize: vi.fn(() => ({
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
}))
} as unknown as LGraphNode
}
function createNewNode(
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
widgets: { name: string; value: unknown }[] = []
): LGraphNode {
return {
id: 0,
type: '',
pos: [0, 0],
size: [100, 50],
order: 0,
mode: 0,
flags: {},
has_errors: false,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
configure: vi.fn(),
serialize: vi.fn()
} as unknown as LGraphNode
}
function makeMissingNodeType(
type: string,
replacement: NodeReplacement
): MissingNodeType {
return {
type,
isReplaceable: true,
replacement
}
}
describe('useNodeReplacement', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
describe('replaceNodesInPlace', () => {
it('should return empty array when no placeholders exist', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([])
expect(result).toEqual([])
})
it('should use default mapping when no explicit mapping exists', () => {
const placeholder = createPlaceholderNode(1, 'Load3DAnimation')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['Load3DAnimation'])
expect(newNode.configure).not.toHaveBeenCalled()
expect(newNode.id).toBe(1)
expect(newNode.has_errors).toBe(false)
})
it('should transfer input connections using input_mapping', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
1,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(result).toEqual(['T2IAdapterLoader'])
// Link should be updated to point at new node's input
expect(link.target_id).toBe(1)
expect(link.target_slot).toBe(0)
expect(newNode.inputs[0].link).toBe(10)
})
it('should transfer output connections using output_mapping', () => {
const link = createMockLink(20, 1, 0, 5, 0)
const placeholder = createPlaceholderNode(
1,
'ResizeImagesByLongerEdge',
[],
[{ name: 'IMAGE', links: [20] }]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'image', link: null }],
[{ name: 'IMAGE', links: null }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Output link should be remapped
expect(link.origin_id).toBe(1)
expect(link.origin_slot).toBe(0)
expect(newNode.outputs[0].links).toEqual([20])
})
it('should apply set_value to widget', () => {
const placeholder = createPlaceholderNode(1, 'ImageScaleBy')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied to the widget
expect(newNode.widgets![0].value).toBe('scale by multiplier')
})
it('should transfer widget values using old_widget_ids', () => {
const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge')
// Set widget values in serialized data
placeholder.last_serialization!.widgets_values = [512]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[{ name: 'largest_size', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
})
it('should skip replacement when new node type is not registered', () => {
const placeholder = createPlaceholderNode(1, 'UnknownNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('UnknownNode', {
new_node_id: 'NonExistentNode',
old_node_id: 'UnknownNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual([])
})
it('should replace multiple different node types at once', () => {
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
const placeholder2 = createPlaceholderNode(
2,
'ConditioningAverage',
[],
[]
)
// sanitizeNodeName strips & from type names (HTML entity chars)
placeholder2.type = 'ConditioningAverage'
const graph = createMockGraph([placeholder1, placeholder2])
placeholder1.graph = graph
placeholder2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2])
const newNode1 = createNewNode()
const newNode2 = createNewNode()
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(newNode1)
.mockReturnValueOnce(newNode2)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('ConditioningAverage&', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage&',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toHaveLength(2)
expect(result).toContain('Load3DAnimation')
expect(result).toContain('ConditioningAverage&')
})
it('should copy position and identity for mapped replacements', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
42,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
placeholder.pos = [300, 400]
placeholder.size = [250, 150]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(newNode.id).toBe(42)
expect(newNode.pos).toEqual([300, 400])
expect(newNode.size).toEqual([250, 150])
expect(graph._nodes[0]).toBe(newNode)
})
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
const placeholder = createPlaceholderNode(
12,
'ImageScaleBy',
[{ name: 'image', link: 2 }],
[{ name: 'IMAGE', links: [3, 4] }]
)
// Real workflow data: widgets_values: ["lanczos", 2.0]
placeholder.last_serialization!.widgets_values = ['lanczos', 2.0]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied
expect(newNode.widgets![0].value).toBe('scale by multiplier')
// upscale_method (idx 0, value "lanczos") → scale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => {
const link = createMockLink(1, 5, 0, 8, 0)
const placeholder = createPlaceholderNode(
8,
'ResizeImagesByLongerEdge',
[{ name: 'images', link: 1 }],
[{ name: 'IMAGE', links: [2] }]
)
// Real workflow data: widgets_values: [1024]
placeholder.last_serialization!.widgets_values = [1024]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[
{ name: 'largest_size', value: 0 },
{ name: 'upscale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// longer_edge (idx 0, value 1024) → largest_size widget
expect(newNode.widgets![0].value).toBe(1024)
// set_value "lanczos" → upscale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer ConditioningAverage widget value with real workflow data', () => {
const link = createMockLink(4, 7, 0, 13, 0)
// sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space
const placeholder = createPlaceholderNode(
13,
'ConditioningAverage ',
[
{ name: 'conditioning_to', link: 4 },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: [6] }]
)
placeholder.last_serialization!.widgets_values = [0.75]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'conditioning_to', link: null },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: null }],
[{ name: 'conditioning_average', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ConditioningAverage ', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage ',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
// Default mapping transfers connections and widget values by name
expect(newNode.id).toBe(13)
expect(newNode.inputs[0].link).toBe(4)
expect(newNode.outputs[0].links).toEqual([6])
expect(newNode.widgets![0].value).toBe(0.75)
})
it('should skip dot-notation input connections but still transfer widget values', () => {
const placeholder = createPlaceholderNode(1, 'ImageBatch')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('ImageBatch', {
new_node_id: 'BatchImagesNode',
old_node_id: 'ImageBatch',
old_widget_ids: null,
input_mapping: [
{ new_id: 'images.image0', old_id: 'image1' },
{ new_id: 'images.image1', old_id: 'image2' }
],
output_mapping: null
})
])
// Should still succeed (dot-notation skipped gracefully)
expect(result).toEqual(['ImageBatch'])
})
})
})

View File

@@ -0,0 +1,292 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { t } from '@/i18n'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
/** Compares sanitized type strings to match placeholder → missing node type. */
function findMatchingType(
node: LGraphNode,
selectedTypes: MissingNodeType[]
): Extract<MissingNodeType, { type: string }> | undefined {
const nodeType = node.type
for (const selected of selectedTypes) {
if (typeof selected !== 'object' || !selected.isReplaceable) continue
if (sanitizeNodeName(selected.type) === nodeType) return selected
}
return undefined
}
function transferInputConnection(
oldNode: LGraphNode,
oldInputName: string,
newNode: LGraphNode,
newInputName: string,
graph: LGraph
): void {
const oldSlotIdx = oldNode.inputs?.findIndex((i) => i.name === oldInputName)
const newSlotIdx = newNode.inputs?.findIndex((i) => i.name === newInputName)
if (oldSlotIdx == null || oldSlotIdx === -1) return
if (newSlotIdx == null || newSlotIdx === -1) return
const linkId = oldNode.inputs[oldSlotIdx].link
if (linkId == null) return
const link = graph.links.get(linkId)
if (!link) return
link.target_id = newNode.id
link.target_slot = newSlotIdx
newNode.inputs[newSlotIdx].link = linkId
oldNode.inputs[oldSlotIdx].link = null
}
function transferOutputConnections(
oldNode: LGraphNode,
oldOutputIdx: number,
newNode: LGraphNode,
newOutputIdx: number,
graph: LGraph
): void {
const oldLinks = oldNode.outputs?.[oldOutputIdx]?.links
if (!oldLinks?.length) return
if (!newNode.outputs?.[newOutputIdx]) return
for (const linkId of oldLinks) {
const link = graph.links.get(linkId)
if (!link) continue
link.origin_id = newNode.id
link.origin_slot = newOutputIdx
}
newNode.outputs[newOutputIdx].links = [...oldLinks]
oldNode.outputs[oldOutputIdx].links = []
}
/** Uses old_widget_ids as name→index lookup into widgets_values. */
function transferWidgetValue(
serialized: ISerialisedNode,
oldWidgetIds: string[] | null,
oldInputName: string,
newNode: LGraphNode,
newInputName: string
): void {
if (!oldWidgetIds || !serialized.widgets_values) return
const oldWidgetIdx = oldWidgetIds.indexOf(oldInputName)
if (oldWidgetIdx === -1) return
const oldValue = serialized.widgets_values[oldWidgetIdx]
if (oldValue === undefined) return
const newWidget = newNode.widgets?.find((w) => w.name === newInputName)
if (newWidget) {
newWidget.value = oldValue
newWidget.callback?.(oldValue)
}
}
function applySetValue(
newNode: LGraphNode,
inputName: string,
value: unknown
): void {
const widget = newNode.widgets?.find((w) => w.name === inputName)
if (widget) {
widget.value = value as TWidgetValue
widget.callback?.(widget.value)
}
}
function isDotNotation(id: string): boolean {
return id.includes('.')
}
/** Auto-generates identity mapping by name for same-structure replacements without backend mapping. */
function generateDefaultMapping(
serialized: ISerialisedNode,
newNode: LGraphNode
): Pick<
NodeReplacement,
'input_mapping' | 'output_mapping' | 'old_widget_ids'
> {
const oldInputNames = new Set(serialized.inputs?.map((i) => i.name) ?? [])
const inputMapping: { old_id: string; new_id: string }[] = []
for (const newInput of newNode.inputs ?? []) {
if (oldInputNames.has(newInput.name)) {
inputMapping.push({ old_id: newInput.name, new_id: newInput.name })
}
}
const oldWidgetIds = (newNode.widgets ?? []).map((w) => w.name)
for (const widget of newNode.widgets ?? []) {
if (!oldInputNames.has(widget.name)) {
inputMapping.push({ old_id: widget.name, new_id: widget.name })
}
}
const outputMapping: { old_idx: number; new_idx: number }[] = []
for (const [oldIdx, oldOutput] of (serialized.outputs ?? []).entries()) {
const newIdx = newNode.outputs?.findIndex((o) => o.name === oldOutput.name)
if (newIdx != null && newIdx !== -1) {
outputMapping.push({ old_idx: oldIdx, new_idx: newIdx })
}
}
return {
input_mapping: inputMapping.length > 0 ? inputMapping : null,
output_mapping: outputMapping.length > 0 ? outputMapping : null,
old_widget_ids: oldWidgetIds.length > 0 ? oldWidgetIds : null
}
}
function replaceWithMapping(
node: LGraphNode,
newNode: LGraphNode,
replacement: NodeReplacement,
nodeGraph: LGraph,
idx: number
): void {
newNode.id = node.id
newNode.pos = [...node.pos]
newNode.size = [...node.size]
newNode.order = node.order
newNode.mode = node.mode
if (node.flags) newNode.flags = { ...node.flags }
nodeGraph._nodes[idx] = newNode
newNode.graph = nodeGraph
nodeGraph._nodes_by_id[newNode.id] = newNode
const serialized = node.last_serialization ?? node.serialize()
if (serialized.title != null) newNode.title = serialized.title
if (serialized.properties) {
newNode.properties = { ...serialized.properties }
if ('Node name for S&R' in newNode.properties) {
newNode.properties['Node name for S&R'] = replacement.new_node_id
}
}
if (replacement.input_mapping) {
for (const inputMap of replacement.input_mapping) {
if ('old_id' in inputMap) {
if (isDotNotation(inputMap.new_id)) continue // Autogrow/DynamicCombo
transferInputConnection(
node,
inputMap.old_id,
newNode,
inputMap.new_id,
nodeGraph
)
transferWidgetValue(
serialized,
replacement.old_widget_ids,
inputMap.old_id,
newNode,
inputMap.new_id
)
} else {
if (!isDotNotation(inputMap.new_id)) {
applySetValue(newNode, inputMap.new_id, inputMap.set_value)
}
}
}
}
if (replacement.output_mapping) {
for (const outMap of replacement.output_mapping) {
transferOutputConnections(
node,
outMap.old_idx,
newNode,
outMap.new_idx,
nodeGraph
)
}
}
newNode.has_errors = false
}
export function useNodeReplacement() {
const toastStore = useToastStore()
function replaceNodesInPlace(selectedTypes: MissingNodeType[]): string[] {
const replacedTypes: string[] = []
const graph = app.rootGraph
const changeTracker =
useWorkflowStore().activeWorkflow?.changeTracker ?? null
changeTracker?.beforeChange()
try {
const placeholders = collectAllNodes(
graph,
(n) => !!n.has_errors && !!n.last_serialization
)
for (const node of placeholders) {
const match = findMatchingType(node, selectedTypes)
if (!match?.replacement) continue
const replacement = match.replacement
const nodeGraph = node.graph
if (!nodeGraph) continue
const idx = nodeGraph._nodes.indexOf(node)
if (idx === -1) continue
const newNode = LiteGraph.createNode(replacement.new_node_id)
if (!newNode) continue
const hasMapping =
replacement.input_mapping != null ||
replacement.output_mapping != null
const effectiveReplacement = hasMapping
? replacement
: {
...replacement,
...generateDefaultMapping(
node.last_serialization ?? node.serialize(),
newNode
)
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
}
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
toastStore.add({
severity: 'success',
summary: t('g.success'),
detail: t('nodeReplacement.replacedAllNodes', {
count: replacedTypes.length
}),
life: 3000
})
}
} finally {
changeTracker?.afterChange()
}
return replacedTypes
}
return {
replaceNodesInPlace
}
}

View File

@@ -27,6 +27,7 @@ type FirebaseRuntimeConfig = {
*/
export type RemoteConfig = {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert

View File

@@ -177,7 +177,7 @@ export function useSettingUI(
},
component: defineAsyncComponent(
() =>
import('@/components/dialog/content/setting/WorkspacePanelContent.vue')
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
)
}

View File

@@ -1202,9 +1202,9 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.NodeReplacement.Enabled',
category: ['Comfy', 'Workflow', 'NodeReplacement'],
name: 'Enable automatic node replacement',
name: 'Enable node replacement suggestions',
tooltip:
'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.',
'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.',
type: 'boolean',
defaultValue: false,
experimental: true,
@@ -1221,5 +1221,16 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
experimental: true,
versionAdded: '1.40.0'
},
{
id: 'Comfy.RightSidePanel.ShowErrorsTab',
category: ['Comfy', 'Error System'],
name: 'Show errors tab in side panel',
tooltip:
'When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance.',
type: 'boolean',
defaultValue: false,
experimental: true,
versionAdded: '1.40.0'
}
]

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
describe('GtmTelemetryProvider', () => {
beforeEach(() => {
window.__CONFIG__ = {}
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
})
it('injects the GTM runtime script', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
}
new GtmTelemetryProvider()
const gtmScript = document.querySelector(
'script[src="https://www.googletagmanager.com/gtm.js?id=GTM-TEST123"]'
)
expect(gtmScript).not.toBeNull()
expect(window.dataLayer?.[0]).toMatchObject({
event: 'gtm.js'
})
})
it('bootstraps gtag when a GA measurement id exists', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
new GtmTelemetryProvider()
const gtagScript = document.querySelector(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
)
const dataLayer = window.dataLayer as unknown[]
expect(gtagScript).not.toBeNull()
expect(typeof window.gtag).toBe('function')
expect(dataLayer).toHaveLength(2)
expect(Array.from(dataLayer[0] as IArguments)[0]).toBe('js')
expect(Array.from(dataLayer[1] as IArguments)).toEqual([
'config',
'G-TEST123',
{
send_page_view: false
}
])
})
it('does not inject duplicate gtag scripts across repeated init', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
new GtmTelemetryProvider()
new GtmTelemetryProvider()
const gtagScripts = document.querySelectorAll(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
)
expect(gtagScripts).toHaveLength(1)
})
})

View File

@@ -22,13 +22,21 @@ export class GtmTelemetryProvider implements TelemetryProvider {
if (typeof window === 'undefined') return
const gtmId = window.__CONFIG__?.gtm_container_id
if (!gtmId) {
if (gtmId) {
this.initializeGtm(gtmId)
} else {
if (import.meta.env.MODE === 'development') {
console.warn('[GTM] No GTM ID configured, skipping initialization')
}
return
}
const measurementId = window.__CONFIG__?.ga_measurement_id
if (measurementId) {
this.bootstrapGtag(measurementId)
}
}
private initializeGtm(gtmId: string): void {
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
@@ -44,6 +52,38 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.initialized = true
}
private bootstrapGtag(measurementId: string): void {
window.dataLayer = window.dataLayer || []
if (typeof window.gtag !== 'function') {
function gtag() {
// gtag queue shape is dataLayer.push(arguments)
// eslint-disable-next-line prefer-rest-params
;(window.dataLayer as unknown[] | undefined)?.push(arguments)
}
window.gtag = gtag as Window['gtag']
}
const gtagScriptSrc = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`
const existingGtagScript = document.querySelector(
`script[src="${gtagScriptSrc}"]`
)
if (!existingGtagScript) {
const script = document.createElement('script')
script.async = true
script.src = gtagScriptSrc
document.head.insertBefore(script, document.head.firstChild)
}
const gtag = window.gtag
if (typeof gtag !== 'function') return
gtag('js', new Date())
gtag('config', measurementId, { send_page_view: false })
}
private pushEvent(event: string, properties?: Record<string, unknown>): void {
if (!this.initialized) return
window.dataLayer?.push({ event, ...properties })

View File

@@ -9,17 +9,37 @@ describe('getCheckoutAttribution', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
window.__ga_identity__ = undefined
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: undefined
}
window.gtag = undefined
window.ire = undefined
window.history.pushState({}, '', '/')
})
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
window.__ga_identity__ = {
client_id: '123.456',
session_id: '1700000000',
session_number: '2'
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: 'G-TEST123'
}
const gtagSpy = vi.fn(
(
_command: 'get',
_targetId: string,
fieldName: GtagGetFieldName,
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
) => {
const valueByField = {
client_id: '123.456',
session_id: '1700000000',
session_number: '2'
}
callback(valueByField[fieldName])
}
)
window.gtag = gtagSpy as unknown as Window['gtag']
window.history.pushState(
{},
'',
@@ -48,6 +68,61 @@ describe('getCheckoutAttribution', () => {
'generateClickId',
expect.any(Function)
)
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'client_id',
expect.any(Function)
)
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'session_id',
expect.any(Function)
)
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'session_number',
expect.any(Function)
)
})
it('stringifies numeric GA values from gtag', async () => {
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: 'G-TEST123'
}
const gtagSpy = vi.fn(
(
_command: 'get',
_targetId: string,
fieldName: GtagGetFieldName,
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
) => {
const valueByField = {
client_id: '123.456',
session_id: 1700000000,
session_number: 2
}
callback(valueByField[fieldName])
}
)
window.gtag = gtagSpy as unknown as Window['gtag']
const attribution = await getCheckoutAttribution()
expect(attribution).toMatchObject({
ga_client_id: '123.456',
ga_session_id: '1700000000',
ga_session_number: '2'
})
expect(gtagSpy).toHaveBeenCalledWith(
'get',
'G-TEST123',
'session_number',
expect.any(Function)
)
})
it('falls back to URL click id when generateClickId is unavailable', async () => {

View File

@@ -9,6 +9,13 @@ type GaIdentity = {
session_number?: string
}
const GA_IDENTITY_FIELDS = [
'client_id',
'session_id',
'session_number'
] as const satisfies ReadonlyArray<GtagGetFieldName>
type GaIdentityField = GtagGetFieldName
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
@@ -23,6 +30,7 @@ const ATTRIBUTION_QUERY_KEYS = [
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
const GET_GA_IDENTITY_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
@@ -93,19 +101,53 @@ function hasAttributionChanges(
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function getGaIdentity(): GaIdentity | undefined {
if (typeof window === 'undefined') return undefined
async function getGaIdentityField(
measurementId: string,
fieldName: GaIdentityField
): Promise<string | undefined> {
if (typeof window === 'undefined' || typeof window.gtag !== 'function') {
return undefined
}
const gtag = window.gtag
const identity = window.__ga_identity__
if (!isPlainObject(identity)) return undefined
return withTimeout(
() =>
new Promise<string | undefined>((resolve) => {
gtag('get', measurementId, fieldName, (value) => {
resolve(asNonEmptyString(value))
})
}),
GET_GA_IDENTITY_TIMEOUT_MS
).catch(() => undefined)
}
async function getGaIdentity(): Promise<GaIdentity | undefined> {
const measurementId = asNonEmptyString(window.__CONFIG__?.ga_measurement_id)
if (!measurementId) {
return undefined
}
const [clientId, sessionId, sessionNumber] = await Promise.all(
GA_IDENTITY_FIELDS.map((fieldName) =>
getGaIdentityField(measurementId, fieldName)
)
)
if (!clientId && !sessionId && !sessionNumber) {
return undefined
}
return {
client_id: asNonEmptyString(identity.client_id),
session_id: asNonEmptyString(identity.session_id),
session_number: asNonEmptyString(identity.session_number)
client_id: clientId,
session_id: sessionId,
session_number: sessionNumber
}
}
@@ -170,7 +212,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
persistAttribution(attribution)
}
const gaIdentity = getGaIdentity()
const gaIdentity = await getGaIdentity()
return {
...attribution,

View File

@@ -0,0 +1,251 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { app } from '@/scripts/app'
const { mockShowLoadWorkflowWarning, mockShowMissingModelsWarning } =
vi.hoisted(() => ({
mockShowLoadWorkflowWarning: vi.fn(),
mockShowMissingModelsWarning: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showLoadWorkflowWarning: mockShowLoadWorkflowWarning,
showMissingModelsWarning: mockShowMissingModelsWarning,
prompt: vi.fn(),
confirm: vi.fn()
})
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: { ds: { offset: [0, 0], scale: 1 } },
rootGraph: { serialize: vi.fn(() => ({})) },
loadGraphData: vi.fn()
}
}))
vi.mock('@/scripts/defaultGraph', () => ({
defaultGraph: {},
blankGraph: {}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ linearMode: false })
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
storeThumbnail: vi.fn(),
getThumbnail: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
useWorkflowDraftStore: () => ({
saveDraft: vi.fn(),
getDraft: vi.fn(),
removeDraft: vi.fn(),
markDraftUsed: vi.fn()
})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
clear: vi.fn()
})
}))
const MISSING_MODELS: PendingWarnings['missingModels'] = {
missingModels: [
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
],
paths: { checkpoints: ['/models/checkpoints'] }
}
function createWorkflow(
warnings: PendingWarnings | null = null,
options: { loadable?: boolean; path?: string } = {}
): ComfyWorkflow {
return {
pendingWarnings: warnings,
...(options.loadable && {
path: options.path ?? 'workflows/test.json',
isLoaded: true,
activeState: { nodes: [], links: [] },
changeTracker: { reset: vi.fn(), restore: vi.fn() }
})
} as unknown as ComfyWorkflow
}
function enableWarningSettings() {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => {
if (key === 'Comfy.Workflow.ShowMissingNodesWarning') return true
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
return false
}
)
}
describe('useWorkflowService', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('showPendingWarnings', () => {
beforeEach(() => {
enableWarningSettings()
})
it('should do nothing when workflow has no pending warnings', () => {
const workflow = createWorkflow(null)
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
})
it('should show missing nodes dialog and clear warnings', () => {
const missingNodeTypes = ['CustomNode1', 'CustomNode2']
const workflow = createWorkflow({ missingNodeTypes })
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
missingNodeTypes
})
expect(workflow.pendingWarnings).toBeNull()
})
it('should show missing models dialog and clear warnings', () => {
const workflow = createWorkflow({ missingModels: MISSING_MODELS })
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowMissingModelsWarning).toHaveBeenCalledWith(MISSING_MODELS)
expect(workflow.pendingWarnings).toBeNull()
})
it('should not show dialogs when settings are disabled', () => {
vi.spyOn(useSettingStore(), 'get').mockReturnValue(false)
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1'],
missingModels: MISSING_MODELS
})
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
expect(workflow.pendingWarnings).toBeNull()
})
it('should only show warnings once across multiple calls', () => {
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1']
})
const service = useWorkflowService()
service.showPendingWarnings(workflow)
service.showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
})
})
describe('openWorkflow deferred warnings', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
enableWarningSettings()
workflowStore = useWorkflowStore()
vi.mocked(app.loadGraphData).mockImplementation(
async (_data, _clean, _restore, wf) => {
;(
workflowStore as unknown as Record<string, unknown>
).activeWorkflow = wf
}
)
})
it('should defer warnings during load and show on focus', async () => {
const workflow = createWorkflow(
{ missingNodeTypes: ['CustomNode1'] },
{ loadable: true }
)
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
await useWorkflowService().openWorkflow(workflow)
expect(app.loadGraphData).toHaveBeenCalledWith(
expect.anything(),
true,
true,
workflow,
expect.objectContaining({ deferWarnings: true })
)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
missingNodeTypes: ['CustomNode1']
})
expect(workflow.pendingWarnings).toBeNull()
})
it('should show each workflow warnings only when that tab is focused', async () => {
const workflow1 = createWorkflow(
{ missingNodeTypes: ['MissingNodeA'] },
{ loadable: true, path: 'workflows/first.json' }
)
const workflow2 = createWorkflow(
{ missingNodeTypes: ['MissingNodeB'] },
{ loadable: true, path: 'workflows/second.json' }
)
const service = useWorkflowService()
await service.openWorkflow(workflow1)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
missingNodeTypes: ['MissingNodeA']
})
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(2)
expect(mockShowLoadWorkflowWarning).toHaveBeenLastCalledWith({
missingNodeTypes: ['MissingNodeB']
})
expect(workflow2.pendingWarnings).toBeNull()
})
it('should not show warnings when refocusing a cleared tab', async () => {
const workflow = createWorkflow(
{ missingNodeTypes: ['CustomNode1'] },
{ loadable: true }
)
const service = useWorkflowService()
await service.openWorkflow(workflow, { force: true })
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -183,9 +183,11 @@ export const useWorkflowService = () => {
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote,
checkForRerouteMigration: false
checkForRerouteMigration: false,
deferWarnings: true
}
)
showPendingWarnings()
}
/**
@@ -437,6 +439,32 @@ export const useWorkflowService = () => {
await app.loadGraphData(state, true, true, filename)
}
/**
* Show and clear any pending warnings (missing nodes/models) stored on the
* active workflow. Called after a workflow becomes visible so dialogs don't
* overlap with subsequent loads.
*/
function showPendingWarnings(workflow?: ComfyWorkflow | null) {
const wf = workflow ?? workflowStore.activeWorkflow
if (!wf?.pendingWarnings) return
const { missingNodeTypes, missingModels } = wf.pendingWarnings
wf.pendingWarnings = null
if (
missingNodeTypes?.length &&
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
) {
void dialogService.showLoadWorkflowWarning({ missingNodeTypes })
}
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
) {
void dialogService.showMissingModelsWarning(missingModels)
}
}
return {
exportWorkflow,
saveWorkflowAs,
@@ -452,6 +480,7 @@ export const useWorkflowService = () => {
loadNextOpenedWorkflow,
loadPreviousOpenedWorkflow,
duplicateWorkflow,
showPendingWarnings,
afterLoadNewGraph,
beforeLoadNewGraph
}

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