Compare commits

..

14 Commits

Author SHA1 Message Date
jaeone94
1266f91ac1 fix: address missing model review gaps 2026-06-11 19:08:33 +09:00
jaeone94
a4dccda18d feat: simplify missing model resolution UI 2026-06-11 17:24:32 +09:00
jaeone94
c7797b201e Simplify swap node error presentation (#12768)
## Summary

Simplifies the Swap Nodes error card as the fourth slice of the
catalog/error-tab presentation refactor, aligning it with the newer
compact error-row patterns while preserving the existing replace and
locate behavior.

This follows the staged rollout plan from the earlier error-tab PRs:

1. #12683 refined execution-style errors: validation, runtime, and
prompt errors.
2. #12705 simplified missing media errors into flat, locatable rows.
3. #12735 simplified missing node pack errors and aligned grouped-row
behavior.
4. This PR applies the same simplification pass to Swap Nodes errors.
5. A later PR is expected to handle Missing Models, which is larger and
intentionally kept separate.

After the Missing Models slice lands, a follow-up consistency PR will
normalize the shared row/disclosure pattern across Missing Node Packs,
Swap Nodes, and Missing Models together. That follow-up will cover
parameterized i18n labels for disclosure controls, shared text-button
styling, and consistent disclosure semantics/accessibility across those
grouped rows.

## Changes

- **What**: Reworks the Swap Nodes card rows so each replacement group
is presented as a compact row with the source node type, replacement
target, replace action, and locate action.
- **What**: For a single affected node, the visible row label can be
clicked to locate the node, matching the interaction model used by the
newer missing-media and missing-node rows.
- **What**: For multiple affected nodes with the same replacement
target, the group renders a count badge and a disclosure row. Expanding
the group shows the affected node rows, each with its own locate action.
- **What**: Removes the old node-id badge path from Swap Nodes rows.
Node-id badges remain available to the other error cards that still own
that behavior.
- **What**: Keeps replacement behavior unchanged: per-group replacement
and replace-all still call through the existing node replacement store
flow.
- **What**: Adds regression coverage for the new grouped-row UI,
including same-type grouping in both Vue Nodes and LiteGraph render
modes.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the Swap Nodes presentation and interaction symmetry
with the previous error-tab PRs:

- Single-node groups should remain directly locatable via the row label
and the locate icon.
- Multi-node groups should expose the count and expand/collapse behavior
without adding duplicate focusable disclosure controls.
- The visible row labels intentionally keep their own accessible names,
while the separate locate icon uses the generic `Locate node on canvas`
accessible name. This mirrors the established pattern from the previous
slices.
- The newly added Playwright fixture covers two same-type replaceable
nodes so duplicate group keys and grouped disclosure behavior are
exercised end-to-end.

## Validation

- `pnpm format`
- `pnpm test:unit
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts`
- `pnpm test:browser:local browser_tests/tests/nodeReplacement.spec.ts
--project=chromium`
- Pre-commit hook: lint-staged, stylelint, oxfmt, oxlint, eslint, `pnpm
typecheck`, `pnpm typecheck:browser`
- Pre-push hook: `pnpm knip --cache`
- Additional parallel code review pass completed locally; no blocker or
major issues remained.

## Screenshots (if applicable)

This PR 

<img width="561" height="362" alt="스크린샷 2026-06-11 오전 3 46 06"
src="https://github.com/user-attachments/assets/65395467-6c2f-4aa1-84c5-3d9614c00c80"
/>

old (Main)
<img width="611" height="798" alt="스크린샷 2026-06-11 오전 3 46 32"
src="https://github.com/user-attachments/assets/3862d5df-f839-40c0-9488-ce64b051378e"
/>
2026-06-11 03:50:16 +00:00
Matt Miller
aa68573a6e fix: remove broken throttle from VirtualGrid scroll tracking (#12781)
## Summary

Every `VirtualGrid` consumer (assets sidebar, manager dialog, widget
select dropdown) is blind to discrete mouse-wheel scrolling:
`useScroll`'s `throttle: 64` never reports scroll position, so the
virtualization window stays frozen — users see blank space below the
first viewport of items and `approach-end` (infinite scroll) never
fires. Trackpad scrolling masks the bug by emitting events faster than
the throttle window.

## Changes

- **What**: drop the `throttle` option from `useScroll` in `VirtualGrid`
and remove the `scrollThrottle` prop (no consumer passes it). Scroll
events are frame-aligned and the handler is cheap, so the throttle
bought nothing even when it worked.
- **Root cause**: VueUse ≥14 `throttleFilter` with `leading=false` (what
`useScroll` uses) marks spaced-out events as executed without executing
them — each event re-arms an `isLeading` restore timer that makes the
next event skip its invoke, and the trailing branch is unreachable when
`elapsed > duration`. Regression of vueuse#2390; still present on vueuse
`main`.

## Review Focus

- Verified live against staging: with the throttle, sidebar scrolled to
`scrollTop` 1250 while `scrollY` stayed 0 and the render window stayed
at `[0..3)` of 27 (blank viewport); with this fix, `scrollY` tracks 1:1
and the window advances. Bare-vs-throttled `useScroll` compared
side-by-side on the same element to isolate the cause.
- Unblocks wheel-scroll for #12780's dropdown infinite scroll with no
changes there.

- Fixes FE-990

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 03:27:54 +00:00
Deep Mehta
79acf7be5e fix: re-encode favicon.ico with PNG frames to fix white corner artifacts (#12753)
## Summary

The rebranded `favicon.ico` renders with **opaque white corners** in
browsers — the rounded mark shows white slivers around its corners on
any background. This is a decode bug, not a design issue: the ICO
contains **BMP-format frames** whose alpha channel Chrome (and other
consumers) mishandle. Verified by loading the raw `.ico` in headless
Chrome on a dark page: corners render white instead of transparent.

Every surface that consumes the `.ico` directly shows the artifact —
Google search results, connector icon scraping, raw image views — while
browser tabs look fine because they prefer the SVG favicon.

## Changes

- **What**: Re-encode `apps/website/public/favicon.ico` and
`public/assets/favicon.ico` with **PNG-format ICO frames** (16/32/48).
PNG frames carry unambiguous alpha and decode correctly in all modern
browsers.
- **No design change**: identical rounded artwork, same transparency,
same sizes and filenames. SVG, PNGs, apple-touch-icon, and manifest
icons are untouched.
- **Breaking**: none.

## Review Focus

- Headless-Chrome verified: the old ico renders white corners on a dark
page; the re-encoded one renders transparent corners. (Comparison in PR
comments.)
- PNG-in-ICO is supported by all modern browsers and Google's favicon
pipeline.
- After merge, please add `needs-backport` + `cloud/1.45` so
cloud.comfy.org's copy gets the same fix.

## Screenshots (if applicable)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-11 03:27:53 +00:00
Robin Huang
02adfd4b83 feat: identify prompt source via comfy_usage_source extra_data (#12772)
Adds `comfy_usage_source: 'comfyui-frontend'` to the prompt body's
`extra_data`. The backend forwards this to API nodes' upstream requests
via the `Comfy-Usage-Source` header, so partner node API usage can be
attributed to the frontend.

Used in https://github.com/Comfy-Org/ComfyUI/pull/14404
2026-06-10 22:43:34 +00:00
Robin Huang
7c2c78b537 feat: send deploy_environment as Comfy-Env header on /releases requests (#12771)
Reads `system.deploy_environment` from `/system_stats` (added in
Comfy-Org/ComfyUI#14402) and sends it as the `Comfy-Env` header when
fetching `/releases`, matching the header name the backend already uses
for outbound API node requests. The header is omitted when the backend
doesn't report the field, so older backends are unaffected.

Note: api.comfy.org must allow `Comfy-Env` in
`Access-Control-Allow-Headers` for the CORS preflight to pass.
2026-06-10 21:30:08 +00:00
Matt Miller
bd1fd0680e feat(assets): walk getAllAssetsByTag via keyset cursor (#12720)
## ELI-5

When the app needs *all* the assets for a tag (like every input image),
it asks the server for them one page at a time. Today it says "give me
page starting at item #500" (offset paging). If items get added or
removed while it's flipping through, pages shift and it can show the
same thing twice or skip something.

This switches to "give me the page *after this bookmark*" (cursor
paging). The server now hands back a `next_cursor` bookmark with each
page; we pass it to fetch the next one. Bookmarks don't slip when the
list changes underneath, so the walk is stable and drift-free.

## What

Migrates the full-walk asset pager (`getAllAssetsByTag`) from offset to
keyset (`after` / `next_cursor`) pagination, now that the list-assets
endpoint exposes a cursor contract in the generated types.

- `handleAssetRequest` accepts an `after` cursor and sends it instead of
`offset` when present (the server ignores `offset` alongside a cursor)
- `getAllAssetsByTag` resumes each page from the prior response's
`next_cursor`, and terminates when `has_more` is false or `next_cursor`
is omitted
- `next_cursor` is exposed on the asset response schema; `after` is
threaded through `getAssetsByTag` / `getAssetsPageByTag` for
cursor-aware callers
- offset remains supported for random-access callers; only the full-walk
path changes

## Why

Offset pagination double-counts or skips records when the underlying set
changes mid-walk. Keyset cursors are stable under concurrent
inserts/deletes and scale better than deep offsets.

## Stacking

Based on `update-ingest-types` because the `after`/`next_cursor` types
land there first; this targets that branch and will retarget to the
default branch once it merges. Changes here touch only the asset
service/schema, disjoint from the generated types.

## Follow-ups

The asset store's bespoke offset loops (model loader, flat-output
infinite scroll) and the missing-media resolver still walk by offset;
those migrate in separate PRs.

## Tests

`assetService.test.ts` updated to assert the cursor walk, that the first
page carries neither `after` nor `offset`, that subsequent pages resume
from `next_cursor`, and that the walk halts when `next_cursor` is absent
even if `has_more` is true. Full asset/service + missing-media + store
suites pass locally (193 tests).

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-10 21:15:19 +00:00
Robin Huang
9617e498c9 feat: track desktop download button clicks on website (#12770)
Adds a `website:download_button_clicked` PostHog event (with `platform`
property) fired when a user clicks the desktop installer download button
on comfy.org. Previously we only had `/download` pageviews as a proxy —
autocapture is not active on the website project, so these clicks were
untracked. Includes unit tests for the new capture helper.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:44:57 +00:00
Terry Jia
25205c0f55 feat: add Load3DAdvanced node (#12723)
## Summary
add Load3DAdvanced node, without FE render, upload BE and HDRI.
BE https://github.com/Comfy-Org/ComfyUI/pull/14316

## Screenshots (if applicable)

https://github.com/user-attachments/assets/e561c919-bb52-4904-97da-fb01885762a7
2026-06-10 13:51:50 -04:00
Dante
598cf33ab7 [bugfix] Truncate long workspace names in workspace switcher (#12762)
## Summary

Long team workspace names wrapped onto multiple lines in the user-menu
workspace switcher, overflowing the fixed 54px rows and breaking the
dropdown layout. Applies the same single-line ellipsis pattern already
used by the current-workspace header
(`CurrentUserPopoverWorkspace.vue`).

## Changes

- **What**: `truncate` on the switcher name span, `max-w-full` on the
name row, `shrink-0` on avatar/tier badge/check icon so only the name
shrinks (`WorkspaceSwitcherPopover.vue`, 5 lines)
- Regression tests: Vitest component test + `@cloud` Playwright e2e
measuring single-line render height

Fixes
[FE-778](https://linear.app/comfyorg/issue/FE-778/bug-team-workspace-names-wrapping-to-multiple-lines-display-poorly-in)

## Red-Green Verification

| Commit | CI | Result |
|---|---|---|
| `30e04e2` test only | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378157)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378213)
| 🔴 new unit test + cloud e2e fail (proves tests catch the
bug) |
| `d8f9a5c` fix | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508881)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508715)
| 🟢 same tests pass |

## Screenshots

| Before | After |
|---|---|
| <img width="320" alt="before — name wraps to 4 lines, rows collide"
src="https://github.com/user-attachments/assets/90f3286a-5b50-4477-9b5c-9d32d0b026e4"
/> | <img width="320" alt="after — single line with ellipsis, row height
intact"
src="https://github.com/user-attachments/assets/8e47bbb2-b5b1-4945-a008-68491f39dc46"
/> |

## Review Focus

- Truncation chain: the name span is a flex item, so `truncate`
(overflow-hidden) zeroes its automatic min size; `max-w-full` caps the
`items-start` row at the container width. Mirrors the header pattern —
no new component.
- Figma `Team Plan - Workspaces` (Workspaces Menu component, node
2045-14413) specifies compact single-line rows; long-name overflow was
undesigned, truncation preserves the spec'd layout.
2026-06-10 14:45:36 +00:00
jaeone94
1b14f4df8a Simplify missing node pack error presentation (#12735)
## Summary

Simplify the Missing Node Packs error card so it follows the new
error-tab item-row direction, with clearer pack rows, predictable locate
behavior, and focused E2E coverage.

This is the third PR in the staged error-tab simplification plan:

1. Merged: execution/prompt/validation error presentation and catalog
grouping in #12683.
2. Merged: missing media presentation simplification in #12705.
3. This PR: missing node pack presentation simplification.
4. Planned next: swap-node presentation simplification.
5. Planned later: missing model presentation and action-flow
simplification.

## Changes

- **What**: Refactors Missing Node Packs rows so pack-level and
node-level actions are easier to scan and more consistent with the rest
of the refreshed Errors tab.
- **What**: Removes the node-id badge from missing node pack rows,
matching the simplified item-row direction.
- **What**: Makes a single-node known pack row directly locatable from
the pack label, rather than rendering an extra child row.
- **What**: Keeps multi-node packs collapsed by default, with both the
chevron and pack title toggling the child node list.
- **What**: Keeps unknown packs expanded by default, including the
single-node unknown-pack case, so users can still see the unresolved
node type immediately.
- **What**: Keeps per-node child rows clickable for locate-on-canvas
behavior when a pack contains multiple affected nodes.
- **What**: Replaces missing-node-pack action labels with shared
`g.install` and `g.search` copy and removes now-unused English locale
keys.
- **What**: Adds targeted Playwright coverage for the simplified
missing-node-pack card, including unknown-pack default rows, row-label
locate behavior, and chevron/title expansion behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the missing-node-pack row behavior:

- Single known pack with one affected node should stay compact and
locate the node from the pack label or locate icon.
- Known packs with multiple affected nodes should show a count, start
collapsed, and expand/collapse from either the chevron or title.
- Unknown packs should expose the affected node rows immediately,
including when there is only one affected node.
- Locate actions should remain attached to the affected node rows, not
to the parent pack when there are multiple nodes.
- The E2E fixture intentionally uses two missing nodes with the same
`cnr_id` and node sizes of `[400, 200]` to follow browser-test asset
guidance.

## Validation

- `pnpm format:check`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm knip --cache` via pre-push hook
- `pnpm test:unit
src/components/rightSidePanel/errors/MissingPackGroupRow.test.ts
src/components/rightSidePanel/errors/MissingNodeCard.test.ts --run`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts
--project=chromium`
- Pre-commit hook: staged formatting, linting, `pnpm typecheck`, and
`pnpm typecheck:browser`

## Screenshots

This PR 
<img width="531" height="598" alt="스크린샷 2026-06-10 오전 1 54 31"
src="https://github.com/user-attachments/assets/9c0addeb-92d2-4cef-a4f3-35a87bbad308"
/>

old (Main)
<img width="509" height="807" alt="스크린샷 2026-06-10 오전 1 53 51"
src="https://github.com/user-attachments/assets/b8488f73-d8ed-4356-bd4c-fc678ea205f7"
/>
2026-06-10 09:59:50 +00:00
Dante
ef93b4696c feat(billing): team-plan CreditSlider component — 5 fixed stops (FE-935) (#12644)
## What

https://a46f3266.comfy-storybook.pages.dev/?path=/docs/components-creditslider--docs&globals=theme:dark
<img width="560" height="288" alt="cs12644_dark"
src="https://github.com/user-attachments/assets/c06b5244-d178-4fa5-8bb9-61fd8595fe9b"
/>
<img width="560" height="288" alt="cs12644_light"
src="https://github.com/user-attachments/assets/16626333-43ba-4541-bd11-faaa8513b1e8"
/>


Adds **CreditSlider** — the team-plan credit-subscription slider from
Figma **DES-197** — as a standalone presentational component. **B4
standalone slice (FE-935)**; parent **FE-934**.

## Why it can ship now (no backend dependency)
The slider's 5 stops are **locked in DES-197**, so this component is
built and reviewable independently of the (still-TBD) backend slider
contract. Wiring it into the pricing table is deferred to FE-934.

## How it works
- 5 fixed stops — **200 / 400 / 700 / 1,400 / 2,500 USD** ↔ 42,200 /
84,400 / 147,700 / 295,400 / 527,500 credits; default **$700**.
- Snap-to-stop is guaranteed by driving the shared
`src/components/ui/slider/Slider.vue` (reka-ui) in **index space**
(`:min="0" :max="4" :step="1"`) — the thumb can only land on the 5
stops, with free keyboard-arrow support + ARIA from reka-ui.
- `v-model` carries the selected **USD** value; a `change` event also
emits `{ index, usd, credits }` for the future pricing-table wiring.
- Thresholds live in a typed constant `teamPlanCreditStops.ts` (sibling
to `tierPricing.ts`), **hardcoded per DES-197** with a `TODO(FE-934)` to
source from `GET /api/billing/plans` once the BE contract lands. The
credit figures equal `usdToCredits(usd)` (rate 211); a test guards
against rate drift.

## Files
- `src/platform/cloud/subscription/components/CreditSlider.vue` (+
`CreditSlider.stories.ts`, `CreditSlider.test.ts`)
- `src/platform/cloud/subscription/constants/teamPlanCreditStops.ts`

## Verification
- `vue-tsc --noEmit`: clean.
- `oxlint --type-aware`: 0 errors / 0 warnings.
- `vitest run`: **11/11 pass** — default stop, ArrowRight/ArrowLeft snap
to the adjacent stop (never in between), `change` payload, disabled
state, BE-sourced stops override, empty `stops` renders nothing, all 5
labels render, and the credit-rate-drift guard.

## Not in scope
- Wiring into `PricingTableWorkspace` / the team-plan card (FE-934,
blocked on the BE slider contract).
- The marketing caption and card layout around the slider (parent
component's concern).

Design source: Figma **DES-197** (Team Plan / Workspaces).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:05:34 +00:00
Alexis Rolland
24c512d144 Bumping search ranks (#12750)
## Summary

Bumping the ranking of native nodes to improve their discoverability

## Changes

- **What**: Updated ranking of native nodes in
`public/assets/sorted-custom-node-map.json`
2026-06-10 07:22:10 +00:00
83 changed files with 4268 additions and 1429 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone"
"display": "standalone",
"id": "/",
"start_url": "/"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -8,6 +8,7 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -69,6 +70,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -73,7 +73,7 @@ const websiteJsonLd = {
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />

View File

@@ -53,3 +53,28 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,3 +38,12 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -45,6 +45,8 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
@@ -59,15 +61,14 @@ export const TestIds = {
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelReferenceCount: 'missing-model-reference-count',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingModelRefresh: 'missing-model-header-refresh',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',

View File

@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -11,6 +12,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
test('Should display model name and metadata', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
await expect(getModelLabel(modelsGroup)).toBeVisible()
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
})
test('Should expand model row to show referencing nodes', async ({
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'missing/missing_models_with_nodes'
)
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
const expandButton = modelsGroup.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expectReferenceBadge(modelsGroup, 2)
await expandButton.first().click()
await expect(locateButton.first()).toBeVisible()
await expect(
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(2)
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
const copyButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
expect(copiedText).toContain('/api/devtools/')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
})
@@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
await expect(downloadButton.first()).toHaveText('Download')
})
test('Should render Download all and Refresh actions for one downloadable model', async ({

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should expand pack group to reveal node type names', async ({
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Locate node button is visible for expanded pack nodes', async ({
test('Should toggle grouped pack nodes from chevron and title', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
'missing/missing_nodes_same_pack'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
})
})

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -8,6 +9,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
await expectReferenceBadge(missingModelGroup, 2)
})
test('Pasting a bypassed node does not add a new error', async ({
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

@@ -111,6 +111,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,7 +591,15 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -2,8 +2,8 @@
"PreviewImage": 4314,
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImageAdvanced": 1763,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
@@ -14,6 +14,7 @@
"UpscaleModelLoader": 629,
"UNETLoader": 606,
"VAELoader": 604,
"PreviewAny": 528,
"ShowText|pysssss": 527.5526981023964,
"ImageUpscaleWithModel": 523,
"ControlNetApplyAdvanced": 513,
@@ -24,10 +25,12 @@
"VHS_LoadVideo": 440,
"ImpactSwitch": 349,
"Reroute": 348,
"ResizeImageMaskNode": 337,
"ResizeAndPadImage": 336,
"ImageResizeKJv2": 335,
"StringConcatenate": 326,
"Text Concatenate": 325.7030402103206,
"SaveVideo": 321,
"PreviewAny": 319,
"KSamplerAdvanced": 304,
"SDXLPromptStyler": 297.0913411304729,
"Note": 291,
@@ -52,6 +55,7 @@
"CLIPLoader": 202,
"GeminiNode": 202,
"KSampler (Efficient)": 194.01083622636423,
"RemoveBackground": 187,
"ImageRemoveBackground+": 186,
"IPAdapterModelLoader": 184,
"PrimitiveInt": 183,
@@ -59,7 +63,9 @@
"LoadVideo": 179,
"Text Concatenate (JPS)": 175.98154639522735,
"PrimitiveNode": 175,
"Text Multiline": 163.04749064680308,
"PrimitiveStringMultiline": 166,
"Text Multiline": 165,
"GetImageSize": 164,
"GetImageSize+": 163,
"ImageScaleToTotalPixels": 157,
"String Literal": 150.11343489837878,
@@ -68,15 +74,14 @@
"DownloadAndLoadFlorence2Model": 144,
"LoadImageOutput": 143,
"IPAdapterUnifiedLoader": 141,
"FluxGuidance": 133,
"BatchImagesNode": 134,
"ImageBatchMulti": 133,
"FluxGuidance": 132,
"ByteDanceSeedreamNode": 130,
"CR Text Input Switch": 128.16473423438606,
"IPAdapterAdvanced": 128,
"If ANY execute A else B": 127.77279315110049,
"GeminiImage2Node": 124,
"GetImageSize": 121,
"PrimitiveStringMultiline": 120,
"IPAdapter": 118,
"CreateVideo": 116,
"ConditioningZeroOut": 115,
@@ -102,6 +107,7 @@
"DepthAnythingPreprocessor": 100,
"CR Apply LoRA Stack": 96.02556540496816,
"Image Filter Adjustments": 95.24168323839699,
"ComfyMathExpression": 96,
"SimpleMath+": 95,
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
"Image Overlay": 93.28197782196906,
@@ -147,7 +153,6 @@
"Image Resize": 63.494455492264656,
"Automatic CFG": 63.494455492264656,
"Canny": 63,
"StringConcatenate": 63,
"DepthAnything_V2": 61,
"ImageCrop+": 60,
"ModelSamplingSD3": 59,
@@ -199,6 +204,7 @@
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
"CR SDXL Aspect Ratio": 45.46516566112778,
"LoadAudio": 45,
"ResolutionSelector": 45,
"smZ CLIPTextEncode": 44.68128349455661,
"Bus Node": 44.68128349455661,
"PreviewTextNode": 44.68128349455661,
@@ -389,7 +395,6 @@
"SD_4XUpscale_Conditioning": 21,
"UltimateSDUpscaleCustomSample": 21,
"StyleModelLoader": 21,
"ResizeAndPadImage": 21,
"Text Random Prompt": 20.77287741413597,
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
"BrushNet": 20.77287741413597,

View File

@@ -33,7 +33,6 @@ const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
@@ -42,7 +41,6 @@ const {
items: (T & { key: string })[]
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
@@ -61,7 +59,6 @@ const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})

View File

@@ -23,6 +23,8 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -86,7 +88,7 @@
/>
<RecordingControls
v-if="!isPreview"
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -117,9 +119,18 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -130,11 +141,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = resolveNode(nodeId) ?? null
})
}

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -0,0 +1,21 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,6 +52,7 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -81,6 +82,7 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -129,12 +131,16 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="showBackgroundImage && !hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,12 +83,16 @@
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -114,8 +118,9 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false } = defineProps<{
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
props: ['group', 'showInfoButton'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -130,7 +128,6 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true,
showNodeIdBadge: true
showInfoButton: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,27 +56,29 @@
>
</template>
</i18n-t>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
variant="secondary"
size="sm"
:disabled="isRestarting"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<i
v-else
aria-hidden="true"
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
loading: 'Loading'
install: 'Install',
loading: 'Loading',
search: 'Search'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,7 +100,6 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -110,7 +109,6 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -118,7 +116,6 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('hides multiple nodeTypes behind the expand control by default', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('renders all nodeTypes when expanded', async () => {
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
})
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
it('shows Search when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
await user.click(screen.getByRole('button', { name: 'Install' }))
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
})

View File

@@ -1,187 +1,221 @@
<template>
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="showInfoButton && group.packId !== null"
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
:aria-expanded="expanded"
@click="toggleExpand"
>
#{{ nodeType.nodeId }}
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</span>
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--locate] size-3" />
<i class="icon-[lucide--info] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
<div
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
:class="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
const { group, showInfoButton } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -205,6 +238,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!comfyManagerStore.isPackInstalled(group.packId)) {
if (!isPackInstalled.value) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
expandedOverride.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -241,10 +334,14 @@ function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
}
</script>

View File

@@ -530,14 +530,16 @@ describe('TabErrors.vue', () => {
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'OldSampler' })
).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
@@ -555,11 +557,8 @@ describe('TabErrors.vue', () => {
}
})
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -94,9 +94,10 @@
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
@@ -112,7 +113,6 @@
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
@@ -148,7 +148,6 @@
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
@@ -158,7 +157,6 @@
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
@@ -248,7 +246,6 @@
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
/>
@@ -303,11 +300,9 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
@@ -321,7 +316,6 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -349,7 +343,6 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -373,12 +366,6 @@ function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -465,17 +452,8 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {

View File

@@ -22,6 +22,7 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -413,16 +414,10 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const {
scene: imageData,
@@ -441,16 +436,11 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
camera_info,
recording: '',
model_3d_info
}

View File

@@ -23,6 +23,7 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -58,6 +59,7 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -247,6 +249,24 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,6 +102,8 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -0,0 +1,36 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,7 +9,12 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -0,0 +1,103 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -37,6 +37,7 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -66,6 +67,12 @@ useExtensionService().registerExtension({
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -2423,6 +2423,7 @@
"member": "member",
"usdPerMonth": "USD / mo",
"usdPerMonthPerMember": "USD / mo / member",
"creditSliderSave": "Save {percent}% ({amount})",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
@@ -3086,6 +3087,13 @@
"loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"missingModelImportTypeLocked": "Locked to {type} for this missing model",
"missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.",
"missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.",
"missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.",
"missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.",
"missingModelImportUnknownType": "another model type",
"missingModelImportWillReplace": "This import will replace {model} in:",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
@@ -3629,21 +3637,17 @@
"unsupportedTitle": "Unsupported Node Packs",
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
"installAll": "Install All",
"installNodePack": "Install node pack",
"unknownPack": "Unknown pack",
"installing": "Installing...",
"installed": "Installed",
"applyChanges": "Apply Changes",
"searchInManager": "Search in Node Manager",
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"readyToApply": "Ready to apply",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",

View File

@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelConfirmation from './UploadModelConfirmation.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
const SingleSelectStub = {
name: 'SingleSelect',
props: {
disabled: Boolean,
modelValue: String
},
template:
'<button type="button" :disabled="disabled">{{ modelValue }}</button>'
}
describe('UploadModelConfirmation', () => {
it('shows missing-model replacement context and locks the model type', () => {
const uploadContext: UploadModelDialogContext = {
kind: 'missing-model-resolution',
missingModelName: 'segm/person_yolov8m-seg.pt',
requiredModelType: 'Ultralytics/bbox',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'Checkpoint Loader',
widgetName: 'ckpt_name'
}
]
}
render(UploadModelConfirmation, {
props: {
modelValue: 'Ultralytics/bbox',
metadata: {
content_length: 100,
final_url: 'https://civitai.com/models/123',
filename: 'replacement.safetensors'
},
uploadContext,
'onUpdate:modelValue': () => {}
},
global: {
plugins: [i18n],
stubs: {
SingleSelect: SingleSelectStub
}
}
})
expect(screen.getByText('segm/person_yolov8m-seg.pt')).toBeInTheDocument()
expect(screen.getByText('Checkpoint Loader')).toBeInTheDocument()
expect(screen.getByText('- ckpt_name')).toBeInTheDocument()
const modelTypeSelect = screen.getByRole('button', {
name: 'Ultralytics/bbox'
})
expect(modelTypeSelect).toBeDisabled()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Locked to Ultralytics/bbox for this missing model'
)
})
).toBeInTheDocument()
expect(screen.queryByText(/&#x2F;/)).not.toBeInTheDocument()
})
})

View File

@@ -22,16 +22,50 @@
</div>
</div>
<div
v-if="isMissingModelResolution"
class="flex flex-col gap-2 rounded-lg bg-secondary-background px-4 py-3"
>
<i18n-t
keypath="assetBrowser.missingModelImportWillReplace"
tag="p"
class="m-0 text-base-foreground"
>
<template #model>
<span>{{ missingModelName }}</span>
</template>
</i18n-t>
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="target in replacementTargets"
:key="`${target.nodeId}:${target.widgetName}`"
class="flex min-w-0 items-center gap-2"
>
<span class="min-w-0 truncate text-muted-foreground">
{{ target.nodeLabel }}
</span>
<span class="shrink-0 text-muted-foreground">
- {{ target.widgetName }}
</span>
</li>
</ul>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<span class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i
aria-hidden="true"
class="icon-[lucide--circle-question-mark] text-muted-foreground"
/>
<span v-if="!isMissingModelResolution" class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
</div>
</div>
<SingleSelect
v-model="modelValue"
@@ -41,23 +75,37 @@
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading"
:disabled="isLoading || isMissingModelResolution"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
<i18n-t
v-if="isMissingModelResolution"
keypath="assetBrowser.missingModelImportTypeLocked"
tag="span"
class="text-muted-foreground"
>
<template #type>
<span>{{ selectedModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
const { uploadContext } = defineProps<{
metadata?: AssetMetadata
previewImage?: string
uploadContext?: UploadModelDialogContext
}>()
const modelValue = defineModel<string | undefined>()
@@ -65,4 +113,27 @@ const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const missingModelName = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.missingModelName
: ''
)
const replacementTargets = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.replacementTargets
: []
)
const selectedModelTypeLabel = computed(() => {
const value =
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: modelValue.value
return (
modelTypes.value.find((option) => option.value === value)?.name ?? value
)
})
</script>

View File

@@ -17,6 +17,7 @@
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
:upload-context="uploadContext"
/>
<!-- Step 3: Upload Progress -->
@@ -24,6 +25,7 @@
v-else-if="currentStep === 3 && uploadStatus != null"
:result="uploadStatus"
:error="uploadError"
:type-mismatch="uploadTypeMismatch"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
@@ -39,6 +41,7 @@
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
:can-import-another="!isMissingModelResolution"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@@ -49,29 +52,47 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
const { uploadContext } = defineProps<{
uploadContext?: UploadModelDialogContext
}>()
const emit = defineEmits<{
'upload-success': [result: UploadModelSuccess]
}>()
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const requiredModelType = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: undefined
)
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,
canFetchMetadata,
@@ -80,16 +101,18 @@ const {
uploadModel,
goToPreviousStep,
resetWizard
} = useUploadModelWizard(modelTypes)
} = useUploadModelWizard(modelTypes, {
requiredModelType: requiredModelType.value
})
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
const result = await uploadModel()
if (result) {
emit('upload-success', result)
}
}

View File

@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelFooter from './UploadModelFooter.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function renderFooter(
props: Partial<InstanceType<typeof UploadModelFooter>['$props']> = {}
) {
render(UploadModelFooter, {
props: {
currentStep: 3,
isFetchingMetadata: false,
isUploading: false,
canFetchMetadata: true,
canUploadModel: true,
uploadStatus: 'success',
...props
},
global: {
plugins: [i18n],
stubs: {
VideoHelpDialog: true
}
}
})
}
describe('UploadModelFooter', () => {
it('allows importing another model by default', () => {
renderFooter()
expect(screen.getByRole('button', { name: 'Import Another' })).toBeEnabled()
})
it('disables importing another model when the upload resolves a missing model', () => {
renderFooter({ canImportAnother: false })
expect(
screen.getByRole('button', { name: 'Import Another' })
).toBeDisabled()
})
it('shows recovery actions for upload errors', () => {
renderFooter({ uploadStatus: 'error' })
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
})

View File

@@ -73,6 +73,7 @@
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-import-another-button"
:disabled="!canImportAnother"
@click="emit('importAnother')"
>
{{ $t('assetBrowser.importAnother') }}
@@ -90,6 +91,24 @@
}}
</Button>
</template>
<template v-else-if="currentStep === 3 && uploadStatus === 'error'">
<Button
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-back-button"
@click="emit('back')"
>
{{ $t('g.back') }}
</Button>
<Button
variant="secondary"
size="lg"
data-attr="upload-model-step3-close-button"
@click="emit('close')"
>
{{ $t('g.close') }}
</Button>
</template>
<VideoHelpDialog
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -113,13 +132,14 @@ import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
defineProps<{
const { canImportAnother = true } = defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus?: 'processing' | 'success' | 'error'
canImportAnother?: boolean
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelProgress from './UploadModelProgress.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
describe('UploadModelProgress', () => {
it('renders missing-model type mismatch labels without escaped entities', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA/Custom',
requiredModelType: 'Ultralytics/bbox',
requiredModelTypeLabel: 'Ultralytics/bbox'
}
},
global: {
plugins: [i18n]
}
})
expect(
screen.getByText('This model cannot resolve the missing model.')
).toBeInTheDocument()
expect(screen.getByText('LoRA/Custom')).toBeInTheDocument()
expect(screen.getAllByText('Ultralytics/bbox').length).toBeGreaterThan(0)
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Try importing a different Ultralytics/bbox model that this node can use.'
)
})
).toBeInTheDocument()
expect(screen.queryByText(/&#x2F;/)).not.toBeInTheDocument()
})
it('uses fallback copy when the imported model type label is unknown', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
}
},
global: {
plugins: [i18n]
}
})
expect(screen.getByText('another model type')).toBeInTheDocument()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'This file is already imported as another model type.'
)
})
).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,12 @@
<template>
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<div
:class="
cn(
'flex flex-1 flex-col gap-6 text-sm text-muted-foreground',
isTypeMismatchError && 'min-h-full justify-center'
)
"
>
<!-- Processing State (202 async download in progress) -->
<div v-if="result === 'processing'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
@@ -67,8 +74,51 @@
v-else-if="result === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<i
aria-hidden="true"
class="text-error"
:class="
typeMismatch
? 'icon-[lucide--circle-alert] size-12'
: 'icon-[lucide--x-circle] size-16'
"
/>
<div
v-if="typeMismatch"
class="flex max-w-2xl flex-col gap-3 text-center"
>
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.missingModelImportTypeMismatchTitle') }}
</p>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchAlreadyImported"
tag="p"
class="m-0 text-sm text-muted"
>
<template #actual>
<span>{{ actualModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchRequired"
tag="p"
class="m-0 text-sm text-muted"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchNextAction"
tag="p"
class="m-0 text-sm text-base-foreground"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
<div v-else class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
@@ -81,13 +131,26 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { UploadModelTypeMismatch } from '@/platform/assets/composables/useUploadModelWizard'
defineProps<{
const { typeMismatch } = defineProps<{
result: 'processing' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string
previewImage?: string
typeMismatch?: UploadModelTypeMismatch | null
}>()
const { t } = useI18n()
const isTypeMismatchError = computed(() => typeMismatch != null)
const actualModelTypeLabel = computed(
() =>
typeMismatch?.importedModelTypeLabel ??
t('assetBrowser.missingModelImportUnknownType')
)
</script>

View File

@@ -3,17 +3,28 @@ import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
export function useModelUpload(
onUploadSuccess?: () => Promise<unknown> | void
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function resolveUploadContext() {
return typeof uploadContext === 'function' ? uploadContext() : uploadContext
}
function showUploadDialog() {
if (!flags.privateModelsEnabled) {
dialogStore.showDialog({
@@ -33,8 +44,9 @@ export function useModelUpload(
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await onUploadSuccess?.()
uploadContext: resolveUploadContext(),
onUploadSuccess: async (result: UploadModelSuccess) => {
await onUploadSuccess?.(result)
}
},
dialogComponentProps: {

View File

@@ -1,14 +1,18 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
@@ -45,18 +49,52 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupUploadModelWizard(
...args: Parameters<typeof useUploadModelWizard>
): ReturnType<typeof useUploadModelWizard> {
return setupWithI18n(() => useUploadModelWizard(...args))
}
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -71,11 +109,18 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'model',
modelType: 'checkpoints',
taskId: 'task-123',
status: 'processing'
})
expect(wizard.uploadStatus.value).toBe('processing')
@@ -118,7 +163,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
@@ -169,7 +214,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.red/models/12345'
wizard.selectedModelType.value = 'checkpoints'
@@ -178,4 +223,124 @@ describe('useUploadModelWizard', () => {
expect(assetService.uploadAssetAsync).toHaveBeenCalled()
expect(wizard.uploadStatus.value).toBe('processing')
})
it('keeps a required model type when metadata suggests another type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'lora.safetensors',
tags: ['loras']
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('uploads with the required model type even if selection changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'checkpoints'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'loras'
const result = await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({
tags: ['models', 'checkpoints'],
user_metadata: expect.objectContaining({
model_type: 'checkpoints'
})
})
)
expect(result?.modelType).toBe('checkpoints')
})
it('blocks a missing-model import when an existing asset has the wrong model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA',
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
})
})
it('keeps generic sync imports successful when an existing asset has another model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
])
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'model',
modelType: 'checkpoints',
status: 'success'
})
expect(wizard.uploadStatus.value).toBe('success')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
})

View File

@@ -5,7 +5,10 @@ import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
@@ -26,16 +29,54 @@ interface ModelTypeOption {
value: string
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const MODEL_ROOT_TAG = 'models'
export interface UploadModelSuccess {
filename: string
modelType?: string
taskId?: string
status: 'processing' | 'success'
}
export interface UploadModelTypeMismatch {
importedModelType?: string
importedModelTypeLabel?: string
requiredModelType: string
requiredModelTypeLabel: string
}
interface MissingModelUploadContext {
kind: 'missing-model-resolution'
missingModelName: string
requiredModelType: string
replacementTargets: Array<{
nodeId: string
nodeLabel: string
widgetName: string
}>
}
export type UploadModelDialogContext = MissingModelUploadContext
interface UploadModelWizardOptions {
requiredModelType?: string
}
export function useUploadModelWizard(
modelTypes: Ref<ModelTypeOption[]>,
options: UploadModelWizardOptions = {}
) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const requiredModelType = options.requiredModelType
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
const uploadTypeMismatch = ref<UploadModelTypeMismatch | null>(null)
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
@@ -44,7 +85,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
tags: []
})
const selectedModelType = ref<string>()
const selectedModelType = ref<string | undefined>(requiredModelType)
const resolvedModelType = computed(
() => requiredModelType ?? selectedModelType.value
)
const importSources: ImportSource[] = [
civitaiImportSource,
@@ -65,16 +109,29 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
() => wizardData.value.url,
() => {
uploadError.value = ''
uploadTypeMismatch.value = null
}
)
if (requiredModelType) {
watch(
selectedModelType,
(value) => {
if (value !== requiredModelType) {
selectedModelType.value = requiredModelType
}
},
{ immediate: true }
)
}
// Validation - only enable Continue when URL matches a supported source
const canFetchMetadata = computed(() => {
return detectedSource.value !== null
})
const canUploadModel = computed(() => {
return !!selectedModelType.value
return !!resolvedModelType.value
})
async function fetchMetadata() {
@@ -128,7 +185,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
if (requiredModelType) {
selectedModelType.value = requiredModelType
} else if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
@@ -183,10 +242,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
async function refreshModelCaches() {
if (!selectedModelType.value) return
if (!resolvedModelType.value) return
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
resolvedModelType.value
)
const results = await Promise.allSettled(
providers.map((provider) =>
@@ -203,24 +262,61 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
})
}
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
function getModelTypeLabel(modelType: string): string {
return (
modelTypes.value.find((type) => type.value === modelType)?.name ??
modelType
)
}
function getImportedModelType(asset: AssetItem): string | undefined {
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_TAG &&
modelTypes.value.some((type) => type.value === tag)
)
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
}
function blockMismatchedImportedModel(
asset: AssetItem,
requiredType: string
): boolean {
if (asset.tags.includes(requiredType)) return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
uploadError.value = ''
uploadTypeMismatch.value = {
importedModelType: importedType,
importedModelTypeLabel: importedType
? getModelTypeLabel(importedType)
: undefined,
requiredModelType: requiredType,
requiredModelTypeLabel: getModelTypeLabel(requiredType)
}
return true
}
async function uploadModel(): Promise<UploadModelSuccess | null> {
if (isUploading.value) return null
if (!canUploadModel.value) {
return false
return null
}
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
return false
return null
}
isUploading.value = true
uploadTypeMismatch.value = null
let uploadSuccess: UploadModelSuccess | null = null
try {
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const modelType = resolvedModelType.value
const tags = modelType ? ['models', modelType] : ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
@@ -230,7 +326,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const userMetadata = {
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
model_type: modelType
}
const result = await assetService.uploadAssetAsync({
@@ -241,14 +337,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
})
if (result.type === 'async' && result.task.status !== 'completed') {
if (selectedModelType.value) {
if (modelType) {
assetDownloadStore.trackDownload(
result.task.task_id,
selectedModelType.value,
modelType,
filename
)
}
uploadStatus.value = 'processing'
uploadSuccess = {
filename,
modelType,
taskId: result.task.task_id,
status: 'processing'
}
stopAsyncWatch?.()
let resolved = false
@@ -288,8 +390,23 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
stopAsyncWatch = stop
}
} else {
if (
requiredModelType &&
result.type === 'sync' &&
modelType &&
blockMismatchedImportedModel(result.asset, modelType)
) {
currentStep.value = 3
return null
}
uploadStatus.value = 'success'
await refreshModelCaches()
uploadSuccess = {
filename,
modelType,
status: 'success'
}
}
currentStep.value = 3
} catch (error) {
@@ -301,7 +418,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
} finally {
isUploading.value = false
}
return uploadStatus.value !== 'error'
return uploadSuccess
}
function goToPreviousStep() {
@@ -318,12 +435,13 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
isUploading.value = false
uploadStatus.value = undefined
uploadError.value = ''
uploadTypeMismatch.value = null
wizardData.value = {
url: '',
name: '',
tags: []
}
selectedModelType.value = undefined
selectedModelType.value = requiredModelType
}
return {
@@ -333,6 +451,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,

View File

@@ -22,7 +22,7 @@ const zAsset = z.object({
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true })
.pick({ total: true, has_more: true, next_cursor: true })
.extend({
assets: z.array(zAsset)
})

View File

@@ -53,6 +53,7 @@ const fetchApiMock = vi.mocked(api.fetchApi)
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
nextCursor?: AssetResponse['next_cursor']
}
function buildResponse(
@@ -68,9 +69,18 @@ function buildResponse(
function buildAssetListResponse(
assets: AssetItem[],
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
{
hasMore = false,
total = assets.length,
nextCursor
}: AssetListResponseOptions = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
return buildResponse({
assets,
total,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
@@ -512,7 +522,7 @@ describe(assetService.getAllAssetsByTag, () => {
vi.clearAllMocks()
})
it('paginates tagged asset requests with include_public=true', async () => {
it('walks pages by keyset cursor with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
@@ -520,7 +530,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-page-2' }
)
)
.mockResolvedValueOnce(
@@ -538,6 +548,8 @@ describe(assetService.getAllAssetsByTag, () => {
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
// First page carries neither a cursor nor an offset.
expect(firstParams.has('after')).toBe(false)
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
@@ -545,7 +557,9 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
// Subsequent pages resume from the prior response's next_cursor, never offset.
expect(secondParams.get('after')).toBe('cursor-page-2')
expect(secondParams.has('offset')).toBe(false)
})
it('honors has_more when walking tagged asset pages', async () => {
@@ -556,7 +570,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-next' }
)
)
.mockResolvedValueOnce(
@@ -577,7 +591,45 @@ describe(assetService.getAllAssetsByTag, () => {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
expect(secondParams.get('after')).toBe('cursor-next')
})
it('stops walking when next_cursor is absent even if has_more is true', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'only', tags: ['input'] })], {
hasMore: true
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['only'])
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('stops walking when the server returns a non-advancing cursor', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'b', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 1
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it.for([
@@ -636,7 +688,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-page-2' }
)
})

View File

@@ -31,6 +31,11 @@ export interface PaginationOptions {
}
interface AssetPaginationOptions extends PaginationOptions {
/**
* Opaque keyset cursor from a prior response's `next_cursor`. When set, the
* server resumes after that cursor and `offset` is ignored.
*/
after?: string
signal?: AbortSignal
}
@@ -38,6 +43,7 @@ interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
after?: string
signal?: AbortSignal
}
@@ -286,6 +292,7 @@ function createAssetService() {
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
after,
includePublic,
signal
} = options
@@ -299,7 +306,11 @@ function createAssetService() {
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
if (offset !== undefined && offset > 0) {
// `after` (keyset cursor) takes precedence over `offset`; the server ignores
// `offset` when a cursor is supplied, so we avoid sending a redundant param.
if (after) {
queryParams.set('after', after)
} else if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
if (includePublic !== undefined) {
@@ -481,11 +492,17 @@ function createAssetService() {
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const data = await getAssetsPageByTag(tag, includePublic, {
limit,
offset,
after,
signal
})
@@ -498,17 +515,27 @@ function createAssetService() {
async function getAssetsPageByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
): Promise<AssetResponse> {
return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
{ includeTags: [tag], limit, offset, after, includePublic, signal },
`assets for tag ${tag}`
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
* Pagination follows the required server-provided `has_more` flag.
*
* Uses keyset (cursor) pagination: each page is fetched with the prior
* response's `next_cursor`, which is stable under concurrent inserts/deletes
* and avoids the duplicate/skip drift that offset paging exhibits when the
* underlying set changes mid-walk. Falls back to terminating on `has_more`
* when the server omits `next_cursor`.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -520,18 +547,21 @@ function createAssetService() {
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
signal
}: Pick<AssetPaginationOptions, 'limit' | 'signal'> = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let offset = 0
let after: string | undefined
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await getAssetsPageByTag(tag, includePublic, {
limit: pageSize,
offset,
after,
signal
})
const batch = data.assets
@@ -541,11 +571,12 @@ function createAssetService() {
assets.push(...batch)
if (!data.has_more) {
// A server that returns a non-advancing cursor would loop forever.
if (!data.has_more || !data.next_cursor || data.next_cursor === after) {
return assets
}
offset += batch.length
after = data.next_cursor
}
}

View File

@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import CreditSlider from './CreditSlider.vue'
const meta: Meta<typeof CreditSlider> = {
title: 'Platform/Subscription/CreditSlider',
component: CreditSlider,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
disabled: { control: 'boolean' }
},
args: {
disabled: false
},
decorators: [
(story) => ({
components: { story },
// Previews at the real layout width: the Figma "Team Plan" card column is
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
// width the slider actually renders into inside PricingTableWorkspace.
template: '<div class="w-[512px] px-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
// In production this comes from the API; here it shows the stops being driven
// entirely through props rather than the hardcoded default constant.
const apiTeamCreditStops = {
default_stop_index: 2,
stops: [
{
id: 'team_200',
credits: 42_200,
yearly: { price_cents: 20_000, discount_percent: 0 }
},
{
id: 'team_400',
credits: 84_400,
yearly: { price_cents: 38_000, discount_percent: 5 }
},
{
id: 'team_700',
credits: 147_700,
yearly: { price_cents: 63_000, discount_percent: 10 }
},
{
id: 'team_1400',
credits: 295_400,
yearly: { price_cents: 119_000, discount_percent: 15 }
},
{
id: 'team_2500',
credits: 527_500,
yearly: { price_cents: 200_000, discount_percent: 20 }
}
]
}
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
// The pre-discount list price is recovered as discounted / (1 - discount).
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
credits: s.credits,
discountPercentYearly: s.yearly.discount_percent,
usd: Math.round(
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
)
}))
export const BackendDrivenStops: Story = {
name: 'Backend-driven stops (props)',
render: (args) => ({
components: { CreditSlider },
setup() {
const defaultStopIndex = apiTeamCreditStops.default_stop_index
const value = ref(mappedStops[defaultStopIndex].usd)
return { args, value, mappedStops, defaultStopIndex }
},
template:
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
})
}

View File

@@ -0,0 +1,189 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { usdToCredits } from '@/base/credits/comfyCredits'
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import CreditSlider from './CreditSlider.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
usdPerMonth: 'USD / mo',
billedYearly: '{total} Billed yearly',
creditSliderSave: 'Save {percent}% ({amount})'
}
}
}
})
function renderSlider(props: Record<string, unknown> = {}) {
return render(CreditSlider, { props, global: { plugins: [i18n] } })
}
async function flush() {
await nextTick()
await nextTick()
}
describe('CreditSlider', () => {
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
renderSlider()
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '4')
expect(thumb).toHaveAttribute('aria-valuenow', '2')
})
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith(1400)
})
it('snaps to the previous fixed stop on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalledWith(400)
})
it('emits change with the full {index, usd, credits} payload', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderSlider({ modelValue: 700, onChange })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith({
index: 3,
usd: 1400,
credits: 295_400
})
})
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
const onChange = vi.fn()
renderSlider({
modelValue: 700,
disabled: true,
'onUpdate:modelValue': onUpdate,
onChange
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()
})
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
renderSlider() // default $700 stop → 10% yearly discount
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 10% ($70)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'$7,560'
)
})
it('hides the discount UI at the 0% stop ($200)', async () => {
renderSlider({ modelValue: 200 })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
expect(
screen.queryByTestId('credit-slider-original-price')
).not.toBeInTheDocument()
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
})
it('renders all five fixed credit stop labels', async () => {
renderSlider({ modelValue: 700 })
await flush()
const stops = within(screen.getByTestId('credit-slider-stops'))
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
expect(stops.getByText(label)).toBeInTheDocument()
}
})
it('renders nothing when stops is empty (defensive for BE-sourced data)', async () => {
renderSlider({ stops: [] })
await flush()
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
expect(screen.queryByTestId('credit-slider-price')).not.toBeInTheDocument()
})
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
const stops = [
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
]
// No modelValue → the model default ($700) matches no stop, so selectedIndex
// falls back to defaultStopIndex (here index 1 → $100).
renderSlider({ stops, defaultStopIndex: 1 })
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$100')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 25% ($25)'
)
// Only the prop's labels render — none of the DES-197 defaults.
const labels = within(screen.getByTestId('credit-slider-stops'))
expect(labels.getByText('10.6K')).toBeInTheDocument()
expect(labels.getByText('21.1K')).toBeInTheDocument()
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
})
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
expect(stop.credits).toBe(usdToCredits(stop.usd))
}
})
})

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import {
TransitionPresets,
usePreferredReducedMotion,
useTransition
} from '@vueuse/core'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
const {
disabled = false,
class: rootClass,
stops = TEAM_PLAN_CREDIT_STOPS,
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX
} = defineProps<{
disabled?: boolean
class?: HTMLAttributes['class']
/**
* The fixed credit stops the slider snaps to; when empty, the component
* renders nothing. Defaults to the hardcoded DES-197 set; pass the
* backend-sourced stops once the contract lands — map
* `GET /api/billing/plans → team_credit_stops.stops` to `CreditStop[]`
* (credits, the pre-discount `usd`, and `discountPercentYearly`).
*/
stops?: readonly CreditStop[]
/**
* Stop selected when the bound value matches none (e.g. first render).
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
*/
defaultStopIndex?: number
}>()
const emit = defineEmits<{
/** Fired when the selected stop changes, with the full derived payload. */
change: [stop: { index: number; usd: number; credits: number }]
}>()
/**
* v-model carries the selected USD value (one of the `stops`). The literal
* default keeps `defineModel` statically analyzable; when custom `stops` are
* passed without a matching v-model, `selectedIndex` falls back to
* `defaultStopIndex`, so the displayed stop is still correct.
*/
const usd = defineModel<number>({
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
})
const selectedIndex = computed(() => {
const i = stops.findIndex((stop) => stop.usd === usd.value)
if (i !== -1) return i
// Fall back to the default stop, clamped into range: a backend-driven `stops`
// array can be shorter than expected (or `defaultStopIndex` out of bounds).
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
})
// Zero-stop fallback: `useTransition` reads its source eagerly at setup, so an
// empty `stops` must not crash even though the template then renders nothing.
const EMPTY_STOP: CreditStop = { usd: 0, credits: 0, discountPercentYearly: 0 }
const current = computed(() => stops.at(selectedIndex.value) ?? EMPTY_STOP)
// Yearly commitment (per DES-197): the discount applies to the monthly figure.
// The card shows the discounted monthly price, the struck pre-discount price,
// the saving, and the annual total.
const discountedMonthly = computed(() =>
Math.round(
current.value.usd * (1 - current.value.discountPercentYearly / 100)
)
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => current.value.discountPercentYearly > 0)
/**
* Smoothly count the price figures up/down as the slider moves between stops
* instead of snapping. Honors the user's reduced-motion preference. The save
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
* tier, so animating the bracketed amount alone would read inconsistently.
*/
const prefersReducedMotion = usePreferredReducedMotion()
const priceTween = {
duration: 350,
easing: TransitionPresets.easeOutCubic,
disabled: computed(() => prefersReducedMotion.value === 'reduce')
}
// One vector tween keeps both figures in phase. Deriving the monthly from the
// animated original instead would jump at the start of each move: the discount
// tier snaps per stop while the base price is still mid-tween.
const animatedPrices = useTransition(
() => [discountedMonthly.value, current.value.usd],
priceTween
)
const displayMonthly = computed(() => Math.round(animatedPrices.value[0]))
const displayOriginal = computed(() => Math.round(animatedPrices.value[1]))
// Derive the yearly total from the displayed monthly so it always reads as
// exactly 12× the price shown — even mid-count — rather than drifting as a
// second, independently-phased tween would.
const displayBilledYearly = computed(() => displayMonthly.value * 12)
/**
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
* model. Driving the slider in index space with `step = 1` guarantees the
* thumb can only land on the fixed stops — never a value in between.
*/
const sliderModel = computed<number[]>({
get: () => [selectedIndex.value],
set: ([index]) => {
const stop = stops[index]
if (!stop) return
usd.value = stop.usd
emit('change', { index, usd: stop.usd, credits: stop.credits })
}
})
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
const formatCreditsCompact = (value: number) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1
}).format(value)
const { t } = useI18n()
</script>
<template>
<div
v-if="stops.length > 0"
:class="cn('flex w-full flex-col gap-3', rootClass)"
>
<!-- Price: discounted monthly + struck pre-discount + save badge -->
<div class="flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span
class="text-[2rem] leading-none font-semibold text-base-foreground"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}
</span>
<span
v-if="hasDiscount"
class="text-base text-muted-foreground line-through"
data-testid="credit-slider-original-price"
>
{{ formatUsd(displayOriginal) }}
</span>
<span class="text-base text-muted-foreground">
{{ t('subscription.usdPerMonth') }}
</span>
</span>
<!-- Save badge: outlined primary pill, pushed to the right (DES-197) -->
<span
v-if="hasDiscount"
data-testid="credit-slider-save"
class="ms-auto shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background"
>
{{
t('subscription.creditSliderSave', {
percent: current.discountPercentYearly,
amount: formatUsd(saveAmount)
})
}}
</span>
</div>
<p
class="m-0 text-sm text-muted-foreground"
data-testid="credit-slider-billed-yearly"
>
{{
t('subscription.billedYearly', {
total: formatUsd(displayBilledYearly)
})
}}
</p>
</div>
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
<Slider
v-model="sliderModel"
:min="0"
:max="lastIndex"
:step="1"
:disabled="disabled"
/>
<!-- Credit stop labels; the selected stop is emphasized -->
<ol
data-testid="credit-slider-stops"
class="m-0 flex list-none justify-between p-0"
>
<li
v-for="(stop, i) in stops"
:key="stop.usd"
:data-selected="i === selectedIndex ? '' : undefined"
:class="
cn(
'flex items-center gap-1 text-xs',
i === selectedIndex
? 'font-semibold text-base-foreground'
: 'text-muted-foreground'
)
"
>
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"
aria-hidden="true"
/>
{{ formatCreditsCompact(stop.credits) }}
</li>
</ol>
</div>
</template>

View File

@@ -0,0 +1,39 @@
export interface CreditStop {
/** Monthly subscription price in USD (pre-discount). */
usd: number
/** Monthly credit grant at this stop. */
credits: number
/**
* Yearly-commitment discount applied to `usd`, as a whole-number percent.
* Threshold-based per the pricing decision (Slack — Alex Tov, 2026-05-08):
* yearly tiers are 0 / 5 / 10 / 15 / 20% with nothing in between (monthly is
* halved, but still being iterated). Only the $700 → 10% tier is
* design-confirmed (DES-197 shows "Save 10% ($70)"); the rest follow the
* agreed 0/5/10/15/20 sequence and should be re-confirmed with design/BE.
*/
discountPercentYearly: number
}
/**
* Team-plan credit-subscription slider stops.
*
* Hardcoded per Figma DES-197 (Updates to PricingTable dialog): the team-plan
* credit slider snaps to exactly these 5 fixed breakpoints — the user cannot
* select a value in between. The `credits` figures equal `usdToCredits(usd)` at
* the current rate (`CREDITS_PER_USD = 211`); a unit test guards against rate
* drift silently changing the designed values.
*
* TODO(FE-934): once the backend slider contract lands, these stops (and their
* discount tiers) will come from `GET /api/billing/plans` instead of being
* hardcoded here.
*/
export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
{ usd: 200, credits: 42_200, discountPercentYearly: 0 },
{ usd: 400, credits: 84_400, discountPercentYearly: 5 },
{ usd: 700, credits: 147_700, discountPercentYearly: 10 },
{ usd: 1_400, credits: 295_400, discountPercentYearly: 15 },
{ usd: 2_500, credits: 527_500, discountPercentYearly: 20 }
] as const
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2

View File

@@ -1,24 +1,36 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template:
'<div class="model-row" :data-show-node-id-badge="showNodeIdBadge" :data-is-asset-supported="isAssetSupported" :data-directory="directory"><button class="locate-trigger" @click="$emit(\'locate-model\', model?.representative?.nodeId)">Locate</button></div>',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
template: `
<div
data-testid="model-row"
class="model-row"
:data-model-name="model.name"
:data-is-asset-supported="isAssetSupported"
:data-directory="directory"
>
<button
class="locate-trigger"
@click="$emit('locate-model', model?.representative?.nodeId)"
>
Locate
</button>
</div>
`,
props: ['model', 'directory', 'isAssetSupported'],
emits: ['locate-model']
}
}))
@@ -35,21 +47,7 @@ import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category',
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
}
}
}
},
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
@@ -106,7 +104,6 @@ function makeGroup(
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {},
onLocateModel?: (nodeId: string) => void
) {
@@ -114,7 +111,6 @@ function mountCard(
return render(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props,
...(onLocateModel ? { onLocateModel } : {})
},
@@ -124,62 +120,70 @@ function mountCard(
})
}
function getRows() {
return screen.queryAllByTestId('model-row')
}
describe('MissingModelCard', () => {
beforeEach(() => {
mockIsCloud.value = true
})
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const { container } = mountCard({
it('passes the model directory to rows', () => {
mockIsCloud.value = false
mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(container.textContent).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(container.textContent).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(container.textContent).toContain('(2)')
expect(getRows()[0].getAttribute('data-directory')).toBe('loras')
})
it('renders correct number of MissingModelRow components', () => {
const { container } = mountCard({
mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(3)
expect(getRows()).toHaveLength(3)
})
it('renders multiple groups', () => {
const { container } = mountCard({
it('flattens multiple groups into rows', () => {
mockIsCloud.value = false
mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).toContain('loras')
expect(getRows()).toHaveLength(2)
})
it('sorts rows by model type order in cloud', () => {
mountCard({
missingModelGroups: [
makeGroup({ directory: null, modelNames: ['unknown.safetensors'] }),
makeGroup({ directory: 'loras', modelNames: ['lora.safetensors'] }),
makeGroup({
directory: 'checkpoints',
modelNames: ['checkpoint.safetensors']
})
]
})
expect(
getRows().map((row) => row.getAttribute('data-model-name'))
).toEqual([
'checkpoint.safetensors',
'lora.safetensors',
'unknown.safetensors'
])
})
it('renders zero rows when missingModelGroups is empty', () => {
const { container } = mountCard({ missingModelGroups: [] })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
mountCard({ missingModelGroups: [] })
expect(getRows()).toHaveLength(0)
})
it('hides bulk actions in cloud', () => {
@@ -191,31 +195,21 @@ describe('MissingModelCard', () => {
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('passes props correctly to MissingModelRow children', () => {
const { container } = mountCard({ showNodeIdBadge: true })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.model-row')
expect(row).not.toBeNull()
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
expect(row!.getAttribute('data-is-asset-supported')).toBe('true')
expect(row!.getAttribute('data-directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
it('does not show the unsupported group header in cloud', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain('Import Not Supported')
expect(container.textContent).not.toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
it('does not show the unsupported group notice in cloud', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain(
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
@@ -251,13 +245,12 @@ describe('MissingModelCard (OSS)', () => {
})
it('shows directory name instead of "Import Not Supported" for unsupported groups', () => {
const { container } = mountCard({
mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints', isAssetSupported: false })
]
})
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).not.toContain('Import Not Supported')
expect(getRows()[0].getAttribute('data-directory')).toBe('checkpoints')
})
it('hides info notice for unsupported groups', () => {
@@ -269,61 +262,39 @@ describe('MissingModelCard (OSS)', () => {
)
})
it('renders unknown category for null directory in OSS', () => {
it('passes null directory for unknown category rows in OSS', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ directory: null, isAssetSupported: false })
]
})
expect(container.textContent).toContain('Unknown Category')
expect(getRows()[0].hasAttribute('data-directory')).toBe(false)
expect(container.textContent).not.toContain('Import Not Supported')
})
it('shows bulk actions when one model is downloadable', () => {
it('shows Download all at the bottom when one model is downloadable', () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
const actions = screen.getByTestId('missing-model-actions')
expect(actions).toBeVisible()
expect(
within(actions).getByRole('button', { name: /Download all/ })
).toBeVisible()
expect(
within(actions).queryByRole('button', { name: 'Refresh' })
).not.toBeInTheDocument()
})
it('hides bulk actions when no model is downloadable', () => {
it('hides Download all when no model is downloadable', () => {
mountCard()
expect(
screen.queryByRole('button', { name: /Download all/ })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Refresh' })
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('refreshes missing models from the action bar', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(store.refreshMissingModels).toHaveBeenCalled()
})
it('keeps the Refresh button focusable and announces refresh progress', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
store.isRefreshingMissingModels = true
await nextTick()
const refreshButton = screen.getByRole('button', { name: 'Refresh' })
expect(refreshButton).toHaveAttribute('aria-disabled', 'true')
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('status')).toHaveTextContent(
'Refreshing missing models.'
)
})
})

View File

@@ -1,9 +1,20 @@
<template>
<div class="px-4 pb-2">
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingModelRow
v-for="row in sortedModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="downloadableModels.length > 0"
data-testid="missing-model-actions"
class="flex items-center gap-2 border-b border-interface-stroke py-2"
class="flex items-center pt-2"
>
<Button
data-testid="missing-model-download-all"
@@ -15,100 +26,6 @@
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
<span class="truncate">{{ downloadAllLabel }}</span>
</Button>
<!-- Keep this focusable while refreshing so the live status remains discoverable. -->
<Button
data-testid="missing-model-refresh"
variant="secondary"
size="sm"
class="h-8 w-28 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click="handleRefreshClick"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span role="status" aria-live="polite" class="sr-only">
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</div>
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
(isCloud && !group.isAssetSupported) || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="isCloud && !group.isAssetSupported">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
<i
v-if="group.directory === null"
aria-hidden="true"
class="mr-1 icon-[lucide--triangle-alert] size-3.5 align-text-bottom"
/>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="isCloud && !group.isAssetSupported"
data-testid="missing-model-import-unsupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
@@ -120,15 +37,28 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
interface MissingModelRowEntry {
key: string
model: MissingModelGroup['models'][number]
directory: string | null
isAssetSupported: boolean
}
const MODEL_TYPE_SORT_ORDER = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -138,6 +68,19 @@ const emit = defineEmits<{
const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const sortedModelRows = computed(() =>
missingModelGroups
.flatMap((group) =>
group.models.map((model, index) => ({
key: getModelRowKey(group, model, index),
model,
directory: group.directory,
isAssetSupported: group.isAssetSupported
}))
)
.sort((a, b) => compareModelRows(a, b))
)
const downloadableModels = computed(() => {
if (isCloud) return []
@@ -159,7 +102,33 @@ function downloadAllModels() {
}
}
function handleRefreshClick() {
void missingModelStore.refreshMissingModels()
function getModelRowKey(
group: MissingModelGroup,
model: MissingModelGroup['models'][number],
index: number
) {
const supportKey = group.isAssetSupported ? 'supported' : 'unsupported'
return [
supportKey,
group.directory ?? '__unknown__',
model.name,
String(index)
].join('::')
}
function compareModelRows(a: MissingModelRowEntry, b: MissingModelRowEntry) {
return (
getModelTypeSortIndex(a.directory) - getModelTypeSortIndex(b.directory) ||
(a.directory ?? '').localeCompare(b.directory ?? '') ||
a.model.name.localeCompare(b.model.name)
)
}
function getModelTypeSortIndex(directory: string | null) {
if (directory === null) return Number.MAX_SAFE_INTEGER
const index = MODEL_TYPE_SORT_ORDER.indexOf(
directory as (typeof MODEL_TYPE_SORT_ORDER)[number]
)
return index === -1 ? MODEL_TYPE_SORT_ORDER.length : index
}
</script>

View File

@@ -1,113 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -0,0 +1,442 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockDownloadModel = vi.hoisted(() => vi.fn())
const mockRootGraph = vi.hoisted<{
value: Record<string, never> | null
}>(() => ({ value: null }))
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
const mockApiListeners = vi.hoisted(
() => new Map<string, (event: CustomEvent) => void>()
)
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
const mockUploadContext = vi.hoisted(() => ({
resolver: undefined as UploadModelContextResolver | undefined
}))
const mockUploadCallbacks = vi.hoisted(() => ({
onUploadSuccess: undefined as
| ((result: UploadModelSuccess) => Promise<unknown> | unknown)
| undefined
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mockRootGraph.value
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(
(event: string, handler: (event: CustomEvent) => void) => {
mockApiListeners.set(event, handler)
}
),
apiURL: vi.fn((path: string) => path),
fetchApi: vi.fn()
}
}))
vi.mock('@/utils/graphTraversalUtil', async () => {
const actual = await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
return {
...actual,
getActiveGraphNodeIds: vi.fn(() => new Set()),
getNodeByExecutionId: mockGetNodeByExecutionId
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: (
onUploadSuccess?: (
result: UploadModelSuccess
) => Promise<unknown> | unknown,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) => {
mockUploadCallbacks.onUploadSuccess = onUploadSuccess
mockUploadContext.resolver =
typeof uploadContext === 'function' ? uploadContext : () => uploadContext
return {
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
}
}
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
})
}))
vi.mock('@/platform/missingModel/missingModelDownload', async () => {
const actual = await vi.importActual<typeof MissingModelDownload>(
'@/platform/missingModel/missingModelDownload'
)
return {
...actual,
downloadModel: mockDownloadModel,
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
})
}
})
import MissingModelRow from './MissingModelRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
const TransitionCollapseStub = {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
function makeModel(
refs: MissingModelViewModel['referencingNodes']
): MissingModelViewModel {
return {
name: 'model.safetensors',
representative: {
nodeId: refs[0]?.nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'model.safetensors',
directory: 'checkpoints',
url: 'https://example.com/model.safetensors',
isMissing: true
},
referencingNodes: refs
}
}
function renderRow(
model: MissingModelViewModel,
onLocateModel = vi.fn(),
isAssetSupported = true,
directory: string | null = 'checkpoints'
) {
const pinia = createPinia()
setActivePinia(pinia)
render(MissingModelRow, {
props: {
model,
directory,
isAssetSupported,
onLocateModel
},
global: {
plugins: [pinia, i18n],
stubs: {
TransitionCollapse: TransitionCollapseStub
}
}
})
return { onLocateModel }
}
describe('MissingModelRow', () => {
beforeEach(() => {
mockIsCloud.value = true
mockShowUploadDialog.mockClear()
mockCopyToClipboard.mockClear()
mockDownloadModel.mockClear()
mockRootGraph.value = null
mockGetNodeByExecutionId.mockReset()
mockUploadContext.resolver = undefined
mockUploadCallbacks.onUploadSuccess = undefined
})
it('opens the model import dialog from the cloud row', async () => {
const user = userEvent.setup()
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Import' }))
expect(mockShowUploadDialog).toHaveBeenCalledTimes(1)
expect(mockUploadContext.resolver?.()).toEqual({
kind: 'missing-model-resolution',
missingModelName: 'model.safetensors',
requiredModelType: 'checkpoints',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name'
}
]
})
})
it('shows row progress as soon as the model import starts', async () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
const store = useMissingModelStore()
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'downloaded-model.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
expect(
store.importTaskIds['supported::checkpoints::model.safetensors']
).toBe('task-1')
expect(
screen.getByRole('progressbar', { name: 'Importing...' })
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Importing...')
expect(screen.getByText('downloaded-model.safetensors')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('applies the completed imported model to every referencing node', async () => {
const graph = {}
const firstWidget = {
name: 'ckpt_name',
value: 'old-first.safetensors',
callback: vi.fn()
}
const secondWidget = {
name: 'ckpt_name',
value: 'old-second.safetensors',
callback: vi.fn()
}
const firstSetDirtyCanvas = vi.fn()
const secondSetDirtyCanvas = vi.fn()
mockRootGraph.value = graph
mockGetNodeByExecutionId.mockImplementation((_graph, nodeId) => {
if (nodeId === '1') {
return {
widgets: [firstWidget],
graph: { setDirtyCanvas: firstSetDirtyCanvas }
}
}
if (nodeId === '2') {
return {
widgets: [secondWidget],
graph: { setDirtyCanvas: secondSetDirtyCanvas }
}
}
return null
})
renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'client-name.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
const handler = mockApiListeners.get('asset_download')
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-1',
asset_name: 'server-name.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 1,
status: 'completed'
}
})
)
await waitFor(() => {
expect(firstWidget.value).toBe('server-name.safetensors')
expect(secondWidget.value).toBe('server-name.safetensors')
})
expect(firstWidget.callback).toHaveBeenCalledWith('server-name.safetensors')
expect(secondWidget.callback).toHaveBeenCalledWith(
'server-name.safetensors'
)
expect(firstSetDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(secondSetDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('locates the parent row directly when a cloud model has one reference', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('moves locate actions to expanded child rows when a cloud model has multiple references', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('locates the parent row directly when an OSS model has one reference', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('does not show the library selector in OSS rows', () => {
mockIsCloud.value = false
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(
screen.getByPlaceholderText('Paste Model URL (Civitai or Hugging Face)')
).toBeInTheDocument()
})
it('shows model type metadata below the model name', () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.getByText('checkpoints')).toBeInTheDocument()
})
it('shows downloadable model size beside the model type metadata', async () => {
mockIsCloud.value = false
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
const store = useMissingModelStore()
store.fileSizes[model.representative.url] = 14 * 1024 ** 3
await nextTick()
expect(screen.getByText('checkpoints · 14 GB')).toBeInTheDocument()
expect(screen.getByTestId('missing-model-download')).toHaveTextContent(
'Download'
)
})
it('shows unknown category metadata for models without a directory', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),
vi.fn(),
true,
null
)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
it('moves locate actions to expanded child rows when an OSS model has multiple references', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('shows the OSS download action in the row for downloadable models', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
await user.click(screen.getByTestId('missing-model-download'))
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: 'model.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{}
)
})
})

View File

@@ -1,72 +1,11 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
data-testid="missing-model-copy-name"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="!isCloud && model.representative.url && !isAssetSupported"
data-testid="missing-model-copy-url"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
>
{{ t('rightSidePanel.missingModels.copyUrl') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
v-if="hasMultipleReferences"
data-testid="missing-model-expand"
variant="textonly"
size="icon-sm"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
@@ -75,116 +14,240 @@
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleModelExpand(modelKey)"
@click="handleToggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleReferences"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleToggleExpand"
>
{{ displayModelName }}
</button>
<button
v-else-if="!isUnknownCategory && primaryReference"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleLocatePrimary"
>
{{ displayModelName }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
:title="displayModelName"
>
{{ displayModelName }}
</span>
<span
v-if="hasMultipleReferences"
data-testid="missing-model-reference-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ model.referencingNodes.length }}
</span>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
>
<i aria-hidden="true" class="icon-[lucide--link] size-4" />
</Button>
</span>
<span
v-if="modelMetadataLabel"
class="text-2xs/none"
:class="
isUnknownCategory
? 'text-warning-background'
: 'text-muted-foreground'
"
>
{{ modelMetadataLabel }}
</span>
</span>
<template v-if="isCloud">
<Button
v-if="!isCloudImportDownloadActive"
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="showUploadDialog"
>
{{ t('g.import') }}
</Button>
<div
v-else
ref="cloudProgress"
role="progressbar"
:aria-label="t('rightSidePanel.missingModels.importing')"
:aria-valuenow="cloudImportProgressPercent"
aria-valuemin="0"
aria-valuemax="100"
tabindex="-1"
class="flex h-8 w-16 shrink-0 items-center"
>
<span
class="block h-1.5 w-full overflow-hidden rounded-full bg-secondary-background-selected"
>
<span
class="block h-full rounded-full bg-primary-background transition-all duration-200 ease-linear"
:style="{ width: `${cloudImportProgressPercent}%` }"
/>
</span>
</div>
<span
v-if="isCloudImportDownloadActive"
role="status"
aria-live="polite"
class="sr-only"
>
{{ t('rightSidePanel.missingModels.importing') }}
</span>
</template>
<template v-else>
<Button
v-if="showDownloadAction"
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
{{ t('g.download') }}
</Button>
<Button
v-else-if="showConfirmAction"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
</template>
<Button
v-if="!hasMultipleReferences && !isUnknownCategory && primaryReference"
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="handleLocatePrimary"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
<ul
v-if="showReferenceList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
)
"
>
<div
<li
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
class="min-w-0"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
<div class="flex min-w-0 items-center gap-2">
<button
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="emit('locateModel', String(ref.nodeId))"
>
{{
getNodeDisplayLabel(ref.nodeId, model.representative.nodeType)
}}
</button>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<template v-if="!isCloud">
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-1"
>
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</div>
<TransitionCollapse>
<div
v-else-if="!isCloud && downloadable"
class="flex w-full items-start py-1"
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-1"
>
<Button
data-testid="missing-model-download"
variant="secondary"
size="md"
class="flex w-full flex-1"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{ downloadLabel }}
</span>
</Button>
</div>
</div>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="isAssetSupported || downloadable"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
</TransitionCollapse>
</TransitionCollapse>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -192,14 +255,14 @@ import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
getNodeDisplayLabel
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
@@ -215,7 +278,6 @@ import { formatSize } from '@/utils/formatUtil'
const { model, directory, isAssetSupported } = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
}>()
@@ -231,21 +293,123 @@ const modelKey = computed(() =>
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isUnknownCategory = computed(() => directory === null)
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const isCloudImportDownloadActive = computed(
() => isCloud && isDownloadActive.value
)
const cloudImportProgressPercent = computed(() =>
Math.round((downloadStatus.value?.progress ?? 0) * 100)
)
const hasMultipleReferences = computed(() => model.referencingNodes.length > 1)
const primaryReference = computed(() => model.referencingNodes[0])
const linkLabel = computed(() =>
model.representative.url
? t('rightSidePanel.missingModels.copyUrl')
: t('rightSidePanel.missingModels.copyModelName')
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const { selectedLibraryModel, importCategoryMismatch } = storeToRefs(store)
const cloudProgress = useTemplateRef<HTMLElement>('cloudProgress')
const modelLabelControl = useTemplateRef<HTMLButtonElement>('modelLabelControl')
const expanded = computed(
() =>
store.modelExpandState[modelKey.value] ??
(isUnknownCategory.value && hasMultipleReferences.value)
)
const showReferenceList = computed(
() =>
(isUnknownCategory.value && model.referencingNodes.length === 1) ||
(hasMultipleReferences.value && expanded.value)
)
const displayModelName = computed(() => {
if (!isCloudImportDownloadActive.value) return model.name
return (
downloadStatus.value?.assetName ??
selectedLibraryModel.value[modelKey.value] ??
model.name
)
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
!isAssetSupported &&
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const showDownloadAction = computed(
() =>
!isCloud &&
downloadable.value &&
!selectedLibraryModel.value[modelKey.value]
)
const showConfirmAction = computed(
() => !isCloud && !!selectedLibraryModel.value[modelKey.value]
)
const downloadSizeLabel = computed(() => {
if (!showDownloadAction.value) return undefined
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? formatSize(size) : undefined
})
const modelTypeLabel = computed(
() => directory ?? t('rightSidePanel.missingModels.unknownCategory')
)
const modelMetadataLabel = computed(() =>
[modelTypeLabel.value, downloadSizeLabel.value].filter(Boolean).join(' · ')
)
const missingModelUploadContext = computed<
UploadModelDialogContext | undefined
>(() => {
if (!directory) return undefined
return {
kind: 'missing-model-resolution',
missingModelName: model.name,
requiredModelType: directory,
replacementTargets: model.referencingNodes.map((ref) => ({
nodeId: String(ref.nodeId),
nodeLabel: getNodeDisplayLabel(ref.nodeId, model.representative.nodeType),
widgetName: ref.widgetName
}))
}
})
const { showUploadDialog } = useModelUpload(
(result) => {
handleUploadedModelImport(modelKey.value, result)
if (result.status === 'success') {
handleLibrarySelect()
}
},
() => missingModelUploadContext.value
)
onMounted(() => {
if (isCloud) return
const url = model.representative.url
if (url && !store.fileSizes[url]) {
fetchModelMetadata(url)
@@ -263,27 +427,6 @@ onMounted(() => {
}
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
!isAssetSupported &&
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const downloadLabel = computed(() => {
const base = t('g.download')
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? `${base} (${formatSize(size)})` : base
})
function handleDownload() {
const rep = model.representative
if (rep.url && rep.directory) {
@@ -296,18 +439,51 @@ function handleDownload() {
}
}
function handleLocatePrimary() {
const ref = primaryReference.value
if (ref) emit('locateModel', String(ref.nodeId))
}
function copyModelLink() {
const url = model.representative.url
copyToClipboard(url ? toBrowsableUrl(url) : model.name)
}
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
getDownloadStatus,
handleUploadedModelImport
} = useMissingModelInteractions()
function handleToggleExpand() {
store.modelExpandState[modelKey.value] = !expanded.value
}
watch(
() => downloadStatus.value?.status,
(status) => {
if (!isCloud || status !== 'completed') return
const completedAssetName = downloadStatus.value?.assetName
if (completedAssetName) {
selectedLibraryModel.value[modelKey.value] = completedAssetName
}
handleLibrarySelect()
},
{ immediate: true }
)
watch(isCloudImportDownloadActive, async (isActive, wasActive) => {
await nextTick()
if (isActive) {
cloudProgress.value?.focus()
} else if (wasActive) {
modelLabelControl.value?.focus()
}
})
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,

View File

@@ -3,7 +3,6 @@
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
@@ -65,7 +64,7 @@
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
{{ t('rightSidePanel.missingModels.readyToApply') }}
</template>
</span>
</div>

View File

@@ -1,6 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
@@ -10,29 +14,16 @@ const mockGetAssetMetadata = vi.fn()
const mockUploadAssetAsync = vi.fn()
const mockTrackDownload = vi.fn()
const mockInvalidateModelsForCategory = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
@@ -55,7 +46,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
updateModelsForTag: vi.fn()
@@ -84,11 +74,6 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
@@ -112,7 +97,6 @@ vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
@@ -133,17 +117,54 @@ function makeCandidate(
}
describe('useMissingModelInteractions', () => {
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupMissingModelInteractions(): ReturnType<
typeof useMissingModelInteractions
> {
return setupWithI18n(() => useMissingModelInteractions())
}
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
@@ -184,101 +205,31 @@ describe('useMissingModelInteractions', () => {
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = useMissingModelInteractions()
const { isModelExpanded } = setupMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
setupMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
setupMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
const { isSelectionConfirmable } = setupMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
@@ -290,7 +241,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = useMissingModelInteractions()
const { isSelectionConfirmable } = setupMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
@@ -299,7 +250,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = useMissingModelInteractions()
const { isSelectionConfirmable } = setupMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
@@ -308,7 +259,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = useMissingModelInteractions()
const { isSelectionConfirmable } = setupMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
@@ -318,12 +269,14 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
store.importTaskIds['key1'] = 'task-123'
const { cancelLibrarySelect } = useMissingModelInteractions()
const { cancelLibrarySelect } = setupMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
expect(store.importTaskIds['key1']).toBeUndefined()
})
})
@@ -347,6 +300,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.importTaskIds['key1'] = 'task-123'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
@@ -354,7 +308,7 @@ describe('useMissingModelInteractions', () => {
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
@@ -372,6 +326,7 @@ describe('useMissingModelInteractions', () => {
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importTaskIds['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
@@ -379,7 +334,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -391,7 +346,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -407,7 +362,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = useMissingModelInteractions()
const { confirmLibrarySelect } = setupMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
@@ -421,7 +376,7 @@ describe('useMissingModelInteractions', () => {
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = useMissingModelInteractions()
const { handleUrlInput } = setupMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
@@ -434,7 +389,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
const { handleUrlInput } = setupMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
@@ -444,7 +399,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
const { handleUrlInput } = setupMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
@@ -458,7 +413,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
const { handleUrlInput } = setupMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
@@ -467,12 +422,12 @@ describe('useMissingModelInteractions', () => {
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = useMissingModelInteractions()
const { getTypeMismatch } = setupMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = useMissingModelInteractions()
const { getTypeMismatch } = setupMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
@@ -480,7 +435,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = useMissingModelInteractions()
const { getTypeMismatch } = setupMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
@@ -491,7 +446,7 @@ describe('useMissingModelInteractions', () => {
tags: ['checkpoints']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
const { getTypeMismatch } = setupMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
@@ -502,7 +457,7 @@ describe('useMissingModelInteractions', () => {
tags: ['loras']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
const { getTypeMismatch } = setupMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
@@ -513,63 +468,14 @@ describe('useMissingModelInteractions', () => {
tags: ['other', 'random']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
const { getTypeMismatch } = setupMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
describe('getComboOptions', () => {
it('returns assets from assetsStore when the model is asset-supported', () => {
mockGetAssets.mockReturnValueOnce([
{ name: 'modelA.safetensors' },
{ name: 'modelB.safetensors' }
])
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate({ isAssetSupported: true }))
expect(mockGetAssets).toHaveBeenCalledWith('CheckpointLoaderSimple')
expect(options).toEqual([
{ name: 'modelA.safetensors', value: 'modelA.safetensors' },
{ name: 'modelB.safetensors', value: 'modelB.safetensors' }
])
})
it('returns widget options when the model is not asset-supported', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [
{
name: 'ckpt_name',
value: '',
options: { values: ['v1.safetensors', 'v2.safetensors'] }
}
]
})
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate())
expect(options).toEqual([
{ name: 'v1.safetensors', value: 'v1.safetensors' },
{ name: 'v2.safetensors', value: 'v2.safetensors' }
])
})
it('returns an empty array when the widget has no options.values', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: '' }]
})
const { getComboOptions } = useMissingModelInteractions()
expect(getComboOptions(makeCandidate())).toEqual([])
})
})
describe('getDownloadStatus', () => {
it('returns null when no taskId is tracked for the key', () => {
const { getDownloadStatus } = useMissingModelInteractions()
const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
@@ -581,7 +487,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-42', status: 'created' }
])
const { getDownloadStatus } = useMissingModelInteractions()
const { getDownloadStatus } = setupMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
taskId: 'task-42',
status: 'created'
@@ -608,7 +514,7 @@ describe('useMissingModelInteractions', () => {
task: { task_id: 'task-99', status: 'created' }
})
const { handleImport } = useMissingModelInteractions()
const { handleImport } = setupMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importTaskIds['key1']).toBe('task-99')
@@ -626,7 +532,7 @@ describe('useMissingModelInteractions', () => {
task: { task_id: 'task-100', status: 'completed' }
})
const { handleImport } = useMissingModelInteractions()
const { handleImport } = setupMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
@@ -641,7 +547,7 @@ describe('useMissingModelInteractions', () => {
asset: { tags: ['models', 'loras'] }
})
const { handleImport } = useMissingModelInteractions()
const { handleImport } = setupMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importCategoryMismatch['key1']).toBe('loras')
@@ -651,7 +557,7 @@ describe('useMissingModelInteractions', () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockRejectedValueOnce(new Error('Upload boom'))
const { handleImport } = useMissingModelInteractions()
const { handleImport } = setupMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.urlErrors['key1']).toBe('Upload boom')

View File

@@ -3,12 +3,9 @@ import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { UploadModelSuccess } from '@/platform/assets/composables/useUploadModelWizard'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -16,12 +13,7 @@ import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
const importSources = [civitaiImportSource, huggingfaceImportSource]
@@ -58,33 +50,6 @@ export function getNodeDisplayLabel(
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
@@ -102,30 +67,6 @@ export function useMissingModelInteractions() {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
@@ -143,6 +84,7 @@ export function useMissingModelInteractions() {
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
delete store.importTaskIds[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
@@ -189,6 +131,7 @@ export function useMissingModelInteractions() {
}
delete store.selectedLibraryModel[key]
delete store.importTaskIds[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
@@ -307,6 +250,16 @@ export function useMissingModelInteractions() {
}
}
function handleUploadedModelImport(key: string, result: UploadModelSuccess) {
if (result.taskId) {
handleAsyncPending(key, result.taskId, result.modelType, result.filename)
} else if (result.status === 'success') {
handleAsyncCompleted(result.modelType)
}
store.selectedLibraryModel[key] = result.filename
}
function handleSyncResult(
key: string,
tags: string[],
@@ -380,14 +333,13 @@ export function useMissingModelInteractions() {
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleUploadedModelImport,
handleImport
}
}

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
@@ -15,8 +15,11 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: {
nodesCount: '{count} node | {count} nodes'
},
rightSidePanel: {
locateNode: 'Locate Node',
locateNode: 'Locate node on canvas',
missingNodePacks: {
collapse: 'Collapse',
expand: 'Expand'
@@ -48,7 +51,6 @@ function makeGroup(overrides: Partial<SwapNodeGroup> = {}): SwapNodeGroup {
function renderRow(
props: Partial<{
group: SwapNodeGroup
showNodeIdBadge: boolean
'onLocate-node': (nodeId: string) => void
onReplace: (group: SwapNodeGroup) => void
}> = {}
@@ -56,7 +58,6 @@ function renderRow(
return render(SwapNodeGroupRow, {
props: {
group: makeGroup(),
showNodeIdBadge: false,
...props
},
global: {
@@ -75,13 +76,15 @@ describe('SwapNodeGroupRow', () => {
expect(container.textContent).toContain('OldNodeType')
})
it('renders node count in parentheses', () => {
const { container } = renderRow()
expect(container.textContent).toContain('(2)')
it('renders node count as a badge', () => {
renderRow()
const badge = screen.getByLabelText('2 nodes')
expect(badge).toBeInTheDocument()
expect(within(badge).getByText('2')).toBeInTheDocument()
})
it('renders node count of 5 for 5 nodeTypes', () => {
const { container } = renderRow({
renderRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: 'OldNodeType',
@@ -90,7 +93,9 @@ describe('SwapNodeGroupRow', () => {
}))
})
})
expect(container.textContent).toContain('(5)')
const badge = screen.getByLabelText('5 nodes')
expect(badge).toBeInTheDocument()
expect(within(badge).getByText('5')).toBeInTheDocument()
})
it('renders the replacement target name', () => {
@@ -115,106 +120,147 @@ describe('SwapNodeGroupRow', () => {
describe('Expand / Collapse', () => {
it('starts collapsed — node list not visible', () => {
const { container } = renderRow({ showNodeIdBadge: true })
expect(container.textContent).not.toContain('#1')
renderRow()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
it('expands when title is clicked', async () => {
const user = userEvent.setup()
const { container } = renderRow({ showNodeIdBadge: true })
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('#1')
expect(container.textContent).toContain('#2')
renderRow()
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
})
it('collapses when chevron is clicked again', async () => {
it('collapses when title is clicked again', async () => {
const user = userEvent.setup()
const { container } = renderRow({ showNodeIdBadge: true })
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('#1')
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(container.textContent).not.toContain('#1')
renderRow()
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Collapse OldNodeType' })
)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('updates the toggle control state when expanded', async () => {
const user = userEvent.setup()
renderRow()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
const titleButton = screen.getByRole('button', {
name: 'Expand OldNodeType'
})
expect(titleButton).toHaveAttribute('aria-expanded', 'false')
await user.click(titleButton)
const collapseButton = screen.getByRole('button', {
name: 'Collapse OldNodeType'
})
expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
})
})
describe('Node Type List (Expanded)', () => {
async function expand() {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: /^Expand / }))
}
it('renders all nodeTypes when expanded', async () => {
const { container } = renderRow({
renderRow({
group: makeGroup({
type: 'GroupedNodeType',
nodeTypes: [
{ type: 'OldNodeType', nodeId: '10', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '20', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '30', isReplaceable: true }
{ type: 'GroupedNodeType', nodeId: '10', isReplaceable: true },
{ type: 'GroupedNodeType', nodeId: '20', isReplaceable: true },
{ type: 'GroupedNodeType', nodeId: '30', isReplaceable: true }
]
}),
showNodeIdBadge: true
})
})
await expand()
expect(container.textContent).toContain('#10')
expect(container.textContent).toContain('#20')
expect(container.textContent).toContain('#30')
})
expect(screen.queryByRole('list')).not.toBeInTheDocument()
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { container } = renderRow({ showNodeIdBadge: true })
await expand()
expect(container.textContent).toContain('#1')
expect(container.textContent).toContain('#2')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { container } = renderRow({ showNodeIdBadge: false })
await expand()
expect(container.textContent).not.toContain('#1')
expect(container.textContent).not.toContain('#2')
expect(
within(screen.getByRole('list')).getAllByRole('listitem')
).toHaveLength(3)
expect(
within(screen.getByRole('list')).getAllByText('GroupedNodeType')
).toHaveLength(3)
})
it('renders Locate button for each nodeType with nodeId', async () => {
renderRow({ showNodeIdBadge: true })
renderRow()
await expand()
expect(
screen.getAllByRole('button', { name: 'Locate Node' })
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
})
it('does not render Locate button for nodeTypes without nodeId', async () => {
renderRow({
group: makeGroup({
// Intentionally omits nodeId to test graceful handling of incomplete node data
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'NoIdNode', isReplaceable: true }
{ type: 'NoIdNode', isReplaceable: true },
{ type: 'OtherNoIdNode', isReplaceable: true }
])
})
})
await expand()
expect(
screen.queryByRole('button', { name: 'Locate Node' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders locate controls only for locatable nodeTypes', async () => {
renderRow({
group: makeGroup({
type: 'MixedNodeType',
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'MixedNodeType', nodeId: '10', isReplaceable: true },
{ type: 'MixedNodeType', isReplaceable: true }
])
})
})
await expand()
expect(
within(screen.getByRole('list')).getAllByText('MixedNodeType')
).toHaveLength(2)
expect(
within(screen.getByRole('list')).getAllByRole('button', {
name: 'MixedNodeType'
})
).toHaveLength(1)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(1)
})
})
describe('Events', () => {
it('emits locate-node with correct nodeId', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
renderRow({ showNodeIdBadge: true, 'onLocate-node': onLocateNode })
await user.click(screen.getByRole('button', { name: 'Expand' }))
const locateBtns = screen.getAllByRole('button', { name: 'Locate Node' })
renderRow({ 'onLocate-node': onLocateNode })
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
const locateBtns = screen.getAllByRole('button', {
name: 'Locate node on canvas'
})
await user.click(locateBtns[0])
expect(onLocateNode).toHaveBeenCalledWith('1')
@@ -233,24 +279,100 @@ describe('SwapNodeGroupRow', () => {
})
})
describe('Single Node Groups', () => {
it('locates a single node without expanding', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
renderRow({
group: makeGroup({
type: 'SingleNodeType',
nodeTypes: [
{ type: 'SingleNodeType', nodeId: '42', isReplaceable: true }
]
}),
'onLocate-node': onLocateNode
})
expect(
screen.queryByRole('button', { name: /^Expand / })
).not.toBeInTheDocument()
expect(screen.queryByLabelText('1 node')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'SingleNodeType' }))
expect(onLocateNode).toHaveBeenCalledWith('42')
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledTimes(2)
expect(onLocateNode).toHaveBeenLastCalledWith('42')
})
it('renders a single node without nodeId as non-locatable text', () => {
renderRow({
group: makeGroup({
type: 'NoIdNode',
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'NoIdNode', isReplaceable: true }
])
})
})
expect(screen.getByText('NoIdNode')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'NoIdNode' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const { container } = renderRow({
group: makeGroup({ nodeTypes: [] })
renderRow({
group: makeGroup({
nodeTypes: []
})
})
expect(container.textContent).toContain('(0)')
expect(screen.getByText('OldNodeType')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'OldNodeType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^Expand / })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('handles string nodeType entries', async () => {
const user = userEvent.setup()
const { container } = renderRow({
renderRow({
group: makeGroup({
// Intentionally uses a plain string entry to test legacy node type handling
nodeTypes: fromAny<MissingNodeType[], unknown>(['StringType'])
nodeTypes: fromAny<MissingNodeType[], unknown>([
'StringType',
'OtherStringType'
])
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('StringType')
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(screen.getByText('StringType')).toBeInTheDocument()
expect(screen.getByText('OtherStringType')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'StringType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'OtherStringType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
})
})

View File

@@ -1,100 +1,152 @@
<template>
<div class="mb-4 flex w-full flex-col">
<!-- Type header row: type name + chevron -->
<div class="flex h-8 w-full items-center">
<p class="text-foreground min-w-0 flex-1 truncate text-sm font-medium">
{{ `${group.type} (${group.nodeTypes.length})` }}
</p>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="hasMultipleNodeTypes"
variant="textonly"
size="icon-sm"
size="unset"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
aria-hidden="true"
tabindex="-1"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-2 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ nodeType.nodeId }}
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
:aria-label="titleToggleAriaLabel"
:aria-expanded="expanded"
@click="toggleExpand"
>
{{ group.type }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ group.type }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
:title="group.type"
>
{{ group.type }}
</span>
<span
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-count"
role="img"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
>
{{ group.nodeTypes.length }}
</span>
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
@click="handleLocateNode(nodeType)"
</span>
<span class="min-w-0 text-xs/relaxed text-muted-foreground">
{{
t(
'nodeReplacement.willBeReplacedBy',
'This node will be replaced by:'
)
}}
<span
class="inline-flex rounded-sm bg-modal-card-tag-background px-1.5 py-0.5 text-xs/none font-medium text-modal-card-tag-foreground"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
{{ replacementLabel }}
</span>
</span>
</span>
<!-- Description rows: what it is replaced by -->
<div class="mt-1 mb-2 flex flex-col gap-0.5 px-1 text-[13px]">
<span class="text-muted-foreground">{{
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
}}</span>
<span class="text-foreground font-bold">{{
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
}}</span>
</div>
<!-- Replace Action Button -->
<div class="flex w-full items-start py-1">
<Button
variant="secondary"
size="md"
class="flex w-full flex-1"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="handleReplaceNode"
>
<i class="text-foreground mr-1 icon-[lucide--repeat] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--repeat] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
</span>
</Button>
<Button
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul v-if="expanded" class="m-0 list-none space-y-1 p-0 pl-5">
<li
v-for="(nodeType, index) in group.nodeTypes"
:key="getKey(nodeType, index)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -102,9 +154,8 @@ import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCol
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
const { group } = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -115,28 +166,54 @@ const emit = defineEmits<{
const { t } = useI18n()
const expanded = ref(false)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const replacementLabel = computed(
() => group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
)
const locateNodeLabel = computed(() =>
t('rightSidePanel.locateNode', 'Locate node on canvas')
)
const titleToggleAriaLabel = computed(
() =>
`${
expanded.value
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
} ${group.type}`
)
const primaryLocatableNodeType = computed(() => {
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
function getKey(nodeType: MissingNodeType, index: number): string {
if (typeof nodeType === 'string') return `${nodeType}-${index}`
return nodeType.nodeId != null
? String(nodeType.nodeId)
: `${nodeType.type}-${index}`
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locate-node', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locate-node', String(nodeType.nodeId))
}
function handleReplaceNode() {
emit('replace', props.group)
emit('replace', group)
}
</script>

View File

@@ -10,8 +10,8 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
default: {
name: 'SwapNodeGroupRow',
template:
'<div class="swap-row" :data-show-node-id-badge="showNodeIdBadge" :data-group-type="group?.type"><button class="locate-trigger" @click="$emit(\'locate-node\', group?.nodeTypes?.[0]?.nodeId)">Locate</button><button class="replace-trigger" @click="$emit(\'replace\', group)">Replace</button></div>',
props: ['group', 'showNodeIdBadge'],
'<div class="swap-row" :data-group-type="group?.type"><button class="locate-trigger" @click="$emit(\'locate-node\', group?.nodeTypes?.[0]?.nodeId)">Locate</button><button class="replace-trigger" @click="$emit(\'replace\', group)">Replace</button></div>',
props: ['group'],
emits: ['locate-node', 'replace']
}
}))
@@ -29,7 +29,6 @@ function makeGroups(count = 2): SwapNodeGroup[] {
function mountCard(
props: Partial<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}> = {},
callbacks?: {
onLocateNode?: (nodeId: string) => void
@@ -39,7 +38,6 @@ function mountCard(
return render(SwapNodesCard, {
props: {
swapNodeGroups: makeGroups(),
showNodeIdBadge: false,
...props,
...(callbacks?.onLocateNode
? { 'onLocate-node': callbacks.onLocateNode }
@@ -72,16 +70,6 @@ describe('SwapNodesCard', () => {
expect(container.querySelectorAll('.swap-row')).toHaveLength(1)
})
it('passes showNodeIdBadge to children', () => {
const { container } = mountCard({
swapNodeGroups: makeGroups(1),
showNodeIdBadge: true
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.swap-row')
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
})
it('passes group prop to children', () => {
const groups = makeGroups(1)
const { container } = mountCard({ swapNodeGroups: groups })

View File

@@ -4,7 +4,6 @@
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
@replace="emit('replace', $event)"
/>
@@ -15,9 +14,8 @@
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
const { swapNodeGroups } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{

View File

@@ -53,7 +53,8 @@ describe('useReleaseService', () => {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined
signal: undefined,
headers: undefined
})
expect(result).toEqual(mockReleases)
@@ -76,7 +77,8 @@ describe('useReleaseService', () => {
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined
signal: undefined,
headers: undefined
})
expect(result).toEqual(mockReleases)
@@ -86,11 +88,30 @@ describe('useReleaseService', () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases({ project: 'comfyui' }, abortController.signal)
await service.getReleases(
{ project: 'comfyui' },
{ signal: abortController.signal }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal
signal: abortController.signal,
headers: undefined
})
})
it('should send Comfy-Env header when deployEnvironment is provided', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ deployEnvironment: 'local-desktop' }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: undefined,
headers: { 'Comfy-Env': 'local-desktop' }
})
})

View File

@@ -98,8 +98,9 @@ export const useReleaseService = () => {
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
signal?: AbortSignal
options: { signal?: AbortSignal; deployEnvironment?: string } = {}
): Promise<ReleaseNote[] | null> => {
const { signal, deployEnvironment } = options
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
@@ -110,7 +111,10 @@ export const useReleaseService = () => {
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal
signal,
headers: deployEnvironment
? { 'Comfy-Env': deployEnvironment }
: undefined
}),
errorContext,
routeSpecificErrors

View File

@@ -228,12 +228,15 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
})
@@ -300,12 +303,15 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(store.releases).toEqual([mockRelease])
})
@@ -318,12 +324,30 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
it('should pass deploy_environment from system stats', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.deploy_environment = 'local-desktop'
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
expect.anything(),
{ deployEnvironment: 'local-desktop' }
)
})
it('should skip fetching when --disable-api-nodes is present', async () => {

View File

@@ -266,12 +266,18 @@ export const useReleaseStore = defineStore('release', () => {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases({
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
const fetchedReleases = await releaseService.getReleases(
{
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
},
{
deployEnvironment:
systemStatsStore.systemStats?.system?.deploy_environment
}
)
if (fetchedReleases !== null) {
releases.value = fetchedReleases

View File

@@ -0,0 +1,94 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import WorkspaceSwitcherPopover from './WorkspaceSwitcherPopover.vue'
vi.mock('@/platform/workspace/composables/useWorkspaceSwitch', () => ({
useWorkspaceSwitch: () => ({ switchWorkspace: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ subscription: ref(null) })
}))
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspaceSwitcher: {
personal: 'Personal',
roleOwner: 'Owner',
roleMember: 'Member',
createWorkspace: 'Create new workspace',
maxWorkspacesReached:
'You can only own 10 workspaces. Delete one to create a new one.'
}
}
}
})
function createWorkspaceState(overrides: Record<string, unknown>) {
return {
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: [],
...overrides
}
}
function renderComponent() {
return render(WorkspaceSwitcherPopover, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
activeWorkspaceId: 'ws-personal',
isFetchingWorkspaces: false,
workspaces: [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}),
createWorkspaceState({
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
role: 'member'
})
]
}
}
}),
i18n
],
stubs: {
WorkspaceProfilePic: true
}
}
})
}
describe('WorkspaceSwitcherPopover', () => {
it('exposes the full team workspace name as a tooltip on the row', () => {
renderComponent()
const name = screen.getByText(LONG_WORKSPACE_NAME)
expect(name).toHaveAttribute('title', LONG_WORKSPACE_NAME)
})
})

View File

@@ -34,21 +34,20 @@
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
class="size-8 shrink-0 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<div class="flex items-center gap-1.5">
<span class="text-sm text-base-foreground">
{{
workspace.type === 'personal'
? $t('workspaceSwitcher.personal')
: workspace.name
}}
<div class="flex max-w-full items-center gap-1.5">
<span
:title="getDisplayName(workspace)"
class="truncate text-sm text-base-foreground"
>
{{ getDisplayName(workspace) }}
</span>
<span
v-if="resolveTierLabel(workspace)"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ resolveTierLabel(workspace) }}
</span>
@@ -59,7 +58,7 @@
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
class="pi pi-check shrink-0 text-sm text-base-foreground"
/>
</button>
</div>
@@ -171,6 +170,12 @@ function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getDisplayName(workspace: AvailableWorkspace): string {
return workspace.type === 'personal'
? t('workspaceSwitcher.personal')
: workspace.name
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')

View File

@@ -33,6 +33,10 @@ const WorkspaceTokenResponseSchema = z.object({
permissions: z.array(z.string())
})
export type WorkspaceTokenResponse = z.infer<
typeof WorkspaceTokenResponseSchema
>
export class WorkspaceAuthError extends Error {
constructor(
message: string,

View File

@@ -51,6 +51,9 @@ const AudioPreviewPlayer = defineAsyncComponent(
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
const Load3DAdvanced = defineAsyncComponent(
() => import('@/components/load3d/Load3DAdvanced.vue')
)
const WidgetImageCrop = defineAsyncComponent(
() => import('@/components/imagecrop/WidgetImageCrop.vue')
)
@@ -169,6 +172,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'load3DAdvanced',
{
component: Load3DAdvanced,
aliases: ['LOAD_3D_ADVANCED'],
essential: false
}
],
[
'imagecrop',
{
@@ -243,6 +254,7 @@ const EXPANDING_TYPES = [
'textarea',
'markdown',
'load3D',
'load3DAdvanced',
'curve',
'painter',
'imagecompare',

View File

@@ -252,6 +252,7 @@ const zSystemStats = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),

View File

@@ -108,6 +108,11 @@ interface QueuePromptRequestBody {
* ```
*/
api_key_comfy_org?: string
/**
* Identifies the client submitting the prompt. Forwarded by the backend
* to API nodes' upstream requests via the Comfy-Usage-Source header.
*/
comfy_usage_source?: string
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting.
@@ -867,6 +872,7 @@ export class ComfyApi extends EventTarget {
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
comfy_usage_source: 'comfyui-frontend',
extra_pnginfo: { workflow },
...(options?.previewMethod &&
options.previewMethod !== 'default' && {