Compare commits

..

68 Commits

Author SHA1 Message Date
GitHub Action
9410279bd2 [automated] Apply ESLint and Oxfmt fixes 2026-07-17 21:18:43 +00:00
bymyself
c4d6b57d7c test(assets): address review nits on assets-sidebar e2e specs
- Fix false docstring guarantee on setServerFlags: replace the "cannot
  clobber" promise with an accurate note that reconnects will drop
  injected flags; point readers to mockServerFeatures() for durable
  overrides.
- Use MODEL_TYPE_CHECKPOINT_NESTED.loader_path instead of hardcoded
  'SDXL/sd_xl_base_1.0.safetensors' so the assertion tracks the fixture.
- Add setting-value assertions to the cloud routing test so it pins the
  defaults, not just the downstream routing result.
- Swap vacuous toBeHidden() calls on v-if elements to toHaveCount(0).
- Drop dead export from CoreModelAsset (no spec imports it).
- Add inline comments explaining the intentional .gguf extension and the
  distinct coverage path of the near-duplicate fast_complete search test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 21:14:46 +00:00
Simon Pinfold
b017b26499 Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 23:03:10 +12:00
Simon Pinfold
84491c3c60 Merge branch 'synap5e/disable-change-model-tag-on-core' into synap5e/assets-mode-sidebar 2026-07-12 23:03:10 +12:00
Simon Pinfold
e8f5617da7 Merge branch 'synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag' into synap5e/disable-change-model-tag-on-core 2026-07-12 23:03:10 +12:00
Simon Pinfold
65f80ecee7 Merge branch 'synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags' into synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag 2026-07-12 23:03:10 +12:00
Simon Pinfold
f6bbaf4b9e docs(assets): correct the multi-root limitation to preview-only reads
/view_metadata takes no path index and searches roots in order, so
metadata only misresolves on relative-path collisions; the root-0
targeting applies to preview URLs and row identity.
2026-07-12 23:02:39 +12:00
Simon Pinfold
1e7d48623a fix(assets): represent every category of a shared multi-type asset
A shared-root asset tagged with several model_type values groups under
each of them, but its badge and a node-typed picker's title keyed off
only the first value, so viewing it under its second category showed the
first category's label. Cards now badge every authoritative model_type
value, and a node-typed picker titles off the requested node's category
instead of guessing from the first asset.
2026-07-12 23:01:25 +12:00
Simon Pinfold
b14353d340 Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 22:53:01 +12:00
Simon Pinfold
c8afd4cf8a docs(assets): mark the multi-root pathIndex limitation at its source
Asset records carry no root identity, so the asset-backed sidebar
reports every model as root 0; secondary-root metadata/preview reads
misresolve on multi-root categories. Accepted for this surface, loader
values are unaffected; lifting it needs the backend to carry root
identity on assets.
2026-07-12 22:52:02 +12:00
Simon Pinfold
a8c5476d3f Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 21:43:15 +12:00
Simon Pinfold
abfb89b990 Merge branch 'synap5e/disable-change-model-tag-on-core' into synap5e/assets-mode-sidebar 2026-07-12 21:43:14 +12:00
Simon Pinfold
259fd9a62f Merge branch 'synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag' into synap5e/disable-change-model-tag-on-core 2026-07-12 21:43:13 +12:00
Simon Pinfold
724e31d235 Merge branch 'synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags' into synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag 2026-07-12 21:43:13 +12:00
Simon Pinfold
9a7793ccc8 fix(models): close reload races that strand or misorder folder contents
A scan-complete reload landing while the eager load is still in flight
replaced the Loading folder objects but only re-loaded Loaded ones,
stranding the sidebar on uninitialized folders whose original requests
finish into detached objects; Loading folders now count as previously
loaded. Same-folder concurrent refreshes were last-completion-wins, so
an older slow response could overwrite a newer commit; refreshes now
carry a per-folder generation and stale ones discard.
2026-07-12 21:40:05 +12:00
Simon Pinfold
317cc81196 fix(assets): close pagination-termination and title-preservation gaps
Adversarial review follow-ups: an offset-ignoring backend that reorders
its responses could alternate two page orderings and defeat the
repeated-page check forever, so the walk now also remembers every served
page signature and treats a run of no-new-ID full pages as exhausted.
Modal titles for uncovered assets keep their legacy verbatim tag (only
covered assets retitle to their model_type value), restoring the
pre-alignment behavior the batch accidentally changed, and namespace
residue like a malformed empty model_type: can no longer leak into
titles, nav items, or categories.
2026-07-12 21:34:25 +12:00
Simon Pinfold
3a73d41d73 Merge branch 'synap5e/assets-mode-sidebar' into synap5e/test/assets-sidebar-e2e 2026-07-12 21:06:03 +12:00
Simon Pinfold
64740d9d4a Merge branch 'synap5e/disable-change-model-tag-on-core' into synap5e/assets-mode-sidebar 2026-07-12 21:05:25 +12:00
Simon Pinfold
9788ed2439 Merge branch 'synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag' into synap5e/disable-change-model-tag-on-core 2026-07-12 21:05:00 +12:00
Simon Pinfold
7b841548ce refactor(assets): require modelTypeMode on the write-path helpers
Matches the read-path helpers: new call sites must choose the tag mode
explicitly instead of silently inheriting legacy behavior.
2026-07-12 21:04:01 +12:00
Simon Pinfold
fcb7d838ef Merge branch 'synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags' into synap5e/fe-1084-fe-write-model-type-from-discovery-flip-the-cutover-flag
# Conflicts:
#	src/platform/assets/utils/assetMetadataUtils.test.ts
#	src/platform/assets/utils/assetMetadataUtils.ts
2026-07-12 21:01:01 +12:00
Simon Pinfold
873a85e59e fix(assets): surface refresh errors and harden pagination races
A refresh that fails before its first batch commits now mirrors the
error onto the committed state consumers actually read, instead of
dying silently on the discarded pending state. Pagination stops only
when a full page repeats the previous page verbatim (ignored-offset
signature); an all-duplicate page whose content merely shifted keeps
walking so later pages still load. Teardown of a stale request can no
longer evict a newer request's single-flight entries after an
interleaved invalidation.
2026-07-12 20:57:50 +12:00
Simon Pinfold
ca33752569 fix(assets): align badge and loader resolution with model_type tags
In modelTypeMode the card badge and modal title now key off the same
model_type: value the asset groups under, so they can no longer diverge
from the grouping (review consensus on FE-1076). Loader-node resolution
demotes bare tags unrelated to any model_type: value to trailing
fallbacks: they can no longer pre-empt an authoritative resolvable
model_type: however deep they are, while related hierarchical twins
(LLM/Qwen-VL over flat model_type:LLM) keep winning.

Also: getAssetModelType can no longer return a raw model_type: literal
(kills the prefixed round-trip through edit widgets), modelTypeMode is
now a required parameter so new call sites must choose explicitly,
malformed empty model_type: tags produce no badge, per-asset flag reads
hoisted, and a test pins the local reserved-tag mirrors to
assetService's canonical values.
2026-07-12 20:57:13 +12:00
Simon Pinfold
033ef6069c test(assets): tighten seed-payload and ghost-arming assertions
The seed test now asserts the request body ({ roots: ['models'] }), not
just the endpoint. The placement test anchors its zero-node check on the
visible ghost preview, so it verifies arming completed without placing
rather than racing a first-poll pass.
2026-07-10 23:37:58 +12:00
Simon Pinfold
1b44590a87 test(assets): harden e2e suite against review findings
The active-search scan test now proves the eager load and debounced
search pipeline settled (via a hit on existing data) before the mock
changes, so it can no longer false-pass off a still-pending load. The
bare-tag test forces supports_model_type_tags off instead of inheriting
whatever the test backend advertises, which would have broken once CI's
backend gained the capability. The seed mock response is typed as
SeedAssetsResponse per the typed-mock rule.
2026-07-10 22:45:26 +12:00
Simon Pinfold
561b519944 test(assets): e2e coverage for the assets-mode model library sidebar
Covers the integration seams the unit suites mock away: settings-driven
tab routing (asset browser modal vs sidebar tree, including the cloud
defaults), the eager-loaded asset-mode tree rendered from a mocked walk
with registration-order folders and extension display filtering, refresh
seeding a backend rescan with live tree/search updates on the
assets.seed.fast_complete websocket event, and ghost placement filling
the loader widget with the category-relative loader_path.

All assets endpoints are mocked so the suite does not depend on the CI
container backend having the assets API. AssetHelper's route pattern is
narrowed to **/api/assets** so navigating after mock() no longer
intercepts the built frontend's /assets/*.js chunks.
2026-07-10 18:29:51 +12:00
Simon Pinfold
954f935d33 test: unbreak typecheck:browser with main's branded LinkId fix
Mirrors 9e5fb67b76 verbatim so the stack rebase merges cleanly.
2026-07-10 18:29:20 +12:00
Simon Pinfold
e6916bb665 chore: correct an overclaiming comment on the model-library routing 2026-07-10 16:34:25 +12:00
Simon Pinfold
fdc4651934 fix(assets): close eager-load and folder-refresh races in the model store
Second-pass review: loadModels now loads the folder structure first when it
has not arrived yet (eager-loading could race app boot and silently load
nothing), and refreshModelFolder no longer commits a stale folder object
over a structure rebuilt by a concurrent reload. The store also adopts the
assetService-owned scan subscription (with scope-disposal cleanup) and
requestModelScan handles its own failure via async try/catch.
2026-07-10 16:33:29 +12:00
Simon Pinfold
62123c4c0d refactor(assets): own the scan-complete subscription in assetService
Second-pass review: the wire-level assets.seed.fast_complete event is now
owned by assetService, which exposes an onModelsScanned callback returning
an unsubscribe, and the bucket memoizer uses async/try-finally over a
then/finally chain.
2026-07-10 16:32:22 +12:00
Simon Pinfold
1cb04bef92 chore(assets): drop the scan-discovery console log
Review feedback: the sidebar visibly updating is signal enough, and this
removes the only no-console suppression in src.
2026-07-10 14:45:47 +12:00
Simon Pinfold
f5e221b955 chore(assets): drop a comment restating behavior pinned by a test 2026-07-10 14:42:56 +12:00
Simon Pinfold
fd1f2726a5 fix(settings): open the asset browser only when the asset API is enabled
Review feedback: rather than prompt-correcting the invalid combination, the
browser routing now derives from UseAssetBrowser AND UseAssetAPI — the asset
browser cannot function without the asset API, so with the API disabled the
model library tab always opens the sidebar tree.
2026-07-10 14:42:04 +12:00
Simon Pinfold
8d51d933fc refactor(assets): resolve model preview urls in the model store
Review feedback: url construction is domain logic that belongs below the
component layer; ModelTreeLeaf now consumes a pure getModelPreviewUrl.
2026-07-10 14:41:47 +12:00
Simon Pinfold
be564b232b Merge remote-tracking branch 'origin/main' into synap5e/fe-1076-fe-group-the-model-library-by-model_type-tags
# Conflicts:
#	src/composables/useFeatureFlags.ts
2026-07-10 14:07:58 +12:00
Matt Miller
2ef341dcd8 test: E2E for BYOK secret add / list / delete flow (#13510)
## ELI-5

The settings screen now has a "Secrets" panel where you can save API
keys for
model/AI providers. This adds an end-to-end test that plays out the
whole story
like a real user: open the panel, add a key, watch it show up in the
list, then
delete it. It also checks the security promise — the key you type is
sent to the
server but is **never** shown back to you afterward — and that an
account without
access to the gated providers never even sees them in the dropdown.

## What

Adds `browser_tests/tests/cloudSecrets.spec.ts`, a Playwright spec
covering the
secrets (API keys) surface in the cloud app:

- **Entitled account, full CRUD round-trip:** empty state -> add a
provider key
(pick provider, name, secret value, save) -> the key appears in the list
->
  delete it via the confirm dialog -> back to empty state.
- **Secret value is write-only:** asserts the create request carried the
plaintext
value, but the value is never echoed back into the DOM (the
list-response schema
  is metadata-only).
- **Entitlement gate:** an account whose provider allowlist is empty
never sees
  the gated providers anywhere in the add dialog.

Follows the existing cloud E2E conventions: drives a raw `page` and
reuses the
`mockCloudBoot` / `bootCloud` helpers so the app boots signed-in against
fully
mocked endpoints. A small stateful in-memory handler backs the secrets
endpoints
(list / create / delete + the provider allowlist) so the flow is
deterministic
and never touches a real backend.

## Why

Verification capstone for the secrets settings surface — proves the add
/ list /
delete flow works against the documented API behavior
(`GET`/`POST`/`DELETE` on
the secrets collection, `GET` on the provider allowlist) and locks in
the two
contracts that matter: the secret value is never returned after
creation, and the
provider allowlist is the only thing that surfaces gated providers to
the user.

## Tests

- `browser_tests/tests/cloudSecrets.spec.ts` — new, two cases (tagged
`@cloud`).
- Static checks pass locally: oxlint (0 warnings/errors) and oxfmt
formatting.
- The browser run itself needs a served app + the E2E harness (CI), so
it was not
executed in this environment; the spec is self-contained and mocks all
network.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:20:03 +00:00
Mobeen Abdullah
1815c7f7a4 feat(website): add JSON-LD structured data across the site (#13480)
## Summary

This PR adds schema.org **JSON-LD structured data across the whole
marketing site**, built from one shared, CMS-ready module and gated by a
small CI validator. It replaces the old global block (which had a stale
logo, wrong social links, disconnected nodes, and a head slot that
rendered three times) with a single connected `@graph` on every page.
Structured data only — there is no visual or runtime change for users.
Tracks Linear **FE-1170**.

The design goal was that structured data should be impossible to get
subtly wrong: one place builds it, honesty rules are enforced in code,
and a build-time validator fails the build if any page ships a broken
`@id` graph or a fabricated price/rating.

## Changes

- **One builder, one sink.** `utils/jsonLd.ts` holds pure, node-testable
builders; `components/common/JsonLdGraph.astro` is the single escaped
`<script type="application/ld+json">` sink (prevents `</script>`
breakout XSS).
- **The layout owns the page entity.** `BaseLayout` emits a baseline
`Organization` + `WebSite` + `WebPage` graph on every page from its own
`title`/`description`/canonical props. Enriched pages pass only what is
specific to them: `pageType`, `breadcrumbs`, `mainEntityId`, and
`extraJsonLd` nodes. This makes it impossible for a page's meta tags and
its structured data to drift apart.
- **Corrected site-wide entity.** Raster PNG logo (Google does not index
SVG logos), real `sameAs` handles sourced from the footer links,
`@id`-linked `Organization`/`WebSite`, and the triple-rendered head slot
fixed.
- **Honesty is enforced, not just intended.** No fabricated
`Review`/`AggregateRating`/`Offer`. Pricing offers are parsed only from
plain `$N` copy (a future "Contact us" drops the offer instead of
shipping a garbage price). Third-party node packs and listed models do
**not** claim Comfy Org as author/publisher. `noindex` pages (404,
payment) emit no structured data.
- **CI validator.** `pnpm --filter @comfyorg/website validate:jsonld`
runs over `dist/` in the website build workflow and fails on invalid
JSON, an unresolved `@id`, a fake rating, or an empty/non-numeric offer
price.
- **Breaking:** none.

## Coverage (also a manual QA checklist for the preview)

Every public page carries at least `Organization` + `WebSite` +
`WebPage`. The pages below add a page-specific primary entity, in
**English and zh-CN**:

| Page | Path (example) | Adds to the graph |
|---|---|---|
| Home | `/`, `/zh-CN` | `SoftwareApplication` (ComfyUI, free) +
`SoftwareSourceCode` |
| Download | `/download` | `SoftwareApplication` (ComfyUI desktop) |
| Pricing | `/cloud/pricing` | `Product` + 3 monthly `Offer`s
($20/$35/$100) + Breadcrumb |
| Models catalog | `/p/supported-models` | `CollectionPage` + `ItemList`
(313, lean) + Breadcrumb |
| Model detail | `/p/supported-models/4x-ultrasharp` |
`SoftwareApplication` + `FAQPage` + Breadcrumb |
| Nodes catalog | `/cloud/supported-nodes` | `CollectionPage` +
`ItemList` (58 packs) + Breadcrumb |
| Node-pack detail | `/cloud/supported-nodes/ComfyQR` |
`SoftwareApplication` (+ free `Offer`) + Breadcrumb |
| Demos | `/demos/community-workflows` | `LearningResource` + Breadcrumb
|
| About / Contact | `/about`, `/contact` | `AboutPage` / `ContactPage`
(Org as `mainEntity`) + Breadcrumb |
| Careers | `/careers` | `CollectionPage` + `ItemList` of open roles +
Breadcrumb |
| Affiliates | `/affiliates` | `FAQPage` + Breadcrumb |

Pages deliberately left at the baseline `WebPage` (generic landings,
legal, coming-soon) and pages with **no** structured data (`noindex`:
`/404`, `/payment/*`; redirect URLs) are intentional.

## Review Focus

- **Layout-owns-WebPage design.** `BaseLayout` builds the `WebPage`;
pages contribute only extra nodes. This is the main structural decision
and is what removes meta-vs-schema drift by construction.
- **Honesty guardrails.** Worth confirming: pricing offers, third-party
author omission on packs/models, and that `noindex` pages emit nothing.
- **`@id` and URL consistency.** All cross-page links and `@id`s resolve
to the canonical trailing-slash form; zh-CN breadcrumbs are rooted under
`/zh-CN`; the singleton `WebSite`/`#software` entities carry one
consistent definition across pages and locales.
- **The validator.** It is a bespoke ~100-line script scoped to the
website build job (not the prod deploy). Happy to make it non-blocking
or drop it if the team prefers.
- **Coordination with #13468.** Both branches add
`components/common/JsonLdGraph.astro`. Customer pages are intentionally
excluded here; #13468 can converge onto this shared builder.

## Verification

`astro check` 0 errors · 166 unit tests · `knip` 0 · `eslint` 0 · build
497 pages · validator passes across 500 pages · JSON-LD e2e specs 24/24.
(The 3 pre-existing demo e2e timeouts are an external Arcade-embed flake
on one slug, reproduced identically on `main`.)

## Screenshots

Not applicable — head-only structured data, no visual change. Validate
on the Vercel preview with the Rich Results Test and the schema.org
validator. Note: `@id`/URL values render as `comfy.org` (from
`astro.config` `site`) even on the preview host, which is correct.
2026-07-09 21:03:43 +00:00
imick-io
287b9eb980 feat(website): add HeroBackdrop01 block component (#13549)
## Summary

Adds the `HeroBackdrop01` hero block component to the website app. This
lands the component on its own so it can be merged to `main` ahead of
the EDU page work (on another branch) that consumes it.

The component renders a responsive hero with an optional image/video
backdrop (in-flow rounded card on mobile, full-bleed background on
desktop), an optional product badge, title, subtitle, and footnote. It
respects `prefers-reduced-motion` by not autoplaying the looping
backdrop video (WCAG 2.2.2).

## Notes

- No consumer imports the component yet — it is intentionally added
ahead of the page that will use it. The `knip` unused-file check flags
this, which is expected for this staging PR.
- Depends on existing `useReducedMotion` composable and
`ProductHeroBadge` component, both already present on `main`.

## Test plan

- [ ] `pnpm typecheck:website` passes (verified locally via pre-commit
hook)
- [ ] Component renders correctly once wired into a page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:43:49 +00:00
Matt Miller
06b0471257 test: pin unknown-provider flow-through in server-driven provider options (#13546)
## ELI-5

The provider picker for BYOK secrets is now driven by the server's
`availableProviders` list, not a hardcoded frontend list. The point is
that a provider the server offers but the frontend has never heard of
should still show up — using its raw id as the label and no logo. This
adds the one test that actually pins that behavior, so nobody can later
re-add a "only show providers the frontend knows about" filter without a
test going red.

## Summary

Regression test for the server-driven provider options introduced in
#13509: asserts that an unknown provider id passes through
`providerOptions` rather than being filtered against the local
presentational registry.

## Changes

- **What**: Adds one `useSecretForm.test.ts` case asserting that a
create-mode `availableProviders` list containing an id absent from the
local `SECRET_PROVIDERS` registry (`'brand-new-provider'`) yields a
single `providerOptions` entry rendered with the raw id as its label and
`logo: undefined`.

## Review Focus

The existing suite already covers the registry fallback
(`providers.test.ts`) and server-listed *known* ids (`runway`/`gemini`),
but every one of those cases uses an id that exists in the local
registry — so a future change that re-filtered `availableProviders`
against `SECRET_PROVIDERS` could pass all current tests while silently
dropping unknown providers. This test closes that gap by using an id
that is deliberately absent from the registry, so it fails if
pass-through is ever broken. Follow-up to the optional review ask on
#13509. Test-only; no production code changes.
2026-07-09 19:20:56 +00:00
Maanil Verma
8120142f49 feat(cloud): redesign the cloud onboarding survey (#13518)
## Summary

New users get a cleaner, faster onboarding survey: one question per
screen on tappable cards that advance the moment you pick an answer,
with follow-up questions that appear only when they're relevant. The
questions themselves were reworked to learn what people actually want to
do with ComfyUI.

## Changes

**What**

- Replaced the radio/checkbox list with a card-based,
one-question-at-a-time wizard. Choosing a single-select answer advances
automatically — no separate Next click — while multi-select and
free-text steps still wait for you to confirm.
- Reworked the question set: what you want to make, how well you know
ComfyUI, and how you found us. Two questions are now conditional — a
"what are you building?" follow-up appears only for workflow/API
builders, and a "which platform?" follow-up appears only when you say
you found us on social media.
- Added an "other" free-text escape hatch to the intent and source
questions, required before you can move on so we don't capture an empty
"other".
- Polished the whole surface to the comfy-canvas theme with animated
step-height and cross-fade transitions between questions, and hid the
marketing hero on the survey and user-check routes so the form has room.
- Errors now surface only after you've interacted with a field, not on
first paint.
- Extended the telemetry survey-response and remote-config option shapes
to carry the new fields (including per-option icons), leaving the older
fields in place so historical responses still typecheck.

**Breaking** — None. The remote-config survey schema stays
backend-overridable, hidden branch answers are zeroed in the submitted
payload, and the telemetry field names line up 1:1 with the schema. The
backend dynamic config already ships the matching version-3 schema.


## Testing

Behavioral coverage over the wizard's real interactions rather than DOM
structure, since the risk is in navigation/branching/validation, not
markup. `vue-i18n` is mounted for real with the actual locale file so
tests assert on rendered copy.

-
[DynamicSurveyForm.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts)
— auto-advance on single-select, no-advance on multi/other, Back
navigation, branch reveal/hide and its submitted payload,
required-"other" gating, post-interaction error surfacing, and
survey-prop reset.
-
[DynamicSurveyField.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyField.test.ts)
— card rendering/selection state, stable option ids, multi-select emit,
the conditional "other" input, and label resolution via key / locale map
/ id fallback.
-
[surveySchema.test.ts](src/platform/cloud/onboarding/survey/surveySchema.test.ts)
— default-schema branching (which steps show), hidden-field zeroing and
free-text-over-sentinel in the payload, and the shared
`hasNonEmptyValue` truth table.

Gates: `vue-tsc` typecheck clean; eslint/oxfmt clean on touched files;
survey suite green (68 tests across the three files). Manual: run the
cloud onboarding flow, pick a workflow/apps intent to confirm the
"building" follow-up, pick social to confirm the platform follow-up, and
verify an empty "other" blocks advancing.

## Screen Recording



https://github.com/user-attachments/assets/1908ca18-93d1-41a2-a55b-1f04a6df2268
2026-07-09 18:31:52 +00:00
AustinMroz
3164e6ab61 On workflow swap, restore 'Preview as Text' text (#13536)
Also adds proper typing for `onNodeOutputsUpdated`

See also: #12877 and #13427, which include near equivalent changes for
the bug itself, but different tests. If I had more time and had not
already made my own fix, I would have liked to spend more time getting
either of them cleaned up.
2026-07-09 17:03:19 +00:00
pythongosssss
731512c655 test: remove timeout causing flake (#13543)
## Summary

The timeout resolved 1 second after the test completed sometimes
throwing:
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
ReferenceError: window is not defined
 ❯ resolveMessageFormat node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1357:13
 ❯ translate node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1216:11
 ❯ node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:48
 ❯ wrapWithDeps node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:526:19
 ❯ t node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:16
 ❯ onNodePackChange src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue:201:10
    199|   // Add Latest option with actual version number
    200|   const latestLabel = latestVersionNumber
    201|     ? `${t('manager.latestVersion')} (${latestVersionNumber})`
       |          ^

    202|     : t('manager.latestVersion')
    203|

This error originated in "src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
```

The timeout was not required for the test to validate the loading text.

## Changes

- **What**: Remove timeout
2026-07-09 16:27:28 +00:00
imick-io
c0ad1e98c2 test(website): add e2e specs for the learning page (#13529)
## Summary

Add end-to-end test coverage for the `/learning` page, which previously
had none.

## Changes

- **What**: New `e2e/learning.spec.ts` covering the EN page smoke
behaviour (hero, featured workflow, tutorial grid rendered from the
`learningTutorials` data source, per-tutorial Try Workflow links, and
the contact-sales CTA), the tutorial video dialog open/close/Escape
interactions, and the zh-CN localized page.

## Review Focus

Assertions are driven off the `learningTutorials` data source and `t()`
i18n keys rather than hardcoded strings to avoid change-detector tests.
Media is stubbed by the auto-applied `blockExternalMedia` fixture, so
the specs have no network dependency. The tutorial-dialog interaction
uses a `toPass` retry to accommodate `client:visible` hydration.
2026-07-09 16:23:07 +00:00
Comfy Org PR Bot
bd9fab2d2f 1.48.0 (#13541)
Minor version increment to 1.48.0

**Base branch:** `main`

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-07-09 07:21:27 +00:00
Simon Pinfold
b62405a6ca fix(assets): group slashed bare tags by their top-level segment
Bare-tag backends can carry hierarchical tags (e.g. Chatterbox/sub/model);
the asset browser has always grouped those under the top-level segment, and
the sidebar's folder names come from /experiment/models, so bucketing under
the full slashed tag made such models unreachable. Match the browser's
grouping. On main's server-side per-folder query these assets were equally
invisible, so this is a fix over both.
2026-07-07 11:12:02 +12:00
Simon Pinfold
33c1806673 fix(assets): drop assets with traversal-shaped loader paths
The loader value flows into viewMetadata URLs and widget values; validate
it against the existing filename schema at bucket time so a '../' path
cannot pass through even if backend validation regresses.
2026-07-07 10:51:42 +12:00
Simon Pinfold
9342caad0a fix(assets): harden concurrent folder reloads
Guard loadModelFolders with a request id so a slow stale response cannot
overwrite a fresher folder structure when a manual refresh races the
scan-complete reload, and log instead of leaking an unhandled rejection
when the post-scan background reload fails.
2026-07-07 10:51:20 +12:00
Simon Pinfold
48aed9e0d9 feat(assets): filter model library folders by their registered extensions
/experiment/models returns each folder's raw registered allowlist; an
empty array means match-all. The asset walk filters folder contents by the
registered set verbatim, substituting an FE-owned copy of core's default
supported_pt_extensions when the allowlist is empty or the field is absent
(older backends). This reproduces the legacy sidebar's global-set display
behavior for match-all folders and extensions-less backends — nothing that
was hidden starts showing — while registered allowlists let exotic folders
(e.g. .gguf) surface their actual models. The legacy listing keeps its
historical server-side filter untouched.
2026-07-07 09:14:02 +12:00
Simon Pinfold
dd3dceeaca fix(assets): keep active search results live across model reloads
Search results were a snapshot filled inside handleSearch, so a reload
(e.g. a completed scan discovering new files) updated the store without the
filtered tree ever re-running. Filter via a computed over the store instead,
and expand results from a watch so late-arriving matches unfold too.
2026-07-07 09:14:02 +12:00
Simon Pinfold
3605fc1d75 feat(assets): eager-load the model library in asset mode
In asset mode every folder resolves from one cached walk, so load all
folders on sidebar mount: search and badge counts are complete from the
start and the load-all button becomes redundant (hidden; it remains for the
request-per-folder legacy path, where AutoLoadAll stays the opt-in). Log
when a scan's fast phase discovered new files, since the sidebar now
updates itself and the console note is the only trace of why.
2026-07-07 09:14:02 +12:00
Simon Pinfold
f97195e392 feat(assets): rescan model roots on refresh and live-update the sidebar
Manual refresh now kicks off POST /assets/seed (fire-and-forget, 409
tolerated, skipped on Cloud and on the legacy listing path) alongside the
immediate re-load of known server state. The scan's fast phase already
persists the category tags and loader filenames the sidebar needs, and the
backend announces it with the assets.seed.fast_complete websocket event, so
the store re-loads previously loaded folders on that event instead of
polling /assets/seed/status (which cannot distinguish the fast phase from
enrichment).
2026-07-07 09:14:02 +12:00
Simon Pinfold
0488d0d8a4 feat(assets): back the model library sidebar with the asset API
Derive folders from model_type: tags and use loader_path for the tree,
widget value, and viewMetadata. Gate local-disk metadata/preview reads
on !isCloud.

The models tag is walked once and bucketed per category (multi-category
assets land in each), memoized behind a request id so an in-flight walk
cannot repopulate an invalidated cache. Assets with a null loader_path
are unloadable (orphans) and are dropped with a warning rather than
minting a widget value from the deprecated name field; bare-tag backends
that predate loader_path keep the name fallback.
2026-07-07 09:14:02 +12:00
Simon Pinfold
aea28a06de feat(assets): gate model library asset view behind a runtime setting (FE-1142)
Add a hidden, experimental Comfy.Assets.ModelLibraryAssetView setting that
controls whether the model library opens the asset browser or the sidebar,
defaulting to the asset browser on Cloud and the sidebar on Core. The
model-library toggle now reads this setting instead of Comfy.Assets.UseAssetAPI,
decoupling the sidebar-vs-asset-browser view from Asset API enablement so the
asset-backed view stays reachable on Core for testing without forcing the
cutover.
2026-07-07 09:14:02 +12:00
Simon Pinfold
5847b3d148 fix(dialog): keep non-modal dialogs open when focus lands on a toast
Toasts teleport to body but were missing from the reka outside-layer
allowlist, so a non-modal dialog (e.g. Settings) dismissed itself when
focus recovery landed on a toast's close button. This happens whenever an
action button disables itself while its request is in flight and the
request fails: the browser drops focus from the disabled button and it can
resurface on the error toast. Latent on main; deterministic once the
sidebar rework shifted chunk timing enough for the loading state to render
before the failure response.
2026-07-07 09:14:02 +12:00
Simon Pinfold
187239712c fix(assets): make model type read-only on core
Retagging a model rewrites its asset tags, but core is filesystem-backed
and does not yet move the file to match the new type, so an edit would
leave the tag and the on-disk location diverged. Gate the model type
dropdown to cloud builds; on core the field renders the existing
read-only label. Revert once core implements move-on-retag (or the asset
system no longer requires the file move).
2026-07-02 14:08:09 +12:00
Simon Pinfold
e53b7b4ba8 fix(assets): recognize model_type: prefix in import type-mismatch guard
The required-type guard and imported-type label compared the bare folder_name
against raw asset tags, so a namespaced model_type:<folder> tag was treated as a
type mismatch (and leaked the raw prefix into the mismatch label). Strip the
model_type: prefix before comparing/labelling so a namespaced tag satisfies the
required type. Not reachable in production (the dual-write keeps the bare twin),
but hardens the import path against a namespaced-only asset.
2026-07-01 14:20:37 +12:00
Simon Pinfold
ab56cf9e82 test(assets): pin import write keeps user_metadata.model_type bare
Assert the import-by-URL path namespaces only the tag (model_type:<folder_name>)
while user_metadata.model_type stays the bare folder_name, since that value
still feeds download tracking, category refresh, and the progress label.
2026-07-01 14:20:37 +12:00
Simon Pinfold
f772fc0123 feat(assets): write model_type: tag from discovery
Prefix the discovery folder_name with model_type: on model import and on
model-type edits, gated on the supports_model_type_tags backend capability.
Default off writes the legacy bare directory-path tag unchanged, so this is a
no-op until the backend declares the cutover; the flag is the same authoritative
signal FE-1076 grouping reads.

The FE writes only the single selected folder_name's model_type: form; the
backend keeps the bare directory-path twin in sync (and forward-expands shared
paths), so the edit path manages just the model_type: tags and the resolver read
path is untouched. Mirrors the read side: an uncovered asset with no model_type:
tag still routes by its bare tag.
2026-07-01 14:20:37 +12:00
Simon Pinfold
a5f4559df8 fix(assets): halt model batch loop when backend ignores offset
The batch loop only stopped on a short page, so a backend that ignores
offset and repeats a full page would refetch forever. Now runs on non-cloud
builds too. Stop when a full page contributes no new IDs.
2026-07-01 14:07:54 +12:00
Simon Pinfold
6103dd164e fix(assets): prefer resolvable model_type over incidental slash tags
An incidental hierarchical tag (e.g. a user-added foo/bar) could outrank
an authoritative model_type:vae in node-category resolution, targeting the
wrong provider or failing with NO_PROVIDER. Resolution now tries category
candidates most-specific-first and takes the first that maps to a provider,
so the deepest resolvable path still wins but unresolvable tags fall through.
2026-07-01 14:07:37 +12:00
Simon Pinfold
f7d672e3a5 test(assets): restore mocks after each resolveModelNodeFromAsset test
Add afterEach(vi.restoreAllMocks) so the console.error spy doesn't persist
across tests (CodeRabbit nit).
2026-07-01 09:35:05 +12:00
Simon Pinfold
6f0eaefe1b fix(assets): keep reserved-tag constants local in assetMetadataUtils
Importing MODELS_TAG/MISSING_TAG from assetService made this leaf util pull
the assetService -> i18n (createI18n) chain. Test suites that import
assetMetadataUtils (e.g. useAssetFilterOptions, AssetsSidebarListView) with a
minimal vue-i18n mock then failed to load. Define the two reserved-tag
literals locally so the util has no heavy transitive dependency.
2026-07-01 09:35:05 +12:00
Simon Pinfold
55ceec0a16 fix(assets): port model_type node-category resolution to resolveModelNodeFromAsset
main (#12765) renamed createModelNodeFromAsset -> resolveModelNodeFromAsset.
Re-apply FE-1076's flag-gated node-category resolution there: use
getAssetNodeCategory(asset, flags.supportsModelTypeTags) instead of the inline
first-non-reserved-tag lookup, so a model_type:* tag resolves the loader when
the supports_model_type_tags flag is on.
2026-07-01 09:35:05 +12:00
Simon Pinfold
5ac1b15266 fix(assets): strip model_type: prefix from the asset browser title
Also realigns the card type badge to the same rule: keep the existing
"first non-models tag" selection and only strip the model_type: namespace
prefix, rather than reordering to prefer the model_type: value. So an asset
like [models, foo, model_type:bar] still shows foo, while
[models, model_type:checkpoints] shows checkpoints. Title (primaryCategoryTag)
strips the same prefix when the flag is on.
2026-07-01 09:35:05 +12:00
Simon Pinfold
df826415ca fix(assets): strip model_type: prefix from asset card type badge
The card type badge derived from the first raw non-models tag, so in
model_type mode it rendered the literal `model_type:checkpoints`. Route it
through a model_type-aware getAssetTypeBadge and key the display cache on the
flag so a late-arriving feature flag re-derives the badge. Also adds
docstrings to the tag helpers to satisfy coverage.
2026-07-01 09:35:05 +12:00
Simon Pinfold
ec25e874a2 refactor(assets): run model-asset store on non-cloud builds
Unwrap the if (isCloud) gate in assetsStore getModelState so the asset-mode
Model Library works on non-cloud distributions (it previously returned an
empty no-op store off-cloud, leaving the library blank even with the asset
API enabled). Scoped to the model-asset store only; the rest of the isCloud
forks are out of scope here.
2026-07-01 09:35:05 +12:00
Simon Pinfold
c8e4029f96 refactor(assets): gate model_type grouping on supports_model_type_tags
Drive the model_type: grouping/resolution off the backend-reported
supports_model_type_tags capability (getServerFeature) instead of sniffing
tags per asset. Default false preserves the legacy bare-tag grouping, so a
backend that does not declare the capability is unchanged. When the flag is
true, assets group by their model_type: values and an uncovered asset (no
model_type: tag) still routes by its bare tags.

The thread-through is a modelTypeMode flag on getAssetCategories /
getAssetNodeCategory / filterByCategory (keeping them pure); call sites read
the capability via useFeatureFlags.
2026-07-01 09:35:05 +12:00
Simon Pinfold
5ebeb580ca feat(assets): group model library by model_type tags
Group the asset-mode Model Library by namespaced model_type:* tags. When an
asset carries model_type:* tags, the stripped values are the authoritative
categories and all other tags are disregarded; otherwise fall back to the
legacy bare-tag top-level grouping. A shared getAssetCategories helper keeps
the "By type" nav and category filtering in agreement.

Resolve node-provider lookup from the same tags via getAssetNodeCategory:
prefer the most specific (deepest parent/child) candidate so a flat
model_type:LLM does not shadow a resolvable hierarchical tag, and strip the
model_type: prefix before the modelNodeMappings lookup.
2026-07-01 09:34:29 +12:00
129 changed files with 6925 additions and 2924 deletions

View File

@@ -40,3 +40,6 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -0,0 +1,158 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning — Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 — Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -17,7 +17,8 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -0,0 +1,129 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,12 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -0,0 +1,53 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -82,14 +82,19 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -2190,6 +2190,13 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -4061,7 +4068,6 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4157,10 +4163,6 @@ const translations = {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'

View File

@@ -14,8 +14,10 @@ import {
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -23,6 +25,10 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -31,15 +37,21 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
@@ -58,28 +70,21 @@ const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
---
<!doctype html>
@@ -121,10 +126,7 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
{structuredData && <JsonLdGraph graph={structuredData} />}
<slot name="head" />
<!-- Google Tag Manager -->
@@ -144,7 +146,6 @@ const websiteJsonLd = {
)}
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (

View File

@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="About Us — Comfy">
<BaseLayout
title="About Us — Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
}
---
<BaseLayout
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers — Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -2,9 +2,41 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="Pricing — Comfy Cloud">
<BaseLayout
title="Pricing — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,39 +4,44 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,7 +7,12 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,9 +2,25 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="Contact — Comfy">
<BaseLayout
title="Contact — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
@@ -88,25 +61,20 @@ const breadcrumbJsonLd = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop — Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: displayName,
url,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
})
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
},
{
'@type': 'Question',
@@ -97,54 +79,44 @@ const faqJsonLd = {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
},
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
},
},
],
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} — Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}

View File

@@ -2,10 +2,29 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -26,6 +45,13 @@ const dirLabel: Record<string, string> = {
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<BaseLayout
title="关于我们 — Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />

View File

@@ -2,9 +2,44 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="定价 — Comfy Cloud">
<BaseLayout
title="定价 — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
</BaseLayout>

View File

@@ -4,39 +4,47 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
const title = t('cloudNodes.meta.title', 'zh-CN')
const description = t('cloudNodes.meta.description', 'zh-CN')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,7 +7,12 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -2,9 +2,28 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import FormSection from '../../components/contact/FormSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="联系我们 — Comfy">
<BaseLayout
title="联系我们 — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection locale="zh-CN" client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
@@ -88,25 +61,23 @@ const breadcrumbJsonLd = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest'
import { externalLinks } from '../config/routes'
import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
itemListNode,
jsonLdId,
organizationId,
pageContext,
productNode,
softwareApplicationNode
} from './jsonLd'
const siteUrl = 'https://comfy.org'
const site = new URL('https://comfy.org/')
function typeNames(graph: JsonLdGraph): string[] {
return graph['@graph'].map((node) => node['@type'])
}
describe('absoluteUrl', () => {
it('resolves internal paths to their trailing-slash canonical form', () => {
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
})
})
describe('pageContext', () => {
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
expect(pageContext(site, '/about/', undefined)).toEqual({
siteUrl,
locale: 'en',
url: 'https://comfy.org/about/'
})
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
})
})
describe('itemListNode', () => {
it('counts items and omits per-item names when not supplied', () => {
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
{ url: 'https://jobs.example/1' },
{ url: 'https://jobs.example/2', name: 'Designer' }
])
expect(node.numberOfItems).toBe(2)
const items = node.itemListElement as Record<string, unknown>[]
expect('name' in items[0]).toBe(false)
expect(items[1].name).toBe('Designer')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
siteUrl,
id: jsonLdId(siteUrl, 'software'),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
isFree: true
})
const orgRef = { '@id': organizationId(siteUrl) }
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
expect(node.offers).toEqual({
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: orgRef
})
})
it('does not name Comfy Org as seller on a third-party free offer', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
isFree: true
})
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
})
it('credits a known third-party author without claiming to publish it', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
authorName: 'Jane Dev'
})
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
expect(node.publisher).toBeUndefined()
})
it('claims no author or publisher for third-party software with no author', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.author).toBeUndefined()
expect(node.publisher).toBeUndefined()
})
})
describe('sameAs encyclopedic references', () => {
it('links the organization to its Wikidata entity', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/`, name: 'Home' }
)
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
})
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.sameAs).toEqual([
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
])
})
it('omits sameAs for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.sameAs).toBeUndefined()
})
})
describe('productNode', () => {
it('gives every offer a currency and price', () => {
const node = productNode({
siteUrl,
id: 'https://comfy.org/cloud/pricing/#product',
name: 'Comfy Cloud',
url: 'https://comfy.org/cloud/pricing/',
offers: [{ name: 'Standard', price: '20' }]
})
const offers = node.offers as Record<string, unknown>[]
expect(offers[0].price).toBe('20')
expect(offers[0].priceCurrency).toBe('USD')
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{
url,
name: 'Pricing',
type: 'CollectionPage',
mainEntityId: jsonLdId(url, 'itemlist'),
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
},
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
)
it('always includes the site-wide organization, website and page entity', () => {
expect(typeNames(graph)).toContain('Organization')
expect(typeNames(graph)).toContain('WebSite')
expect(typeNames(graph)).toContain('CollectionPage')
})
it('produces a graph where every @id reference resolves', () => {
const { defined, references } = collectGraphIds(graph)
for (const reference of references) {
expect(defined.has(reference)).toBe(true)
}
})
})
describe('escapeJsonLd on a built graph', () => {
it('neutralizes a </script> breakout in a page name', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
)
const serialized = escapeJsonLd(graph)
expect(serialized).not.toContain('</script>')
expect(serialized).toContain('\\u003c')
})
})

View File

@@ -0,0 +1,377 @@
import { externalLinks } from '../config/routes'
import type { Locale } from '../i18n/translations'
export type JsonLdNode = Record<string, unknown> & { '@type': string }
export interface JsonLdGraph {
'@context': 'https://schema.org'
'@graph': JsonLdNode[]
}
export interface PageContext {
siteUrl: string
locale: Locale
}
export type WebPageType =
| 'WebPage'
| 'AboutPage'
| 'ContactPage'
| 'CollectionPage'
export interface Crumb {
name: string
url?: string
}
const sameAs = [
externalLinks.github,
externalLinks.x,
externalLinks.youtube,
externalLinks.discord,
externalLinks.instagram,
externalLinks.reddit,
externalLinks.linkedin,
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
externalLinks.wikidataComfyOrg
]
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
const comfyUiSameAs = [
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
]
function siteUrlFrom(site: URL | undefined): string {
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
}
export function absoluteUrl(site: URL | undefined, path: string): string {
const resolved = new URL(path, site ?? 'https://comfy.org').href
return resolved.endsWith('/') ? resolved : `${resolved}/`
}
export function pageContext(
site: URL | undefined,
pathname: string,
currentLocale: string | undefined
): PageContext & { url: string } {
return {
siteUrl: siteUrlFrom(site),
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
url: absoluteUrl(site, pathname)
}
}
export function jsonLdId(pageUrl: string, fragment: string): string {
return `${pageUrl}#${fragment}`
}
export function organizationId(siteUrl: string): string {
return `${siteUrl}/#organization`
}
function websiteId(siteUrl: string): string {
return `${siteUrl}/#website`
}
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
return {
'@context': 'https://schema.org',
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
}
}
function organizationNode(siteUrl: string): JsonLdNode {
return {
'@type': 'Organization',
'@id': organizationId(siteUrl),
name: 'Comfy Org',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/web-app-manifest-512x512.png`,
width: 512,
height: 512
},
sameAs
}
}
function websiteNode(siteUrl: string): JsonLdNode {
return {
'@type': 'WebSite',
'@id': websiteId(siteUrl),
name: 'Comfy',
url: siteUrl,
publisher: { '@id': organizationId(siteUrl) }
}
}
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
return {
'@type': 'BreadcrumbList',
'@id': jsonLdId(pageUrl, 'breadcrumb'),
itemListElement: crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return isLast || !crumb.url
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
: {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}
})
}
}
export function itemListNode(
pageUrl: string,
name: string,
items: { url: string; name?: string }[]
): JsonLdNode {
return {
'@type': 'ItemList',
'@id': jsonLdId(pageUrl, 'itemlist'),
name,
numberOfItems: items.length,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
url: item.url,
...(item.name ? { name: item.name } : {})
}))
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
url: string
name: string
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
return {
'@type': type,
'@id': jsonLdId(input.url, 'webpage'),
url: input.url,
name: input.name,
description: input.description,
isPartOf: { '@id': websiteId(input.siteUrl) },
primaryImageOfPage: input.imageUrl
? { '@type': 'ImageObject', url: input.imageUrl }
: undefined,
breadcrumb: hasCrumbs
? { '@id': jsonLdId(input.url, 'breadcrumb') }
: undefined,
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
inLanguage: input.locale
}
}
export interface SoftwareAppInput {
siteUrl: string
id: string
name: string
url: string
applicationCategory: string
firstParty?: boolean
applicationSubCategory?: string
description?: string
operatingSystem?: string
image?: string
softwareVersion?: string
license?: string
codeRepository?: string
authorName?: string
isFree?: boolean
sameAs?: string[]
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
const orgRef = { '@id': organizationId(input.siteUrl) }
const author = input.firstParty
? orgRef
: input.authorName
? { '@type': 'Person', name: input.authorName }
: undefined
return {
'@type': 'SoftwareApplication',
'@id': input.id,
name: input.name,
url: input.url,
applicationCategory: input.applicationCategory,
applicationSubCategory: input.applicationSubCategory,
description: input.description,
operatingSystem: input.operatingSystem,
image: input.image,
softwareVersion: input.softwareVersion,
license: input.license,
codeRepository: input.codeRepository,
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
offers: input.isFree
? {
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: input.firstParty ? orgRef : undefined
}
: undefined
}
}
interface SourceCodeInput {
siteUrl: string
id: string
name: string
codeRepository: string
programmingLanguage?: string
targetProductId?: string
}
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
return {
'@type': 'SoftwareSourceCode',
'@id': input.id,
name: input.name,
codeRepository: input.codeRepository,
programmingLanguage: input.programmingLanguage,
targetProduct: input.targetProductId
? { '@id': input.targetProductId }
: undefined,
author: { '@id': organizationId(input.siteUrl) }
}
}
export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
id: comfyUiSoftwareId(siteUrl),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',
targetProductId: comfyUiSoftwareId(siteUrl)
})
}
interface OfferInput {
name: string
price: string | number
url?: string
}
export interface ProductInput {
siteUrl: string
id: string
name: string
url: string
offers: OfferInput[]
}
export function productNode(input: ProductInput): JsonLdNode {
return {
'@type': 'Product',
'@id': input.id,
name: input.name,
url: input.url,
brand: { '@id': organizationId(input.siteUrl) },
offers: input.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: 'USD',
url: offer.url,
seller: { '@id': organizationId(input.siteUrl) },
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: offer.price,
priceCurrency: 'USD',
unitText: 'MONTH'
}
}))
}
}
export interface PageGraphInput {
url: string
name: string
type?: WebPageType
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
export function buildPageGraph(
ctx: PageContext,
page: PageGraphInput,
...extraNodes: (JsonLdNode | null | undefined)[]
): JsonLdGraph {
const { type = 'WebPage', ...rest } = page
const input: WebPageInput = {
...rest,
siteUrl: ctx.siteUrl,
locale: ctx.locale
}
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
return buildGraph(
organizationNode(ctx.siteUrl),
websiteNode(ctx.siteUrl),
webPageNode(input, type),
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
...extraNodes
)
}
export function collectGraphIds(value: unknown): {
defined: Set<string>
references: string[]
} {
const defined = new Set<string>()
const references: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>
const id = record['@id']
if (typeof id === 'string') {
if (Object.keys(record).length === 1) references.push(id)
else defined.add(id)
}
Object.values(record).forEach(walk)
}
}
walk(value)
return { defined, references }
}

View File

@@ -254,6 +254,18 @@ export class ModelLibrarySidebarTab extends SidebarTab {
.filter({ hasText: label })
.first()
}
/**
* A folder's own row (not the whole subtree). Required for nested folders:
* an ancestor `.p-tree-node`'s text contains its descendants' labels, so
* `getFolderByLabel` would match — and click — the ancestor instead.
*/
getFolderRowByLabel(label: string) {
return this.modelTree
.locator('.p-tree-node:not(.p-tree-node-leaf) > .p-tree-node-content')
.filter({ hasText: label })
.first()
}
}
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'

View File

@@ -1,4 +1,13 @@
import type { Asset } from '@comfyorg/ingest-types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Core-native asset shape: the ingest Asset plus the `loader_path` contract
* field that `supports_model_type_tags` backends emit (see
* `src/platform/assets/schemas/assetSchema.ts`).
*/
type CoreModelAsset = Asset & Pick<AssetItem, 'loader_path'>
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
@@ -89,6 +98,70 @@ export const STABLE_LORA: Asset = createModelAsset({
updated_at: '2025-02-20T14:00:00Z'
})
function createCoreModelAsset(
overrides: Partial<CoreModelAsset>
): CoreModelAsset {
return { ...createModelAsset(), ...overrides }
}
export const MODEL_TYPE_CHECKPOINT_NESTED: CoreModelAsset =
createCoreModelAsset({
id: 'mt-checkpoint-001',
name: 'sd_xl_base_1.0.safetensors',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'SDXL/sd_xl_base_1.0.safetensors',
created_at: '2025-01-15T10:30:00Z',
updated_at: '2025-01-15T10:30:00Z'
})
export const MODEL_TYPE_CHECKPOINT_ROOT: CoreModelAsset = createCoreModelAsset({
id: 'mt-checkpoint-002',
name: 'v1-5-pruned-emaonly.safetensors',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'v1-5-pruned-emaonly.safetensors',
created_at: '2025-01-20T08:00:00Z',
updated_at: '2025-01-20T08:00:00Z'
})
export const MODEL_TYPE_CHECKPOINT_GGUF: CoreModelAsset = createCoreModelAsset({
id: 'mt-checkpoint-003',
name: 'flux_quantized.gguf',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'flux_quantized.gguf',
created_at: '2025-02-01T09:00:00Z',
updated_at: '2025-02-01T09:00:00Z'
})
export const MODEL_TYPE_CHECKPOINT_SCANNED: CoreModelAsset =
createCoreModelAsset({
id: 'mt-checkpoint-004',
name: 'freshly_scanned.safetensors',
tags: ['models', 'model_type:checkpoints'],
loader_path: 'freshly_scanned.safetensors',
created_at: '2025-02-10T09:00:00Z',
updated_at: '2025-02-10T09:00:00Z'
})
export const MODEL_TYPE_LORA: CoreModelAsset = createCoreModelAsset({
id: 'mt-lora-001',
name: 'detail_enhancer_v1.2.safetensors',
tags: ['models', 'model_type:loras'],
loader_path: 'detail_enhancer_v1.2.safetensors',
created_at: '2025-02-20T14:00:00Z',
updated_at: '2025-02-20T14:00:00Z'
})
export const MODEL_TYPE_LORA_README: CoreModelAsset = createCoreModelAsset({
id: 'mt-lora-002',
name: 'README.txt',
mime_type: 'text/plain',
size: 2_048,
tags: ['models', 'model_type:loras'],
loader_path: 'README.txt',
created_at: '2025-02-20T14:00:00Z',
updated_at: '2025-02-20T14:00:00Z'
})
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
id: 'test-input-001',
name: 'reference_photo.png',

View File

@@ -3,6 +3,7 @@ import type { Page, Route } from '@playwright/test'
import type {
Asset,
ListAssetsResponse,
SeedAssetsResponse,
UpdateAssetData
} from '@comfyorg/ingest-types'
import {
@@ -35,6 +36,13 @@ function emptyConfig(): AssetConfig {
type AssetOperator = (config: AssetConfig) => AssetConfig
/**
* Scoped to the API path so the built frontend's own `/assets/*.js` chunks
* are never intercepted (a page navigated after `mock()` would otherwise
* fail to load).
*/
const ASSET_API_ROUTE_PATTERN = '**/api/assets**'
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
const merged = new Map(config.assets)
for (const asset of newAssets) {
@@ -141,6 +149,8 @@ export class AssetHelper {
return this.handleUpdateAsset(route, path, body)
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
return this.handleDeleteAsset(route, path)
if (method === 'POST' && path.endsWith('/assets/seed'))
return this.handleSeedScan(route)
if (method === 'POST' && /\/assets\/?$/.test(path))
return this.handleUploadAsset(route)
if (method === 'POST' && path.endsWith('/assets/download'))
@@ -149,7 +159,7 @@ export class AssetHelper {
return route.fallback()
}
const pattern = '**/assets**'
const pattern = ASSET_API_ROUTE_PATTERN
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
@@ -165,7 +175,7 @@ export class AssetHelper {
})
}
const pattern = '**/assets**'
const pattern = ASSET_API_ROUTE_PATTERN
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
@@ -276,6 +286,11 @@ export class AssetHelper {
return route.fulfill({ status: 201, json: response })
}
private handleSeedScan(route: Route) {
const response: SeedAssetsResponse = { status: 'started' }
return route.fulfill({ status: 200, json: response })
}
private handleDownloadAsset(route: Route) {
return route.fulfill({
status: 202,

View File

@@ -51,6 +51,27 @@ export class FeatureFlagHelper {
})
}
/**
* Force server feature flags (the WS `feature_flags` handshake payload) on
* the running app by merging into `api.serverFeatureFlags`. The `ff:`
* localStorage override is dev-only (tree-shaken from production builds),
* so this is the way to control `api.serverSupportsFeature()` in e2e.
*
* Note: this merges the injected flags at call time. A subsequent WS
* reconnect or a late `feature_flags` handshake will full-replace
* `serverFeatureFlags`, dropping the overrides. For flags that must survive
* reconnects, use `mockServerFeatures()` to intercept the /api/features
* route instead.
*/
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -167,10 +167,6 @@ export const TestIds = {
selectDefaultSearchInput: 'widget-select-default-search-input',
selectDefaultViewport: 'widget-select-default-viewport'
},
errorResolution: {
panel: 'error-resolution-panel',
back: 'error-resolution-back'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',

View File

@@ -0,0 +1,20 @@
import type { Page } from '@playwright/test'
/**
* Dispatches a wire-level custom event on the app's api singleton, simulating
* a websocket broadcast (e.g. `assets.seed.fast_complete`). Uses the raw
* EventTarget dispatch because `api.dispatchCustomEvent` is typed against the
* ApiEventTypes map, which deliberately excludes events consumed via
* `addCustomEventListener`.
*/
export async function dispatchApiCustomEvent(
page: Page,
type: string
): Promise<void> {
await page.evaluate((eventType) => {
EventTarget.prototype.dispatchEvent.call(
window.app!.api,
new CustomEvent(eventType)
)
}, type)
}

View File

@@ -61,10 +61,10 @@ test.describe(
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.back)
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})

View File

@@ -0,0 +1,279 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
* add a provider key, see it listed, delete it — the full CRUD round-trip —
* plus the entitlement contract that a non-entitled account never sees the
* gated providers.
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
* `/secrets` backend on top so the flow is deterministic and never touches a
* real server.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
const BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches.
*/
async function openSecretsPanel(page: Page) {
const settingsDialog = page.getByTestId('settings-dialog')
await page.evaluate(() => {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
})
await settingsDialog.waitFor({ state: 'visible' })
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingsDialog
.locator('nav')
.getByRole('button', { name: 'Secrets' })
.click()
await Promise.all([providersResolved, listResolved])
return settingsDialog
}
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// --- ADD -------------------------------------------------------------
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
// Pick the entitled Runway provider from the server-driven dropdown.
await formDialog.locator('#secret-provider').click()
await page.getByRole('option', { name: 'Runway' }).click()
await formDialog.locator('#secret-name').fill('My Runway Key')
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(formDialog).toBeHidden()
// --- LIST ------------------------------------------------------------
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
// The create request carried the plaintext value + provider...
expect(backend.createRequests).toHaveLength(1)
expect(backend.createRequests[0]).toMatchObject({
name: 'My Runway Key',
provider: 'runway',
secret_value: RUNWAY_KEY_VALUE
})
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// --- DELETE ----------------------------------------------------------
await settingsDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
const confirmDialog = page
.getByRole('dialog')
.filter({ hasText: 'Delete Secret' })
await confirmDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
expect(backend.store).toHaveLength(0)
})
test('a non-entitled account never sees the gated providers', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
await mockSecretsBackend(page, [])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated
// providers must not appear anywhere.
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
await formDialog.locator('#secret-provider').click()
// Anchor on the opened listbox so the absence assertions below can't pass
// vacuously against a dropdown that never opened.
const providerListbox = page.getByRole('listbox')
await expect(providerListbox).toBeVisible()
// An empty allowlist must yield an empty dropdown. Asserting zero options
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
// where `availableProviders` is null and the default providers would show.
await expect(providerListbox.getByRole('option')).toHaveCount(0)
})
})

View File

@@ -1,191 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
},
{
type: 'value_smaller_than_min',
message: 'Value -1 smaller than min of 0',
details: '',
extra_info: { input_name: 'quality' }
}
]
}
}
test.describe('Error resolution view', { tag: ['@ui', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
})
test('shows canvas with error panel and hides UI chrome', async ({
comfyPage
}) => {
await expect(comfyPage.canvas).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.back)
).toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
// FitView on entry must wait for the canvas to be re-measured; fitting
// a zero-sized canvas corrupts the view transform (scale 0 / NaN)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const { scale, offset } = window.app!.canvas.ds
return (
Number.isFinite(scale) &&
scale > 0.01 &&
Number.isFinite(offset[0]) &&
Number.isFinite(offset[1])
)
})
)
.toBe(true)
})
test('back button returns to app mode and restores chrome on next graph entry', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.errorResolution.back).click()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeHidden()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})
test('locating an error node keeps the panel open', async ({ comfyPage }) => {
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
await panel
.getByRole('button', { name: /locate/i })
.first()
.click()
await comfyPage.nextFrame()
await expect(panel).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.back)
).toBeVisible()
})
test('narrow viewport shows collapsible top bar with card carousel', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 375, height: 812 })
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
await expect(panel).toBeVisible()
await expect(panel.getByTestId(TestIds.errorResolution.back)).toBeVisible()
const errorCards = panel.getByTestId('error-group-execution')
await expect(errorCards.first()).toBeVisible()
// The summary hero is replaced by the top bar count in carousel layout
await expect(panel.getByTestId('errors-summary-hero')).toBeHidden()
// Two catalog groups → two carousel slides with position dots
await expect(errorCards).toHaveCount(2)
const dots = panel.getByTestId('error-carousel-dots').getByRole('button')
await expect(dots).toHaveCount(2)
await expect(dots.first()).toHaveAttribute('aria-current', 'true')
// Each slide spans the full track width (one card per view)
const cardBox = await errorCards.first().boundingBox()
const panelBox = await panel.boundingBox()
expect(cardBox!.width).toBeGreaterThan(panelBox!.width * 0.8)
// Clicking a dot swipes to that slide
await dots.nth(1).click()
await expect(dots.nth(1)).toHaveAttribute('aria-current', 'true')
await panel.getByRole('button', { name: /hide errors/i }).click()
await expect(errorCards.first()).toBeHidden()
await panel.getByRole('button', { name: /show errors/i }).click()
await expect(errorCards.first()).toBeVisible()
})
test('desktop panel does not cover the minimap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
const minimap = comfyPage.page.getByTestId('minimap-container')
await expect(panel).toBeVisible()
await expect(minimap).toBeVisible()
const panelBox = await panel.boundingBox()
const minimapBox = await minimap.boundingBox()
expect(panelBox).not.toBeNull()
expect(minimapBox).not.toBeNull()
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(minimapBox!.y)
})
test('desktop panel does not cover the canvas menu when minimap is hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
const minimapToggle = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(panel).toBeVisible()
await expect(minimapToggle).toBeVisible()
const panelBox = await panel.boundingBox()
const toggleBox = await minimapToggle.boundingBox()
expect(panelBox).not.toBeNull()
expect(toggleBox).not.toBeNull()
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(toggleBox!.y)
})
test('right screen edge has no splitter gutter hit area', async ({
comfyPage
}) => {
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeVisible()
const edgeHitsGutter = await comfyPage.page.evaluate(() => {
const x = window.innerWidth - 1
for (const y of [100, window.innerHeight / 2, window.innerHeight - 100]) {
const el = document.elementFromPoint(x, y)
if (el?.closest('.p-splitter-gutter')) return true
}
return false
})
expect(edgeHitsGutter).toBe(false)
})
})

View File

@@ -1,7 +1,13 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
wstest(
'restoring workflow restores state',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
const preview = node.root.locator('textarea')
await test.step('node previews execution result', async () => {
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
execution.executed('', id, { text: 'massive fennec ears' })
await expect(preview).toHaveValue('massive fennec ears')
})
await test.step('swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(node.root).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(node.root).toBeVisible()
})
await expect(preview, 'previous output is restored').toHaveValue(
'massive fennec ears'
)
}
)
})

View File

@@ -0,0 +1,239 @@
import { expect, mergeTests } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
MODEL_TYPE_CHECKPOINT_GGUF,
MODEL_TYPE_CHECKPOINT_NESTED,
MODEL_TYPE_CHECKPOINT_ROOT,
MODEL_TYPE_CHECKPOINT_SCANNED,
MODEL_TYPE_LORA,
MODEL_TYPE_LORA_README,
STABLE_CHECKPOINT
} from '@e2e/fixtures/data/assetFixtures'
import { withModels } from '@e2e/fixtures/helpers/AssetHelper'
import { dispatchApiCustomEvent } from '@e2e/fixtures/utils/dispatchApiEvent'
import type { ModelFolderInfo } from '@/platform/assets/schemas/assetSchema'
const test = mergeTests(comfyPageFixture, assetApiFixture)
// Deliberately not alphabetical: the sidebar must show folders in backend
// registration order, so 'loras' listed first must render first.
const REGISTERED_FOLDERS: ModelFolderInfo[] = [
{ name: 'loras', folders: ['/models/loras'], extensions: [] },
{
name: 'checkpoints',
folders: ['/models/checkpoints'],
extensions: ['.safetensors', '.gguf']
}
]
const WALK_ASSETS: Asset[] = [
MODEL_TYPE_CHECKPOINT_NESTED,
MODEL_TYPE_CHECKPOINT_ROOT,
MODEL_TYPE_CHECKPOINT_GGUF,
MODEL_TYPE_LORA,
MODEL_TYPE_LORA_README
]
test.use({
initialSettings: {
'Comfy.Assets.UseAssetAPI': true,
'Comfy.ModelLibrary.UseAssetBrowser': false
}
})
test.describe('Model library sidebar - asset mode', () => {
test.beforeEach(async ({ comfyPage, assetApi }) => {
assetApi.configure(withModels(WALK_ASSETS))
await assetApi.mock()
await comfyPage.modelLibrary.mockModelFolders(REGISTERED_FOLDERS)
await comfyPage.setup()
await comfyPage.featureFlags.setServerFlags({
supports_model_type_tags: true
})
await comfyPage.menu.modelLibraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Lists folders in backend registration order', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await expect(tab.folderNodes.nth(0)).toContainText('loras')
await expect(tab.folderNodes.nth(1)).toContainText('checkpoints')
})
test('Eager-loads models and drops the load-all button', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await expect(tab.refreshButton).toBeVisible()
await expect(tab.loadAllFoldersButton).toBeHidden()
// Models render from the eager walk on expansion, with loader_path
// subdirectories as nested folders.
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
await tab.getFolderRowByLabel('SDXL').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
})
test('Applies registered extension allowlists verbatim and default-filters match-all folders', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
// checkpoints registers ['.safetensors', '.gguf'], so the .gguf model
// shows even though the legacy fixed list would have hidden it.
await tab.getFolderRowByLabel('checkpoints').click()
// .gguf keeps its extension intentionally: only .safetensors is stripped by
// the display-name formatter, so this is the correct label to assert.
await expect(tab.getLeafByLabel('flux_quantized.gguf')).toBeVisible()
// loras is registered match-all (empty allowlist); the FE substitutes
// the default model-extension list, hiding non-model noise.
await tab.getFolderRowByLabel('loras').click()
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
await expect(tab.getLeafByLabel('README')).toBeHidden()
})
test('Refresh seeds a backend rescan', async ({ comfyPage, assetApi }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.refreshButton.click()
await expect
.poll(
() =>
assetApi
.getMutations()
.find(
(mutation) =>
mutation.method === 'POST' &&
mutation.endpoint.endsWith('/assets/seed')
)?.body
)
.toEqual({ roots: ['models'] })
})
test('Live-updates the tree when the scan fast-phase completes', async ({
comfyPage,
assetApi
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
await expect(tab.getLeafByLabel('freshly_scanned')).toBeHidden()
assetApi.configure(
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
)
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
})
// Distinct from the tree-update test above: this exercises the debounced
// search pipeline specifically. The tree re-render and the search re-query
// are separate reactive paths; removing this test would silently drop
// coverage of the search update on scan completion.
test('Active search results update when the scan fast-phase completes', async ({
comfyPage,
assetApi
}) => {
const tab = comfyPage.menu.modelLibraryTab
// Search an existing model first: its result proves the eager load and
// the debounced search pipeline have settled, so the later update can
// only come from the scan event, not from a still-pending load.
await tab.searchInput.fill('detail_enhancer')
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
await tab.searchInput.fill('freshly')
await expect(tab.leafNodes).toHaveCount(0)
assetApi.configure(
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
)
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
})
test('Placing a model fills the loader with the category-relative loader path', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
const tab = comfyPage.menu.modelLibraryTab
await tab.getFolderRowByLabel('checkpoints').click()
await tab.getFolderRowByLabel('SDXL').click()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
// The visible ghost preview marks arming as complete, so a zero node
// count here proves nothing is placed until the canvas is clicked.
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe(
MODEL_TYPE_CHECKPOINT_NESTED.loader_path
)
})
})
test.describe('Model library sidebar - asset mode on bare-tag backends', () => {
test.beforeEach(async ({ comfyPage, assetApi }) => {
assetApi.configure(withModels([STABLE_CHECKPOINT]))
await assetApi.mock()
await comfyPage.modelLibrary.mockModelFolders([
{
name: 'checkpoints',
folders: ['/models/checkpoints'],
extensions: ['.safetensors']
}
])
await comfyPage.setup()
// Force the capability off rather than omitting it: the real backend's
// feature_flags handshake would otherwise decide which mode this tests.
// Bare-tag backends bucket by bare tags and emit no loader_path, so
// names fall back to the filename.
await comfyPage.featureFlags.setServerFlags({
supports_model_type_tags: false
})
await comfyPage.menu.modelLibraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Buckets by bare tags and names leaves from the filename', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.getFolderRowByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
})
})

View File

@@ -0,0 +1,80 @@
import { expect, mergeTests } from '@playwright/test'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const test = mergeTests(comfyPageFixture, assetApiFixture)
const assetBrowserModal = '[data-component-id="AssetBrowserModal"]'
test.describe('Model library tab routing', () => {
test('Opens the asset browser when both asset settings are enabled', async ({
comfyPage,
assetApi
}) => {
await assetApi.mock()
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.ModelLibrary.UseAssetBrowser',
true
)
await comfyPage.menu.modelLibraryTab.tabButton.click()
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toHaveCount(0)
})
test('Keeps the sidebar tree when the asset API is disabled', async ({
comfyPage
}) => {
// With the asset API off, the browser setting is inert.
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', false)
await comfyPage.settings.setSetting(
'Comfy.ModelLibrary.UseAssetBrowser',
true
)
await comfyPage.menu.modelLibraryTab.open()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
await expect(comfyPage.page.locator(assetBrowserModal)).toHaveCount(0)
})
test('Keeps the sidebar tree when only the asset API is enabled', async ({
comfyPage,
assetApi
}) => {
await assetApi.mock()
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.ModelLibrary.UseAssetBrowser',
false
)
await comfyPage.menu.modelLibraryTab.open()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
await expect(comfyPage.page.locator(assetBrowserModal)).toHaveCount(0)
})
})
test.describe('Model library tab routing on cloud', { tag: '@cloud' }, () => {
test('Defaults to the asset browser', async ({ comfyPage, assetApi }) => {
// Cloud defaults both asset settings on; no explicit settings here so the
// test pins the defaults, not just the routing.
await assetApi.mock()
await comfyPage.menu.modelLibraryTab.tabButton.click()
// Assert the defaults themselves, not only the routing result.
expect(
await comfyPage.settings.getSetting('Comfy.Assets.UseAssetAPI')
).toBe(true)
expect(
await comfyPage.settings.getSetting('Comfy.ModelLibrary.UseAssetBrowser')
).toBe(true)
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
await expect(comfyPage.menu.modelLibraryTab.modelTree).toHaveCount(0)
})
})

View File

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

View File

@@ -70,7 +70,7 @@
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !isChromeHidden) && 'hidden'
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
@@ -81,7 +81,7 @@
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !isChromeHidden"
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
@@ -131,7 +131,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
@@ -142,7 +141,9 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
@@ -155,7 +156,7 @@ const unifiedWidth = computed(() =>
settingStore.get('Comfy.Sidebar.UnifiedWidth')
)
const { isChromeHidden } = useChromeVisibility()
const { focusMode } = storeToRefs(workspaceStore)
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
@@ -188,9 +189,7 @@ const lastPanelVisible = computed(
*/
const bothSidePanelsVisible = computed(
() =>
!isChromeHidden.value &&
sidebarPanelVisible.value &&
showOffsideSplitter.value
!focusMode.value && sidebarPanelVisible.value && showOffsideSplitter.value
)
const centerPanelDefaultSize = computed(() =>
@@ -265,7 +264,7 @@ const splitterRefreshKey = computed(() => {
})
const firstPanelStyle = computed(() => {
if (isChromeHidden.value) return { display: 'none' }
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -273,7 +272,7 @@ const firstPanelStyle = computed(() => {
})
const lastPanelStyle = computed(() => {
if (isChromeHidden.value) return { display: 'none' }
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -297,9 +296,7 @@ const lastPanelStyle = computed(() => {
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
),
:deep(
.p-splitter-gutter:has(
+ [data-pc-name='splitterpanel'][style*='display: none']
)
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
) {
display: none;
}

View File

@@ -1,6 +1,6 @@
<template>
<div
v-show="workspaceState.focusMode && !errorResolutionStore.isActive"
v-show="workspaceState.focusMode"
class="no-drag fixed top-0 right-0 z-9999 flex flex-row"
>
<Button
@@ -24,12 +24,10 @@ import { watchEffect } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const errorResolutionStore = useErrorResolutionStore()
const settingStore = useSettingStore()
const exitFocusMode = () => {
workspaceState.focusMode = false

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="!isChromeHidden"
v-if="!workspaceStore.focusMode"
class="ml-1 flex flex-col gap-1 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
@@ -145,7 +145,6 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -155,6 +154,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
@@ -168,7 +168,7 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import { cn } from '@comfyorg/tailwind-utils'
const settingStore = useSettingStore()
const { isChromeHidden } = useChromeVisibility()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()

View File

@@ -426,7 +426,7 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay'])(
it.for(['p-dialog', 'p-select-overlay', 'p-toast'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')
@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
expect(event.defaultPrevented).toBe(true)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')

View File

@@ -32,7 +32,9 @@
dialogStore.activeKey === item.key
)
"
@focus-outside="onRekaFocusOutside"
@focus-outside="
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<template v-if="item.dialogComponentProps.headless">

View File

@@ -1,10 +1,13 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
// PrimeVue Dialogs, Toasts) teleport to body. Reka treats clicks on
// body-portaled elements as outside its dialog and would auto-dismiss on the
// first interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside. Toasts matter for focus-outside: when a
// button disables itself mid-action (e.g. a confirm entering its loading
// state), the browser drops focus and recovery can land on the toast's close
// button, which must not dismiss the dialog underneath.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog, .p-toast'
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
@@ -53,7 +56,22 @@ export function onRekaPointerDownOutside(
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
//
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
// also lose focus to an ordinary app element — not just a portal — when a
// nested dialog closes and the element it focused was removed (deleting the
// selected row). That programmatic focus shift is not a dismiss intent, so such
// a dialog opts out of focus-outside dismissal entirely via
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
// pointer.
export function onRekaFocusOutside(
event: OutsideEvent,
options: { dismissOnFocusOutside?: boolean } = {}
) {
if (options.dismissOnFocusOutside === false) {
event.preventDefault()
return
}
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}

View File

@@ -81,7 +81,9 @@ function createTestI18n() {
viewDetails: 'View details'
},
linearMode: {
fixErrors: 'Fix errors'
error: {
goto: 'Show errors in graph'
}
}
}
}
@@ -194,7 +196,7 @@ describe('ErrorOverlay', () => {
await nextTick()
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'Fix errors'
'Show errors in graph'
)
})
})

View File

@@ -41,7 +41,7 @@
>
{{
appMode
? t('linearMode.fixErrors')
? t('linearMode.error.goto')
: t('errorOverlay.viewDetails')
}}
</Button>

View File

@@ -1,116 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { fromAny } from '@total-typescript/shoehorn'
import ErrorResolutionOverlay from './ErrorResolutionOverlay.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
// The real store initializes the auth chain, which cannot run in tests
const workspaceMock = vi.hoisted(() => ({ focusMode: false }))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => workspaceMock
}))
vi.mock('@/components/errorResolution/ErrorResolutionPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
// Without this the async loader mounts the module namespace itself
// instead of unwrapping the default export
__esModule: true,
default: defineComponent({
render: () => h('section', { 'data-testid': 'error-resolution-panel' })
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorResolution: {
backToApp: 'Back to App Mode'
}
}
}
})
function renderOverlay() {
const user = userEvent.setup()
const result = render(ErrorResolutionOverlay, {
global: {
plugins: [
i18n,
createTestingPinia({ createSpy: vi.fn, stubActions: false })
]
}
})
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = fromAny({ activeMode: 'graph' })
return { user, workflowStore, ...result }
}
describe('ErrorResolutionOverlay.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
workspaceMock.focusMode = false
})
it('renders nothing while the view is inactive', () => {
renderOverlay()
expect(
screen.queryByTestId('error-resolution-panel')
).not.toBeInTheDocument()
})
it('shows the back button and panel while active in graph mode', async () => {
renderOverlay()
useErrorResolutionStore().enter()
expect(
await screen.findByTestId('error-resolution-panel')
).toBeInTheDocument()
expect(screen.getByTestId('error-resolution-back')).toBeInTheDocument()
})
it('stays hidden while the workflow is in app mode', async () => {
const { workflowStore } = renderOverlay()
useErrorResolutionStore().enter()
workflowStore.activeWorkflow = fromAny({ activeMode: 'app' })
await waitFor(() => {
expect(
screen.queryByTestId('error-resolution-panel')
).not.toBeInTheDocument()
})
})
it('returns to app mode via the back button', async () => {
const { user } = renderOverlay()
const errorResolutionStore = useErrorResolutionStore()
workspaceMock.focusMode = true
errorResolutionStore.enter()
await user.click(await screen.findByTestId('error-resolution-back'))
expect(errorResolutionStore.isActive).toBe(false)
expect(workspaceMock.focusMode).toBe(false)
expect(useCanvasStore().linearMode).toBe(true)
})
})

View File

@@ -1,47 +0,0 @@
<template>
<template v-if="isVisible">
<Button
v-if="!isNarrow"
data-testid="error-resolution-back"
variant="secondary"
size="lg"
class="fixed top-2 left-2 z-1000"
@click="backToAppMode"
>
<i class="icon-[lucide--arrow-left] size-4" />
{{ t('errorResolution.backToApp') }}
</Button>
<ErrorResolutionPanel @back="backToAppMode" />
</template>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const ErrorResolutionPanel = defineAsyncComponent(
() => import('@/components/errorResolution/ErrorResolutionPanel.vue')
)
const { t } = useI18n()
const canvasStore = useCanvasStore()
const errorResolutionStore = useErrorResolutionStore()
const workspaceStore = useWorkspaceStore()
const isNarrow = useBreakpoints(breakpointsTailwind).smaller('md')
const isVisible = computed(
() => errorResolutionStore.isActive && !canvasStore.linearMode
)
function backToAppMode() {
errorResolutionStore.exit()
workspaceStore.focusMode = false
canvasStore.linearMode = true
}
</script>

View File

@@ -1,158 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { setViewport } from '@/components/searchbox/v2/__test__/testUtils'
import ErrorResolutionPanel from './ErrorResolutionPanel.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: vi.fn()
}))
}))
describe('ErrorResolutionPanel.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorResolution: {
title: 'Fix workflow errors',
backToApp: 'Back to App Mode',
allResolved: 'All errors resolved',
allResolvedDesc: 'You are ready to go back to App Mode.',
showErrors: 'Show errors',
hideErrors: 'Hide errors'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorsDetected: 'Error detected | Errors detected',
resolveBeforeRun: 'Resolve before running the workflow',
expand: 'Expand',
collapse: 'Collapse'
}
}
}
})
})
function renderComponent(initialState = {}) {
const user = userEvent.setup()
const result = render(ErrorResolutionPanel, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
stubs: {
AsyncSearchInput: {
template: '<input />'
}
}
}
})
return { user, ...result }
}
it('shows the resolved state with a back button when no errors exist', async () => {
const { user, emitted } = renderComponent()
expect(
screen.getByRole('status'),
'the resolved transition is announced via the persistent live region'
).toHaveTextContent('All errors resolved')
expect(screen.getAllByText('All errors resolved')).not.toHaveLength(0)
expect(screen.queryByTestId('errors-summary-hero')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Back to App Mode/i }))
expect(emitted('back')).toHaveLength(1)
})
it('shows the error list when errors exist', () => {
renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
expect(screen.getByTestId('errors-summary-hero')).toBeInTheDocument()
expect(screen.queryByText('All errors resolved')).not.toBeInTheDocument()
})
it('shows a collapsible top bar with the error count on narrow viewports', async () => {
setViewport({ width: 375, height: 800 })
const { user, emitted } = renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
expect(
screen.getByText('Error detected'),
'the top bar shows the error count summary'
).toBeInTheDocument()
expect(
screen.queryByTestId('errors-summary-hero'),
'the hero is replaced by the top bar on narrow viewports'
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Hide errors' }))
await waitFor(() => {
expect(
screen.queryByTestId('selection-context-strip')
).not.toBeInTheDocument()
})
expect(
screen.getByRole('button', { name: 'Show errors' })
).toBeInTheDocument()
await user.click(screen.getByTestId('error-resolution-back'))
expect(emitted('back')).toHaveLength(1)
setViewport({ width: 1024, height: 768 })
})
})

View File

@@ -1,161 +0,0 @@
<template>
<section
data-testid="error-resolution-panel"
:class="
cn(
'pointer-events-auto fixed z-1000 flex flex-col overflow-hidden border-interface-stroke bg-base-background',
isNarrow
? 'inset-x-0 top-0 border-b'
: cn(
// Centered in the band above the canvas menu (or the open
// minimap): equal 10%-of-band gaps top and bottom
'right-1 w-90 max-w-[calc(100vw-0.5rem)] rounded-lg border shadow-interface',
isMinimapVisible
? 'top-[calc((100%-258px)/10)] bottom-[calc(258px+(100%-258px)/10)]'
: 'top-[calc((100%-58px)/10)] bottom-[calc(58px+(100%-58px)/10)]'
)
)
"
:aria-label="t('errorResolution.title')"
>
<!-- Persistent live region: one inserted with its content is not announced -->
<span role="status" class="sr-only">
{{ isResolved ? t('errorResolution.allResolved') : '' }}
</span>
<div
v-if="isNarrow"
class="flex min-w-0 shrink-0 items-center gap-2 bg-base-foreground/5 p-2"
>
<Button
data-testid="error-resolution-back"
variant="base"
size="icon"
class="shrink-0 border border-interface-stroke"
:aria-label="t('errorResolution.backToApp')"
@click="emit('back')"
>
<i class="icon-[lucide--arrow-left] size-4" />
</Button>
<template v-if="isResolved">
<i
aria-hidden="true"
class="icon-[lucide--circle-check] size-5 shrink-0 text-success-background"
/>
<span class="min-w-0 flex-1 truncate text-sm font-semibold">
{{ t('errorResolution.allResolved') }}
</span>
</template>
<template v-else>
<span
class="flex h-10 min-w-7 shrink-0 items-center justify-center px-1 text-2xl/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-8 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5 px-1">
<span
class="truncate text-xs/tight font-semibold text-base-foreground"
>
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="truncate text-2xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</template>
<Button
variant="textonly"
size="icon"
class="shrink-0"
:aria-label="
isExpanded
? t('errorResolution.hideErrors')
: t('errorResolution.showErrors')
"
:aria-expanded="isExpanded"
@click="isExpanded = !isExpanded"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-up]'
: 'icon-[lucide--chevron-down]'
)
"
/>
</Button>
</div>
<TransitionCollapse>
<div v-if="!isNarrow || isExpanded" class="flex min-h-0 flex-1 flex-col">
<div
v-if="isResolved"
class="flex min-h-0 flex-1 flex-col bg-interface-panel-surface p-3"
>
<div
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-lg border border-secondary-background px-6 py-8 text-center"
>
<i
aria-hidden="true"
class="icon-[lucide--circle-check] size-10 text-success-background"
/>
<p class="m-0 text-sm font-semibold text-base-foreground">
{{ t('errorResolution.allResolved') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ t('errorResolution.allResolvedDesc') }}
</p>
<Button variant="secondary" class="mt-2" @click="emit('back')">
<i class="icon-[lucide--arrow-left] size-4" />
{{ t('errorResolution.backToApp') }}
</Button>
</div>
</div>
<ErrorGroupList
v-else
:show-search="false"
:carousel="isNarrow"
class="min-h-0 flex-1"
/>
</div>
</TransitionCollapse>
</section>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import ErrorGroupList from '@/components/rightSidePanel/errors/ErrorGroupList.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import { useSettingStore } from '@/platform/settings/settingStore'
const emit = defineEmits<{
back: []
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const isNarrow = useBreakpoints(breakpointsTailwind).smaller('md')
const isExpanded = ref(true)
const isMinimapVisible = computed(() =>
settingStore.get('Comfy.Minimap.Visible')
)
const { allErrorGroups } = useErrorGroups('')
const totalErrorCount = computed(() =>
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const isResolved = computed(() => allErrorGroups.value.length === 0)
</script>

View File

@@ -153,7 +153,6 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
@@ -240,8 +239,9 @@ const selectionToolboxEnabled = computed(() =>
const activeSidebarTab = computed(() => {
return workspaceStore.sidebarTab.activeSidebarTab
})
const { isChromeHidden } = useChromeVisibility()
const showUI = computed(() => !isChromeHidden.value && betaMenuEnabled.value)
const showUI = computed(
() => !workspaceStore.focusMode && betaMenuEnabled.value
)
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))

View File

@@ -1,19 +1,12 @@
<template>
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
<div class="flex min-h-8 w-full items-center gap-2 px-3">
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:class="
cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left',
collapsible &&
'focus-visible:ring-ring cursor-pointer outline-none focus-visible:ring-1'
)
"
:aria-expanded="collapsible ? !collapse : undefined"
:aria-controls="collapsible ? bodyId : undefined"
@click="collapsible && (collapse = !collapse)"
<button
type="button"
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
:aria-controls="bodyId"
@click="collapse = !collapse"
>
<span
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
@@ -23,10 +16,9 @@
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ title }}
</span>
</component>
</button>
<slot name="actions" />
<button
v-if="collapsible"
type="button"
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
@@ -48,7 +40,7 @@
</button>
</div>
<TransitionCollapse>
<div v-if="!(collapsible && collapse)" :id="bodyId">
<div v-if="!collapse" :id="bodyId">
<slot />
</div>
</TransitionCollapse>
@@ -65,13 +57,10 @@ import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCol
const {
title,
count,
collapsible = true,
class: className
} = defineProps<{
title: string
count: number
/** When false, the section always renders expanded with no toggle UI. */
collapsible?: boolean
class?: string
}>()

View File

@@ -89,10 +89,9 @@ function seedTwoErrorGroups(pinia: TestingPinia) {
})
}
function renderList(pinia: TestingPinia, props: Record<string, unknown> = {}) {
function renderList(pinia: TestingPinia) {
const user = userEvent.setup()
const { rerender } = render(ErrorGroupList, {
props,
render(ErrorGroupList, {
global: {
plugins: [PrimeVue, testI18n, pinia],
stubs: {
@@ -102,7 +101,7 @@ function renderList(pinia: TestingPinia, props: Record<string, unknown> = {}) {
}
}
})
return { user, rerender }
return { user }
}
function createPinia() {
@@ -237,68 +236,4 @@ describe('ErrorGroupList selection emphasis', () => {
expect(strip).toHaveTextContent('2 nodes — 2 errors')
})
})
it('carousel ignores collapse state, renders no toggles, and snaps to matches', async () => {
const scrollTo = vi
.spyOn(Element.prototype, 'scrollTo')
.mockImplementation(() => {})
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const { user, rerender } = renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const loaderSection = getSectionByTitle('Validation failed')
const [loaderHeader] = within(loaderSection).getAllByRole('button')
await user.click(loaderHeader)
expect(isSectionExpanded(loaderSection)).toBe(false)
await rerender({ carousel: true })
expect(
within(getSectionByTitle('Validation failed')).getByText('LoaderNode'),
'the carousel ignores collapse state carried over from the list'
).toBeInTheDocument()
expect(
screen.queryByLabelText('Collapse'),
'the carousel renders no collapse affordances'
).not.toBeInTheDocument()
expect(screen.queryByLabelText('Expand')).not.toBeInTheDocument()
const sections = screen.getAllByTestId('error-group-execution')
const matchedIndex = 1
const nodeInSecondSlide = within(sections[matchedIndex]).queryByText(
'Missing connection'
)
? SAMPLER_NODE
: LOADER_NODE
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([nodeInSecondSlide])
await waitFor(() => {
expect(
scrollTo,
'selection snaps to the matched slide, not slide 0'
).toHaveBeenCalledWith(
expect.objectContaining({ left: matchedIndex * 8 })
)
})
expect(
within(getSectionByTitle('Validation failed')).getByText('LoaderNode'),
'slides stay expanded instead of collapsing'
).toBeInTheDocument()
const dots = within(screen.getByTestId('error-carousel-dots')).getAllByRole(
'button'
)
expect(dots).toHaveLength(2)
scrollTo.mockClear()
await user.click(dots[0])
expect(scrollTo, 'dot buttons scroll to their slide').toHaveBeenCalledWith(
expect.objectContaining({ left: 0 })
)
scrollTo.mockRestore()
})
})

View File

@@ -2,7 +2,6 @@
<div class="flex min-w-0 flex-col">
<!-- Search bar + collapse toggle -->
<div
v-if="showSearch"
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
@@ -27,16 +26,10 @@
<div
v-else
:class="
cn(
!carousel &&
'overflow-hidden rounded-lg border border-secondary-background'
)
"
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
v-if="!carousel"
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
@@ -63,14 +56,7 @@
<div
data-testid="selection-context-strip"
role="status"
:class="
cn(
'flex items-center px-3',
carousel
? 'mb-2 rounded-md border border-secondary-background py-1.5'
: 'border-t border-secondary-background pt-3.5 pb-1.5'
)
"
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
>
<i18n-t
:keypath="strip.keypath"
@@ -92,308 +78,246 @@
</div>
<!-- Group by Class Type -->
<div
ref="carouselRef"
:class="
cn(
carousel && 'scrollbar-hide snap-x snap-mandatory overflow-x-auto'
)
"
>
<TransitionGroup
tag="div"
name="list-scale"
:class="cn('relative', carousel && 'flex gap-2')"
<TransitionGroup tag="div" name="list-scale" class="relative">
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="group.count"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="group.count"
:collapsible="!carousel"
:collapse="
!carousel && isSectionCollapsed(group.groupKey) && !isSearching
"
:class="
cn(
carousel
? 'max-h-[30vh] w-full shrink-0 snap-center overflow-y-auto rounded-lg border border-secondary-background'
: 'border-t border-secondary-background first:border-t-0'
)
"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="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"
>
<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"
/>
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="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"
>
<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"
/>
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
:aria-current="
isCardInSelection(item.cardId) ? 'true' : undefined
"
:class="
cn(
'min-w-0',
selectionEmphasisClass(isCardInSelection(item.cardId))
)
"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
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-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="
isExecutionItemDetailExpanded(item.key)
"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.label
})
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div
v-else-if="group.type === 'execution'"
class="space-y-3 px-3"
>
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
:aria-current="
isCardInSelection(card.id) ? 'true' : undefined
isCardInSelection(item.cardId) ? 'true' : undefined
"
:class="
cn(
selectionEmphasisClass(isCardInSelection(card.id)),
isCardInSelection(card.id) && '-my-1 py-1'
'min-w-0',
selectionEmphasisClass(isCardInSelection(item.cardId))
)
"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-model="handleLocateAssetNode"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
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-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.label
})
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:aria-current="isCardInSelection(card.id) ? 'true' : undefined"
:class="
cn(
selectionEmphasisClass(isCardInSelection(card.id)),
isCardInSelection(card.id) && '-my-1 py-1'
)
"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
<!-- Carousel position indicator -->
<div
v-if="carousel && filteredGroups.length > 1"
data-testid="error-carousel-dots"
class="flex justify-center pt-0.5"
>
<button
v-for="(group, index) in filteredGroups"
:key="group.groupKey"
type="button"
:aria-label="group.displayTitle"
:aria-current="index === activeSlide ? 'true' : undefined"
class="flex size-6 cursor-pointer appearance-none items-center justify-center border-0 bg-transparent p-0"
@click="scrollToSlide(index)"
>
<span
:class="
cn(
'rounded-full transition-all',
index === activeSlide ? 'size-2' : 'size-1.5',
hasSelectionEmphasis &&
selectionMatchedGroupKeys.has(group.groupKey)
? 'bg-primary-background-hover'
: index === activeSlide
? 'bg-base-foreground'
: 'bg-base-foreground/30'
)
"
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-model="handleLocateAssetNode"
/>
</button>
</div>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -433,12 +357,6 @@ interface ExecutionItemListEntry {
displayDetails?: string
}
const { showSearch = true, carousel } = defineProps<{
showSearch?: boolean
/** Render error groups as horizontally swipeable cards (narrow screens). */
carousel?: boolean
}>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
@@ -566,35 +484,6 @@ const strip = computed(() => {
}
})
// Matches the carousel track's gap-2 (8px) between slides
const CAROUSEL_SLIDE_GAP_PX = 8
const carouselRef = useTemplateRef<HTMLElement>('carouselRef')
const { x: carouselScrollX } = useScroll(carouselRef)
const { width: carouselWidth } = useElementSize(carouselRef)
const activeSlide = computed(() => {
if (carouselWidth.value === 0) return 0
const stride = carouselWidth.value + CAROUSEL_SLIDE_GAP_PX
return Math.max(
0,
Math.min(
filteredGroups.value.length - 1,
Math.round(carouselScrollX.value / stride)
)
)
})
function scrollToSlide(index: number) {
const track = carouselRef.value
if (!track) return
// clientWidth, not the ResizeObserver-driven carouselWidth: on the
// immediate-watch mount path the observer has not delivered yet
track.scrollTo({
left: index * (track.clientWidth + CAROUSEL_SLIDE_GAP_PX),
behavior: 'smooth'
})
}
function isCardInSelection(cardId: string): boolean {
return selectionMatchedCardIds.value.has(cardId)
}
@@ -609,32 +498,23 @@ const selectionEmphasisSignature = computed(() =>
: ''
)
// Emphasis per layout: the list expands matched groups and collapses the
// rest; the carousel never collapses (a collapsed slide reads as an empty
// card) and snaps to the first matched slide instead.
/**
* Selection acts as emphasis, not a filter: expand the groups containing
* the selected nodes' errors and collapse the rest. When the emphasis ends
* (selection cleared or moved to a node without errors), re-expand all
* groups so the tab reads as the workflow overview again.
*/
watch(
selectionEmphasisSignature,
(signature, previousSignature) => {
if (!signature) {
if (!previousSignature) return
// Restore regardless of layout: collapse state persists across the
// carousel (which ignores it), so a stale emphasis must not resurface
// when the panel returns to the list layout.
for (const groupKey of Object.keys(collapseState)) {
setSectionCollapsed(groupKey, false)
}
return
}
const matchedKeys = selectionMatchedGroupKeys.value
if (carousel) {
const matchedIndex = filteredGroups.value.findIndex((group) =>
matchedKeys.has(group.groupKey)
)
if (matchedIndex < 0) return
// nextTick: with immediate:true the carousel element isn't mounted yet
void nextTick(() => scrollToSlide(matchedIndex))
return
}
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
}

View File

@@ -1,5 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -18,7 +19,10 @@ const {
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
downloadStoreState
mockLoadModels,
downloadStoreState,
settingState,
modelsState
} = vi.hoisted(() => {
let capturedRoot: TreeExplorerNode<unknown> | null = null
return {
@@ -33,7 +37,13 @@ const {
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
mockLoadModels: vi.fn().mockResolvedValue([]),
downloadStoreState: { setLastCompleted: (_: unknown) => {} },
settingState: { useAssetAPI: false, autoLoadAll: false },
modelsState: {
push: (_: unknown) => {},
reset: () => {}
}
}
})
@@ -54,20 +64,30 @@ const mockModel = fromPartial<ComfyModelDef>({
searchable: 'checkpoints/model.safetensors'
})
vi.mock('@/stores/modelStore', () => ({
ResourceState: {
Loading: 'loading',
Loaded: 'loaded'
},
useModelStore: () => ({
modelFolders: [],
models: [mockModel],
loadModels: vi.fn().mockResolvedValue([]),
loadModelFolders: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue(undefined),
refreshModelFolder: mockRefreshModelFolder
})
}))
vi.mock('@/stores/modelStore', async () => {
const { reactive } = await import('vue')
const models = reactive<ComfyModelDef[]>([])
modelsState.push = (model: unknown) => {
models.push(model as ComfyModelDef)
}
modelsState.reset = () => {
models.splice(0, models.length, mockModel)
}
return {
ResourceState: {
Loading: 'loading',
Loaded: 'loaded'
},
useModelStore: () => ({
modelFolders: [],
models,
loadModels: mockLoadModels,
loadModelFolders: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue(undefined),
refreshModelFolder: mockRefreshModelFolder
})
}
})
vi.mock('@/stores/assetDownloadStore', async () => {
const { ref } = await import('vue')
@@ -92,6 +112,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.ModelLibrary.NameFormat') return 'filename'
if (key === 'Comfy.Assets.UseAssetAPI') return settingState.useAssetAPI
if (key === 'Comfy.ModelLibrary.AutoLoadAll') {
return settingState.autoLoadAll
}
return false
})
})
@@ -104,26 +128,45 @@ vi.mock('@/composables/useTreeExpansion', () => ({
})
}))
vi.mock('@/components/common/TreeExplorer.vue', () => ({
default: {
name: 'TreeExplorer',
template: '<div data-testid="tree-explorer" />',
props: ['root', 'expandedKeys'],
setup(props: { root: TreeExplorerNode<unknown> }) {
captureRoot(props.root)
vi.mock('@/components/common/TreeExplorer.vue', async () => {
const { watchEffect } = await import('vue')
return {
default: {
name: 'TreeExplorer',
template: '<div data-testid="tree-explorer" />',
props: ['root', 'expandedKeys'],
setup(props: { root: TreeExplorerNode<unknown> }) {
watchEffect(() => captureRoot(props.root))
}
}
}
}))
})
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchInput',
template: '<input data-testid="search-input" />',
template: '<input data-testid="search-input" @input="onInput" />',
props: ['modelValue', 'placeholder'],
setup() {
return { focus: vi.fn() }
},
expose: ['focus']
emits: ['update:modelValue', 'search'],
setup(
_props: unknown,
{
emit,
expose
}: {
emit: (event: 'update:modelValue' | 'search', value: string) => void
expose: (exposed: Record<string, unknown>) => void
}
) {
expose({ focus: vi.fn() })
return {
onInput: (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
emit('search', value)
}
}
}
}
}))
@@ -134,7 +177,8 @@ vi.mock('./SidebarTopArea.vue', () => ({
vi.mock('./SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template: '<div><slot name="header" /><slot name="body" /></div>'
template:
'<div><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
}
}))
@@ -157,13 +201,17 @@ describe('ModelLibrarySidebarTab', () => {
vi.clearAllMocks()
resetRoot()
downloadStoreState.setLastCompleted(null)
settingState.useAssetAPI = false
settingState.autoLoadAll = false
modelsState.reset()
})
function renderComponent() {
return render(ModelLibrarySidebarTab, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
stubs: { teleport: true }
stubs: { teleport: true },
directives: { tooltip: {} }
}
})
}
@@ -236,4 +284,67 @@ describe('ModelLibrarySidebarTab', () => {
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
})
describe('search', () => {
it('updates active search results when a reload adds a matching model', async () => {
const user = userEvent.setup()
renderComponent()
await nextTick()
await user.type(screen.getByTestId('search-input'), 'model')
await nextTick()
expect(mockLoadModels).toHaveBeenCalled()
const leafLabels = () => {
const { children: folders = [] } = getRoot()
return folders.flatMap(({ children: leaves = [] }) =>
leaves.map((leaf) => leaf.label)
)
}
expect(leafLabels()).toEqual(['model'])
// A completed scan reloads the store while the search is still active.
modelsState.push(
fromPartial<ComfyModelDef>({
key: 'checkpoints/model-new.safetensors',
file_name: 'model-new.safetensors',
simplified_file_name: 'model-new',
title: 'Model New',
directory: 'checkpoints',
searchable: 'checkpoints/model-new.safetensors'
})
)
await nextTick()
expect(leafLabels()).toEqual(['model', 'model-new'])
})
})
describe('asset mode', () => {
it('hides the load-all button and eager-loads models on mount', async () => {
settingState.useAssetAPI = true
renderComponent()
await nextTick()
expect(screen.queryByLabelText('g.loadAllFolders')).toBeNull()
expect(screen.getByLabelText('g.refresh')).toBeInTheDocument()
expect(mockLoadModels).toHaveBeenCalledTimes(1)
})
it('legacy mode keeps the load-all button and stays lazy by default', async () => {
renderComponent()
await nextTick()
expect(screen.getByLabelText('g.loadAllFolders')).toBeInTheDocument()
expect(mockLoadModels).not.toHaveBeenCalled()
})
it('legacy mode still honors AutoLoadAll', async () => {
settingState.autoLoadAll = true
renderComponent()
await nextTick()
expect(mockLoadModels).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -11,6 +11,7 @@
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
<Button
v-if="!usesAssetAPI"
v-tooltip.bottom="$t('g.loadAllFolders')"
variant="muted-textonly"
size="icon"
@@ -77,28 +78,28 @@ import { buildTree } from '@/utils/treeUtil'
const modelStore = useModelStore()
const modelToNodeStore = useModelToNodeStore()
const settingStore = useSettingStore()
const usesAssetAPI = computed(() =>
settingStore.get('Comfy.Assets.UseAssetAPI')
)
const assetDownloadStore = useAssetDownloadStore()
const searchBoxRef = ref()
const searchQuery = ref<string>('')
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const filteredModels = ref<ComfyModelDef[]>([])
const filteredModels = computed<ComfyModelDef[]>(() => {
const search = searchQuery.value.toLocaleLowerCase()
if (!search) return []
return modelStore.models.filter((model) => model.searchable.includes(search))
})
const handleSearch = async (query: string) => {
if (!query) {
filteredModels.value = []
expandedKeys.value = {}
return
}
// Load all models to ensure we have the latest data
// Load all models to ensure results cover folders not yet opened
await modelStore.loadModels()
const search = query.toLocaleLowerCase()
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
})
await nextTick()
expandNode(root.value)
}
type ModelOrFolder = ComfyModelDef | ModelFolder
@@ -112,6 +113,12 @@ const root = computed<TreeNode>(() => {
)
})
watch(root, async (newRoot) => {
if (!searchQuery.value) return
await nextTick()
expandNode(newRoot)
})
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
@@ -193,7 +200,13 @@ watch(
onMounted(async () => {
searchBoxRef.value?.focus()
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
// In asset mode the whole library resolves from one cached walk, so eager
// loading is cheap and keeps search and folder badges complete from the
// start; AutoLoadAll remains the opt-in for the request-per-folder legacy path.
if (
usesAssetAPI.value ||
settingStore.get('Comfy.ModelLibrary.AutoLoadAll')
) {
await modelStore.loadModels()
}
})

View File

@@ -26,6 +26,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyModelDef } from '@/stores/modelStore'
import { getModelPreviewUrl } from '@/stores/modelStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import ModelPreview from './ModelPreview.vue'
@@ -37,17 +38,7 @@ const props = defineProps<{
// Note: The leaf node should always have a model definition on node.data.
const modelDef = computed<ComfyModelDef>(() => props.node.data!)
const modelPreviewUrl = computed(() => {
if (modelDef.value.image) {
return modelDef.value.image
}
const folder = modelDef.value.directory
const path_index = modelDef.value.path_index
const extension = modelDef.value.file_name.split('.').pop()
const filename = modelDef.value.file_name.replace(`.${extension}`, '.webp')
const encodedFilename = encodeURIComponent(filename).replace(/%2F/g, '/')
return `/api/experiment/models/preview/${folder}/${path_index}/${encodedFilename}`
})
const modelPreviewUrl = computed(() => getModelPreviewUrl(modelDef.value))
const previewRef = ref<InstanceType<typeof ModelPreview> | null>(null)
const modelPreviewStyle = ref<CSSProperties>({

View File

@@ -1,20 +0,0 @@
import { computed } from 'vue'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
/**
* Whether UI chrome (sidebars, top menu, tabs, bottom panel) is hidden,
* leaving only the canvas and minimap. True in focus mode and in the
* error-resolution view.
*/
export function useChromeVisibility() {
const workspaceStore = useWorkspaceStore()
const errorResolutionStore = useErrorResolutionStore()
const isChromeHidden = computed(
() => workspaceStore.focusMode || errorResolutionStore.isActive
)
return { isChromeHidden }
}

View File

@@ -33,7 +33,8 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
SIGNUP_TURNSTILE = 'signup_turnstile',
SUPPORTS_MODEL_TYPE_TAGS = 'supports_model_type_tags'
}
/**
@@ -208,6 +209,12 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get supportsModelTypeTags() {
return api.getServerFeature(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
false
)
}
})

View File

@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
@@ -37,35 +36,11 @@ vi.mock('@/scripts/app', () => ({
app: appMock
}))
const settingsMock = vi.hoisted(() => {
const values = new Map<string, unknown>()
return {
values,
get: vi.fn((key: string) => values.get(key)),
set: vi.fn((key: string, value: unknown) => {
values.set(key, value)
return Promise.resolve()
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingsMock
}))
const executeCommandMock = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined)
)
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: executeCommandMock })
}))
function createSelectedCanvas({ width = 800, height = 600 } = {}) {
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = width
canvasElement.height = height
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
@@ -85,18 +60,15 @@ function createSelectedCanvas({ width = 800, height = 600 } = {}) {
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
settingsMock.values.clear()
executeCommandMock.mockResolvedValue(undefined)
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('enters the error-resolution view when coming from app mode', async () => {
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
@@ -106,84 +78,15 @@ describe('useViewErrorsInGraph', () => {
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
settingsMock.values.set('Comfy.Minimap.Visible', true)
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(errorResolutionStore.isActive).toBe(true)
expect(rightSidePanelStore.isOpen).toBeFalsy()
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(
settingsMock.set,
'entering the view must not mutate persisted settings'
).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(
executeCommandMock,
'the graph is fit into view on entry'
).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
})
it('skips FitView when the canvas never re-measures', async () => {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const { canvas } = createSelectedCanvas({ width: 0, height: 0 })
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
useViewErrorsInGraph().viewErrorsInGraph()
// Outlast the 30-frame resize wait
await new Promise((resolve) => setTimeout(resolve, 700))
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('skips FitView when the view is exited during the resize wait', async () => {
const canvasStore = useCanvasStore()
const errorResolutionStore = useErrorResolutionStore()
const workflowStore = useWorkflowStore()
const { canvas } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
useViewErrorsInGraph().viewErrorsInGraph()
errorResolutionStore.exit()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('opens the errors panel when already in graph mode', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'graph'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(errorResolutionStore.isActive).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('opens graph errors when the canvas is not initialized', () => {

View File

@@ -1,62 +1,20 @@
import { nextTick } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { isAppMode } = useAppMode()
/**
* Wait until the canvas backing store reflects the now-visible container.
* While app mode is shown, the canvas container is display:none and the
* ResizeObserver in app.ts zeroes the canvas size; fitting before it
* re-measures would compute a broken (zero/NaN) scale.
*/
async function waitForCanvasResize() {
const canvasElement = canvasStore.canvas?.canvas
if (!canvasElement) return false
const maxFrames = 30
for (let frame = 0; frame < maxFrames; frame++) {
if (canvasElement.width > 0 && canvasElement.height > 0) return true
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
}
return canvasElement.width > 0 && canvasElement.height > 0
}
/** Fit the whole graph into the now-visible canvas. */
async function prepareErrorResolutionCanvas() {
await nextTick()
if (!(await waitForCanvasResize())) return
// The resize wait spans frames; the user may have already left the view
if (!errorResolutionStore.isActive) return
await commandStore.execute('Comfy.Canvas.FitView')
}
function viewErrorsInGraph() {
const fromAppMode = isAppMode.value
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
if (fromAppMode) {
errorResolutionStore.enter()
void prepareErrorResolutionCanvas()
} else {
rightSidePanelStore.openPanel('errors')
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}

View File

@@ -9,7 +9,9 @@ import {
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
useExtensionService().registerExtension({
name: 'Comfy.PreviewAny',
@@ -30,5 +32,11 @@ useExtensionService().registerExtension({
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
},
onNodeOutputsUpdated(nodeOutputs) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
}
}
})

View File

@@ -2004,14 +2004,6 @@
"coreNodesFromVersion": "Core nodes from version {version}:",
"unknownVersion": "unknown"
},
"errorResolution": {
"title": "Fix workflow errors",
"backToApp": "Back to App Mode",
"allResolved": "All errors resolved",
"allResolvedDesc": "You're ready to go back to App Mode and run this workflow.",
"showErrors": "Show errors",
"hideErrors": "Hide errors"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
@@ -3130,57 +3122,52 @@
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {
"title": "Cloud Survey",
"title": "Let's get to know you",
"placeholder": "Survey questions placeholder",
"intro": "Help us tailor your ComfyUI experience.",
"intro": "A few quick questions so we can set up ComfyUI for you.",
"otherPlaceholder": "Tell us more",
"errors": {
"chooseAnOption": "Please choose an option.",
"selectAtLeastOne": "Please select at least one option.",
"describeAnswer": "Please describe your answer."
},
"steps": {
"usage": "How do you plan to use ComfyUI?",
"familiarity": "How familiar are you with ComfyUI?",
"intent": "What do you want to create with ComfyUI?",
"source": "Where did you hear about ComfyUI?"
"describeAnswer": "Please describe your answer.",
"answerTooLong": "Please keep your answer under {max} characters."
},
"options": {
"usage": {
"personal": "Personal use",
"work": "Work",
"education": "Education (student or educator)"
},
"familiarity": {
"new": "New — never used it",
"starting": "Beginner — following tutorials",
"basics": "Intermediate — comfortable with basics",
"advanced": "Advanced — build and edit workflows",
"expert": "Expert — I help others"
},
"intent": {
"workflows": "Custom workflows or pipelines",
"custom_nodes": "Custom nodes",
"videos": "Videos",
"images": "Images",
"3d_game": "3D assets / game assets",
"audio": "Audio / music",
"apps": "Simplified Apps from workflows",
"api": "API endpoints to run workflows",
"not_sure": "Not sure"
"video": "Video",
"workflows": "Workflows and pipelines",
"apps_api": "Apps and APIs",
"exploring": "Just exploring",
"other": "Something else",
"otherPlaceholder": "What do you want to make?"
},
"experience": {
"new": "New to ComfyUI",
"some": "I know my way around",
"pro": "I'm a power user"
},
"focus": {
"custom_nodes": "Custom nodes",
"pipelines": "Automated pipelines",
"products": "Products for others"
},
"source": {
"social": "Social media",
"friend": "A friend or colleague",
"search": "Web search",
"community": "A community or forum",
"other": "Somewhere else",
"otherPlaceholder": "Where did you find us?"
},
"source_social": {
"youtube": "YouTube",
"reddit": "Reddit",
"twitter": "Twitter / X",
"twitter": "X (Twitter)",
"instagram": "Instagram",
"tiktok": "TikTok",
"linkedin": "LinkedIn",
"friend": "Friend or colleague",
"search": "Google / search",
"newsletter": "Newsletter or blog",
"conference": "Conference or event",
"discord": "Discord / community",
"github": "GitHub",
"other": "Other"
"discord": "Discord"
}
}
},
@@ -3273,10 +3260,11 @@
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to make?",
"cloudSurvey_steps_experience": "How well do you know ComfyUI?",
"cloudSurvey_steps_focus": "What are you building?",
"cloudSurvey_steps_source": "How did you find us?",
"cloudSurvey_steps_source_social": "Which platform?",
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
@@ -3722,7 +3710,7 @@
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"fixErrors": "Fix errors",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
@@ -3775,6 +3763,7 @@
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
"promptVisitGraph": "View the node graph to see the full error.",
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
"goto": "Show errors in graph",
"github": "submit a GitHub issue",
"guide": "troubleshooting guide",
"support": "contact our support",

View File

@@ -9,6 +9,20 @@ import { useAssetsStore } from '@/stores/assetsStore'
const mockAssetsByKey = vi.hoisted(() => new Map<string, AssetItem[]>())
const mockLoadingByKey = vi.hoisted(() => new Map<string, boolean>())
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
},
get modelUploadButtonEnabled() {
return false
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) =>
@@ -214,6 +228,7 @@ describe('AssetBrowserModal', () => {
vi.resetAllMocks()
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockSupportsModelTypeTags.value = false
})
describe('Integration with useAssetBrowser', () => {
@@ -420,5 +435,20 @@ describe('AssetBrowserModal', () => {
'assetBrowser.allCategory:{"category":"Checkpoints"}'
)
})
it('strips the model_type: prefix from the title when the flag is on', async () => {
mockSupportsModelTypeTags.value = true
const assets = [
createTestAsset('asset1', 'Model A', 'model_type:checkpoints')
]
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
renderModal({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
expect(screen.getByTestId('modal-title').textContent).toBe(
'assetBrowser.allCategory:{"category":"Checkpoints"}'
)
})
})
})

View File

@@ -100,6 +100,7 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
@@ -109,12 +110,14 @@ import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getPrimaryCategoryTag } from '@/platform/assets/utils/assetMetadataUtils'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
const { t } = useI18n()
const { flags } = useFeatureFlags()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
@@ -191,9 +194,21 @@ const focusedAsset = ref<AssetDisplayItem | null>(null)
const isRightPanelOpen = ref(false)
const primaryCategoryTag = computed(() => {
const modelTypeMode = flags.supportsModelTypeTags
// A node-typed picker is FOR a category; title off that category rather
// than guessing from the first asset, whose first model_type value may be
// a different category it shares a root with.
if (modelTypeMode && props.nodeType) {
const mapped = modelToNodeStore.getCategoryForNodeType(props.nodeType)
if (mapped) return mapped
}
const assets = fetchedAssets.value ?? []
// Covered assets title off the model_type value they group under (so title
// and grouping cannot diverge); uncovered assets keep the legacy verbatim
// first tag.
const tagFromAssets = assets
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
.map((asset) => getPrimaryCategoryTag(asset, modelTypeMode))
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
if (tagFromAssets) return tagFromAssets

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
@@ -14,6 +14,14 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
})
}))
const mockDistribution = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<object>()),
get isCloud() {
return mockDistribution.isCloud
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -41,6 +49,10 @@ describe('ModelInfoPanel', () => {
...overrides
})
afterEach(() => {
mockDistribution.isCloud = false
})
function renderPanel(asset: AssetDisplayItem) {
return render(ModelInfoPanel, {
props: { asset },
@@ -138,6 +150,18 @@ describe('ModelInfoPanel', () => {
).toBeInTheDocument()
})
it('shows an editable model type dropdown for a mutable asset on cloud', () => {
mockDistribution.isCloud = true
renderPanel(createMockAsset({ is_immutable: false }))
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('keeps the model type read-only on core even for a mutable asset', () => {
mockDistribution.isCloud = false
renderPanel(createMockAsset({ is_immutable: false }))
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
it('renders base models field', () => {
const asset = createMockAsset({
user_metadata: { base_model: ['SDXL'] }

View File

@@ -71,7 +71,7 @@
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-if="!isImmutable" v-model="selectedModelType">
<Select v-if="isModelTypeEditable" v-model="selectedModelType">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
@@ -215,6 +215,7 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -229,17 +230,19 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { isCloud } from '@/platform/distribution/types'
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
import {
buildModelTypeTagUpdate,
getAssetAdditionalTags,
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getEditableModelType,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -265,6 +268,7 @@ const { asset, cacheKey, selectContentStyle } = defineProps<{
}>()
const assetsStore = useAssetsStore()
const { flags } = useFeatureFlags()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
@@ -272,6 +276,9 @@ const pendingModelType = ref<string | undefined>(undefined)
const isEditingDisplayName = ref(false)
const isImmutable = computed(() => asset.is_immutable ?? true)
// Retagging a model rewrites its asset tags; core is filesystem-backed and does
// not yet move the file to match, so the model type is read-only off-cloud.
const isModelTypeEditable = computed(() => !isImmutable.value && isCloud)
const displayName = computed(
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
)
@@ -318,12 +325,17 @@ function handleDisplayNameEdit(newName: string) {
}
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
if (isImmutable.value) return
const currentModelType = getAssetModelType(asset)
if (!isModelTypeEditable.value) return
const currentModelType = getEditableModelType(
asset,
flags.supportsModelTypeTags
)
if (currentModelType === newModelType) return
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
const newTags = buildModelTypeTagUpdate(
asset,
newModelType,
flags.supportsModelTypeTags
)
assetsStore.updateAssetTags(asset, newTags, cacheKey)
}, 500)
@@ -345,7 +357,10 @@ const userDescription = computed({
})
const selectedModelType = computed({
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
get: () =>
pendingModelType.value ??
getEditableModelType(asset, flags.supportsModelTypeTags) ??
undefined,
set: (value: string | undefined) => {
if (!value) return
pendingModelType.value = value

View File

@@ -25,10 +25,22 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toLocaleDateString()
}))
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
}
}
})
}))
describe('useAssetBrowser', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
mockSupportsModelTypeTags.value = false
})
// Test fixtures - minimal data focused on functionality being tested
@@ -138,6 +150,25 @@ describe('useAssetBrowser', () => {
})
})
it('strips the model_type: prefix from the badge when the flag is on', () => {
mockSupportsModelTypeTags.value = true
const apiAsset = createApiAsset({
tags: ['models', 'model_type:checkpoints', 'sdxl']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
expect(result.badges).not.toContainEqual({
label: 'model_type:checkpoints',
type: 'type'
})
})
it('handles tags with multiple slashes in badges', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoint/subfolder/model-name']
@@ -668,6 +699,34 @@ describe('useAssetBrowser', () => {
])
})
it('groups by model_type:* value and ignores other tags when the flag is on', () => {
mockSupportsModelTypeTags.value = true
const assets = [
createApiAsset({ tags: ['models', 'model_type:checkpoints', 'sdxl'] }),
createApiAsset({ tags: ['models', 'model_type:LLM'] })
]
const { navItems } = useAssetBrowser(ref(assets))
const typeGroup = navItems.value[2] as { items: { id: string }[] }
expect(typeGroup.items.map((i) => i.id)).toEqual(['LLM', 'checkpoints'])
})
it('ignores model_type: and groups by bare tags when the flag is off', () => {
const assets = [
createApiAsset({ tags: ['models', 'model_type:checkpoints'] }),
createApiAsset({ tags: ['models', 'model_type:LLM'] })
]
const { navItems } = useAssetBrowser(ref(assets))
const typeGroup = navItems.value[2] as { items: { id: string }[] }
expect(typeGroup.items.map((i) => i.id)).toEqual([
'model_type:LLM',
'model_type:checkpoints'
])
})
it('handles assets with no category tag', () => {
const assets = [
createApiAsset({ tags: ['models'] }), // No second tag

View File

@@ -19,10 +19,13 @@ import {
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetFilename
getAssetCategories,
getAssetFilename,
getAssetTypeBadges
} from '@/platform/assets/utils/assetMetadataUtils'
import { MODELS_TAG } from '@/platform/assets/services/assetService'
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -43,18 +46,19 @@ export interface AssetDisplayItem extends AssetItem {
}
}
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
const displayItemCache = new WeakMap<
AssetItem,
{ modelTypeMode: boolean; item: AssetDisplayItem }
>()
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
function buildDisplayItem(
asset: AssetItem,
modelTypeMode: boolean
): AssetDisplayItem {
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
if (typeTag) {
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
for (const typeBadge of getAssetTypeBadges(asset, modelTypeMode)) {
badges.push({ label: typeBadge, type: 'type' })
}
for (const model of getAssetBaseModels(asset)) {
@@ -75,12 +79,15 @@ function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
}
}
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
function transformAssetForDisplay(
asset: AssetItem,
modelTypeMode: boolean
): AssetDisplayItem {
const cached = displayItemCache.get(asset)
if (cached) return cached
const built = buildDisplayItem(asset)
displayItemCache.set(asset, built)
return built
if (cached && cached.modelTypeMode === modelTypeMode) return cached.item
const item = buildDisplayItem(asset, modelTypeMode)
displayItemCache.set(asset, { modelTypeMode, item })
return item
}
/**
@@ -93,6 +100,7 @@ export function useAssetBrowser(
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
const assetDownloadStore = useAssetDownloadStore()
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
const { flags } = useFeatureFlags()
// State
const searchQuery = ref('')
@@ -122,12 +130,10 @@ export function useAssetBrowser(
})
const typeCategories = computed<NavItemData[]>(() => {
const modelTypeMode = flags.supportsModelTypeTags
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))
.flatMap((asset) =>
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
)
.map((tag) => tag.split('/')[0])
.flatMap((asset) => getAssetCategories(asset, modelTypeMode))
return Array.from(new Set(categories))
.sort()
@@ -191,7 +197,9 @@ export function useAssetBrowser(
// Category-filtered assets for filter options (before search/format/base model filters)
const categoryFilteredAssets = computed(() => {
return assets.value.filter(filterByCategory(selectedCategory.value))
return assets.value.filter(
filterByCategory(selectedCategory.value, flags.supportsModelTypeTags)
)
})
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
@@ -248,7 +256,10 @@ export function useAssetBrowser(
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
// Transform to display format
return sortedAssets.map(transformAssetForDisplay)
const modelTypeMode = flags.supportsModelTypeTags
return sortedAssets.map((asset) =>
transformAssetForDisplay(asset, modelTypeMode)
)
})
function updateFilters(newFilters: AssetFilterState) {

View File

@@ -38,7 +38,10 @@ vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path: string) => path)
apiURL: vi.fn((path: string) => path),
getServerFeature: vi.fn(
(_name: string, defaultValue?: unknown) => defaultValue
)
}
}))
@@ -279,6 +282,43 @@ describe('useUploadModelWizard', () => {
expect(result?.modelType).toBe('checkpoints')
})
it('namespaces the tag but keeps user_metadata.model_type bare when the backend supports it', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const { api } = await import('@/scripts/api')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'model_type:checkpoints']
}
})
vi.mocked(api.getServerFeature).mockImplementation((name, defaultValue) =>
name === 'supports_model_type_tags' ? true : defaultValue
)
try {
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'checkpoints'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.uploadModel()
const uploadArg = vi.mocked(assetService.uploadAssetAsync).mock
.calls[0][0]
expect(uploadArg.tags).toEqual(['models', 'model_type:checkpoints'])
expect(uploadArg.user_metadata?.model_type).toBe('checkpoints')
// The namespaced returned tag must not trip the required-type guard.
expect(wizard.uploadTypeMismatch.value).toBeNull()
} finally {
vi.mocked(api.getServerFeature).mockImplementation(
(_name, defaultValue) => defaultValue
)
}
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -347,6 +387,65 @@ describe('useUploadModelWizard', () => {
})
})
it('treats a namespaced model_type: tag as satisfying the required type', 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', 'model_type:checkpoints']
}
})
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).not.toBeNull()
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
it('strips the model_type: prefix from the imported-type label on a real mismatch', 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', 'model_type: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.uploadTypeMismatch.value).toEqual({
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA',
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
})
})
it('does not block sync imports as mismatches without a required model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')

View File

@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
@@ -11,7 +12,11 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import {
getAssetFilename,
stripModelTypePrefix,
toModelTypeTag
} from '@/platform/assets/utils/assetMetadataUtils'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -68,6 +73,7 @@ export function useUploadModelWizard(
options: UploadModelWizardOptions = {}
) {
const { t } = useI18n()
const { flags } = useFeatureFlags()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
@@ -271,19 +277,22 @@ export function useUploadModelWizard(
}
function getImportedModelType(asset: AssetItem): string | undefined {
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_TAG &&
const subtypeTags = asset.tags
.filter((tag) => tag !== MODEL_ROOT_TAG)
.map(stripModelTypePrefix)
return (
subtypeTags.find((tag) =>
modelTypes.value.some((type) => type.value === tag)
) ?? subtypeTags[0]
)
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
}
function blockMismatchedImportedModel(
asset: AssetItem,
requiredType: string
): boolean {
if (asset.tags.includes(requiredType)) return false
if (asset.tags.map(stripModelTypePrefix).includes(requiredType))
return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
@@ -317,7 +326,11 @@ export function useUploadModelWizard(
try {
const modelType = resolvedModelType.value
const tags = modelType ? ['models', modelType] : ['models']
const subtypeTag =
modelType && flags.supportsModelTypeTags
? toModelTypeTag(modelType)
: modelType
const tags = subtypeTag ? [MODEL_ROOT_TAG, subtypeTag] : [MODEL_ROOT_TAG]
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||

View File

@@ -11,6 +11,8 @@ const zAsset = z.object({
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
/** Path within the model's category folder, i.e. the value a loader widget expects. */
loader_path: z.string().nullish(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
@@ -27,11 +29,6 @@ const zAssetResponse = zListAssetsResponse
assets: z.array(zAsset)
})
const zModelFolder = z.object({
name: z.string(),
folders: z.array(z.string())
})
// Zod schema for ModelFile to align with interface
const zModelFile = z.object({
name: z.string(),
@@ -100,7 +97,6 @@ export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
@@ -132,4 +128,10 @@ export type TagsOperationResult = z.infer<typeof tagsOperationResultSchema>
export interface ModelFolderInfo {
name: string
folders: string[]
/**
* The folder's raw registered extension allowlist from
* `/experiment/models`. An empty array means match-all; absent on older
* backends.
*/
extensions?: string[]
}

View File

@@ -12,6 +12,7 @@ import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -19,6 +20,16 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
}
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
@@ -40,7 +51,9 @@ vi.mock('@/stores/modelToNodeStore', () => {
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
fetchApi: vi.fn(),
addCustomEventListener: vi.fn(),
removeCustomEventListener: vi.fn()
}
}))
@@ -87,6 +100,7 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'model.safetensors',
loader_path: overrides.name ?? 'model.safetensors',
tags: ['models'],
...overrides
}
@@ -416,32 +430,329 @@ describe(assetService.deleteAsset, () => {
})
})
describe(assetService.getAssetModelFolders, () => {
describe(assetService.getAssetModels, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateModelBuckets()
mockSupportsModelTypeTags.value = true
})
it('walks the models tag once, excluding missing assets', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'model_type:checkpoints'] })
])
)
await assetService.getAssetModels('checkpoints')
expect(fetchApiMock).toHaveBeenCalledTimes(1)
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('buckets by bare tags when model_type tags are unsupported', async () => {
mockSupportsModelTypeTags.value = false
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'a',
name: 'a.safetensors',
tags: ['models', 'checkpoints']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([{ name: 'a.safetensors', pathIndex: 0 }])
})
it('drops uncategorized model assets with a warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'ok',
name: 'ok.safetensors',
tags: ['models', 'model_type:loras']
}),
validAsset({
id: 'uncat',
name: 'orphan.safetensors',
tags: ['models']
})
])
)
const loras = await assetService.getAssetModels('loras')
expect(loras).toEqual([{ name: 'ok.safetensors', pathIndex: 0 }])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('orphan.safetensors')
)
warn.mockRestore()
})
it('maps loader_path and drops unloadable assets without one', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'nested',
name: 'model.safetensors',
loader_path: 'sdxl/model.safetensors',
tags: ['models', 'model_type:checkpoints']
}),
validAsset({
id: 'orphan',
name: 'orphan.safetensors',
loader_path: null,
tags: ['models', 'model_type:checkpoints']
}),
validAsset({
id: 'other-folder',
name: 'lora.safetensors',
tags: ['models', 'model_type:loras']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([{ name: 'sdxl/model.safetensors', pathIndex: 0 }])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('orphan.safetensors')
)
warn.mockRestore()
})
it('drops assets whose loader path is traversal-shaped', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'evil',
name: 'evil.safetensors',
loader_path: '../../secrets/evil.safetensors',
tags: ['models', 'model_type:checkpoints']
}),
validAsset({
id: 'ok',
name: 'fine.safetensors',
tags: ['models', 'model_type:checkpoints']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([{ name: 'fine.safetensors', pathIndex: 0 }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unsafe'))
warn.mockRestore()
})
it('groups slashed bare tags by their top-level segment', async () => {
mockSupportsModelTypeTags.value = false
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'slashed',
name: 'model1.safetensors',
tags: ['models', 'Chatterbox/subfolder1/model1']
})
])
)
const models = await assetService.getAssetModels('Chatterbox')
expect(models).toEqual([{ name: 'model1.safetensors', pathIndex: 0 }])
})
it('falls back to filename metadata then name on bare-tag backends', async () => {
mockSupportsModelTypeTags.value = false
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'cloud-hash',
name: 'blake3-content-hash',
loader_path: null,
user_metadata: { filename: 'sdxl/cloud-model.safetensors' },
tags: ['models', 'checkpoints']
}),
validAsset({
id: 'bare',
name: 'plain.safetensors',
loader_path: null,
tags: ['models', 'checkpoints']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([
{ name: 'sdxl/cloud-model.safetensors', pathIndex: 0 },
{ name: 'plain.safetensors', pathIndex: 0 }
])
})
it('orders each folder subdirectories-first then files, alphabetically', async () => {
const checkpointAsset = (id: string, loaderPath: string) =>
validAsset({
id,
name: loaderPath.split('/').pop()!,
loader_path: loaderPath,
tags: ['models', 'model_type:checkpoints']
})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
checkpointAsset('1', 'sdxl/base.safetensors'),
checkpointAsset('2', 'v1-5.safetensors'),
checkpointAsset('3', 'sdxl/refiner.safetensors'),
checkpointAsset('4', 'anything.safetensors'),
checkpointAsset('5', 'dynamicrafter/model.safetensors')
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models.map((m) => m.name)).toEqual([
'dynamicrafter/model.safetensors',
'sdxl/base.safetensors',
'sdxl/refiner.safetensors',
'anything.safetensors',
'v1-5.safetensors'
])
})
it('does not let a stale in-flight walk overwrite an invalidated cache', async () => {
let resolveStaleWalk!: (response: Response) => void
fetchApiMock.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveStaleWalk = resolve
})
)
const staleRead = assetService.getAssetModels('checkpoints')
assetService.invalidateModelBuckets()
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'fresh',
name: 'fresh.safetensors',
tags: ['models', 'model_type:checkpoints']
})
])
)
const freshModels = await assetService.getAssetModels('checkpoints')
expect(freshModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
resolveStaleWalk(
buildAssetListResponse([
validAsset({
id: 'stale',
name: 'stale.safetensors',
tags: ['models', 'model_type:checkpoints']
})
])
)
await staleRead
const cachedModels = await assetService.getAssetModels('checkpoints')
expect(cachedModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('places multi-category assets in every folder from a single walk', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'shared',
name: 'dual_use.safetensors',
loader_path: 'dual_use.safetensors',
tags: [
'models',
'model_type:checkpoints',
'model_type:diffusion_models'
]
})
])
)
const checkpoints = await assetService.getAssetModels('checkpoints')
const diffusion = await assetService.getAssetModels('diffusion_models')
expect(checkpoints).toEqual([
{ name: 'dual_use.safetensors', pathIndex: 0 }
])
expect(diffusion).toEqual([{ name: 'dual_use.safetensors', pathIndex: 0 }])
// Both folder reads resolve from a single memoized models walk.
expect(fetchApiMock).toHaveBeenCalledTimes(1)
})
})
describe(assetService.onModelsScanned, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
it('invokes the callback when the scan event fires and unsubscribes cleanly', () => {
const callback = vi.fn()
const unsubscribe = assetService.onModelsScanned(callback)
const [eventType, handler] = vi.mocked(api.addCustomEventListener).mock
.calls[0]!
expect(eventType).toBe('assets.seed.fast_complete')
handler!(new CustomEvent(eventType))
expect(callback).toHaveBeenCalledOnce()
unsubscribe()
expect(api.removeCustomEventListener).toHaveBeenCalledWith(
eventType,
handler
)
})
})
describe(assetService.seedModelAssets, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('POSTs the models root to the seed endpoint', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
buildResponse({ status: 'started' }, { status: 202 })
)
const folders = await assetService.getAssetModelFolders()
await assetService.seedModelAssets()
expect(folders).toEqual([
{ name: 'checkpoints', folders: [] },
{ name: 'loras', folders: [] }
])
expect(fetchApiMock).toHaveBeenCalledWith('/assets/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: ['models'] })
})
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.has('include_public')).toBe(false)
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
it('treats an already-running scan (409) as success', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ status: 'already_running' }, { ok: false, status: 409 })
)
await expect(assetService.seedModelAssets()).resolves.toBeUndefined()
})
it('throws on other error statuses', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({}, { ok: false, status: 500 })
)
await expect(assetService.seedModelAssets()).rejects.toThrow('500')
})
})

View File

@@ -1,9 +1,11 @@
import { fromZodError } from 'zod-validation-error'
import { z } from 'zod'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import {
assetFilenameSchema,
assetItemSchema,
assetResponseSchema,
asyncUploadResponseSchema,
@@ -17,9 +19,9 @@ import type {
AssetUpdatePayload,
AsyncUploadResponse,
ModelFile,
ModelFolder,
TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -180,6 +182,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
}
const ASSETS_ENDPOINT = '/assets'
const ASSETS_SEED_ENDPOINT = '/assets/seed'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
@@ -187,6 +190,8 @@ const DEFAULT_LIMIT = 500
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Prefix for the namespaced tag that carries a model's folder category, e.g. `model_type:checkpoints`. */
const MODEL_TYPE_TAG_PREFIX = 'model_type:'
export const INPUT_TAG = 'input'
export const OUTPUT_TAG = 'output'
/** Asset tag used by the backend for placeholder records that are not installed. */
@@ -209,6 +214,48 @@ function normalizeAssetTags(tags: string[]): string[] {
return tags.map((tag) => tag.trim()).filter(Boolean)
}
/**
* Resolves the model folder a tag represents, or undefined when the tag is not
* a folder category. `supports_model_type_tags` backends carry the category as
* a namespaced `model_type:<folder>` tag; older backends mint bare tags, which
* may carry subfolder paths (e.g. `Chatterbox/sub/model`) and group by their
* top-level segment, matching the asset browser's legacy grouping.
*/
function modelFolderFromTag(
tag: string,
modelTypeMode: boolean
): string | undefined {
if (modelTypeMode) {
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
: undefined
}
if (tag === MODELS_TAG || tag.length === 0) return undefined
return tag.split('/')[0]
}
/**
* Orders loader paths as subdirectories before files at every level,
* alphabetical within each group. The asset API returns models in storage
* order, which would otherwise interleave root-level files with folder
* contents in the sidebar tree.
*/
function compareLoaderPaths(a: string, b: string): number {
const aSegments = a.split('/')
const bSegments = b.split('/')
const sharedDepth = Math.min(aSegments.length, bSegments.length)
for (let i = 0; i < sharedDepth; i++) {
const aIsFile = i === aSegments.length - 1
const bIsFile = i === bSegments.length - 1
if (aIsFile !== bIsFile) return aIsFile ? 1 : -1
const order = aSegments[i].localeCompare(bSegments[i], undefined, {
numeric: true
})
if (order !== 0) return order
}
return 0
}
async function withCallerAbort<T>(
promise: Promise<T>,
signal?: AbortSignal
@@ -269,6 +316,26 @@ function createAssetService() {
let inputAssetsIncludingPublicRequestId = 0
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
/**
* Model assets bucketed by folder category, built from a single walk of the
* `models` tag rather than a fetch per category. Shared by the folder list
* and per-folder listings so the sidebar loads every model in one pass.
*/
let modelBuckets: Map<string, AssetItem[]> | null = null
let modelBucketsRequestId = 0
let pendingModelBuckets: Promise<Map<string, AssetItem[]>> | null = null
/**
* Discards the cached model buckets so the next read re-walks the models
* tag. Bumping the request id keeps a walk that was already in flight from
* repopulating the cache with pre-invalidation data.
*/
function invalidateModelBuckets(): void {
modelBucketsRequestId++
modelBuckets = null
pendingModelBuckets = null
}
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
function invalidateInputAssetsIncludingPublic(): void {
inputAssetsIncludingPublicRequestId++
@@ -330,51 +397,156 @@ function createAssetService() {
return validateAssetResponse(data)
}
/**
* Gets a list of model folder keys from the asset API
*
* Logic:
* 1. Extract directory names directly from asset tags
* 2. Filter out blacklisted directories
* 3. Return alphabetically sorted directories with assets
*
* @returns The list of model folder keys
* Walks every `models`-tagged asset once and buckets each into the folder
* categories carried by its `model_type:` tags. A single asset lands in every
* category it is tagged with (e.g. a shared-root model in both `checkpoints`
* and `diffusion_models`). Which folders are actually shown is decided by
* `/experiment/models`; models with no category tag are dropped with a warning
* rather than hidden silently.
*/
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
{ includeTags: [MODELS_TAG] },
'model folders'
)
async function buildModelBuckets(): Promise<Map<string, AssetItem[]>> {
const assets = await getAllAssetsByTag(MODELS_TAG, true)
const modelTypeMode = useFeatureFlags().flags.supportsModelTypeTags
const buckets = new Map<string, AssetItem[]>()
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
for (const asset of assets) {
const folders = asset.tags
.map((tag) => modelFolderFromTag(tag, modelTypeMode))
.filter((folder): folder is string => folder !== undefined)
const folderTags = data.assets
.flatMap((asset) => asset.tags)
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
const discoveredFolders = new Set<string>(folderTags)
if (folders.length === 0) {
console.warn(
`Asset ${asset.id} (${asset.name}) is tagged '${MODELS_TAG}' but has no model category; skipping.`
)
continue
}
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] }))
// On loader_path-contract backends a null loader_path marks an
// unloadable asset (e.g. an orphan): it must not mint a widget value,
// and `name` is deprecated for path semantics.
if (modelTypeMode && !asset.loader_path) {
console.warn(
`Asset ${asset.id} (${asset.name}) has no loader_path; skipping.`
)
continue
}
// The loader value flows into viewMetadata URLs and widget values, so a
// traversal-shaped path must not pass through even if the backend's own
// validation ever regresses.
const loaderValue = asset.loader_path ?? getAssetFilename(asset)
if (!assetFilenameSchema.safeParse(loaderValue).success) {
console.warn(
`Asset ${asset.id} (${asset.name}) has an unsafe loader path ('${loaderValue}'); skipping.`
)
continue
}
for (const folder of folders) {
const bucket = buckets.get(folder)
if (bucket) bucket.push(asset)
else buckets.set(folder, [asset])
}
}
for (const bucket of buckets.values()) {
bucket.sort((a, b) =>
compareLoaderPaths(
a.loader_path ?? getAssetFilename(a),
b.loader_path ?? getAssetFilename(b)
)
)
}
return buckets
}
/** Returns the memoized model buckets, walking the models tag on first read. */
async function loadModelBuckets(): Promise<Map<string, AssetItem[]>> {
if (modelBuckets) return modelBuckets
if (pendingModelBuckets) return pendingModelBuckets
const requestId = ++modelBucketsRequestId
const walk = async () => {
try {
const buckets = await buildModelBuckets()
if (requestId === modelBucketsRequestId) {
modelBuckets = buckets
}
return buckets
} finally {
if (requestId === modelBucketsRequestId) {
pendingModelBuckets = null
}
}
}
pendingModelBuckets = walk()
return pendingModelBuckets
}
/**
* Gets a list of models in the specified folder from the asset API
* Gets the models in the specified folder from the single models walk.
* @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const data = await handleAssetRequest(
{ includeTags: [MODELS_TAG, folder] },
`models for ${folder}`
)
return data.assets.map((asset) => ({
name: asset.name,
const buckets = await loadModelBuckets()
return (buckets.get(folder) ?? []).map((asset) => ({
// `loader_path` is the category-relative path the loader widget expects
// and the source for the sidebar tree. Backends that predate it (bare-tag
// mode; today's cloud) fall back to the filename metadata — the same
// value the asset browser serializes — rather than `name`, which is a
// content hash on cloud.
name: asset.loader_path ?? getAssetFilename(asset),
// Asset records carry no root identity, so every model reports root 0.
// Known limitation on multi-root categories (extra_model_paths.yaml):
// preview reads target root 0 (wrong file or 404 for secondary-root
// files), and same-relative-path files in different roots collapse
// onto one sidebar row. Metadata is unaffected unless relative paths
// collide (/view_metadata searches roots in order without an index),
// as are loader widget values; lifting this needs the backend to carry
// root identity on assets.
pathIndex: 0
}))
}
/**
* Asks the backend to rescan the model roots on disk so newly added files
* become assets. Fire-and-forget: the scan's fast (insert) phase already
* writes the category tags and filenames the sidebar needs and is announced
* by an `assets.seed.fast_complete` websocket event. A 409 means a scan is
* already running, which will emit the same event, so it is not an error.
*/
async function seedModelAssets(): Promise<void> {
const res = await api.fetchApi(ASSETS_SEED_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: ['models'] })
})
if (!res.ok && res.status !== 409) {
throw new Error(
`Unable to start asset scan: Server returned ${res.status}`
)
}
}
/**
* Subscribes to the backend's scan fast-phase completion broadcast — the
* moment newly scanned files' tags and loader paths become queryable. The
* wire-level event (`assets.seed.fast_complete`) is owned here; consumers
* receive a callback and an unsubscribe function.
*/
function onModelsScanned(callback: () => void | Promise<void>): () => void {
const handler = () => {
void callback()
}
api.addCustomEventListener('assets.seed.fast_complete', handler)
return () => {
api.removeCustomEventListener('assets.seed.fast_complete', handler)
}
}
/**
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
*
@@ -971,8 +1143,10 @@ function createAssetService() {
}
return {
getAssetModelFolders,
getAssetModels,
invalidateModelBuckets,
onModelsScanned,
seedModelAssets,
isAssetAPIEnabled,
isAssetBrowserEligible,
shouldUseAssetBrowser,

View File

@@ -45,7 +45,7 @@ describe('assetFilterUtils properties', () => {
it('filterByCategory("all") accepts every asset', () => {
fc.assert(
fc.property(arbAssetItem, (asset) => {
expect(filterByCategory('all')(asset)).toBe(true)
expect(filterByCategory('all', false)(asset)).toBe(true)
})
)
})
@@ -56,7 +56,7 @@ describe('assetFilterUtils properties', () => {
fc.array(arbAssetItem, { maxLength: 30 }),
fc.stringMatching(/^[a-z]{1,8}$/),
(assets, category) => {
const filter = filterByCategory(category)
const filter = filterByCategory(category, false)
const result = assets.filter(filter)
expect(result.length).toBeLessThanOrEqual(assets.length)
for (const item of result) {

View File

@@ -26,19 +26,54 @@ function createAsset(
describe('filterByCategory', () => {
it.for([
{ category: 'all', tags: ['checkpoint'], expected: true },
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
{ category: 'lora', tags: ['checkpoint'], expected: false },
{ category: 'all', tags: ['checkpoint'], mode: false, expected: true },
{
category: 'checkpoint',
tags: ['checkpoint'],
mode: false,
expected: true
},
{ category: 'lora', tags: ['checkpoint'], mode: false, expected: false },
{
category: 'checkpoint',
tags: ['models', 'checkpoint/xl'],
mode: false,
expected: true
},
{ category: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
{
category: 'xl',
tags: ['models', 'checkpoint/xl'],
mode: false,
expected: false
},
{
category: 'checkpoints',
tags: ['models', 'model_type:checkpoints'],
mode: true,
expected: true
},
{
category: 'LLM',
tags: ['models', 'model_type:LLM'],
mode: true,
expected: true
},
{
category: 'sdxl',
tags: ['models', 'model_type:checkpoints', 'sdxl'],
mode: true,
expected: false
},
{
category: 'checkpoints',
tags: ['models', 'model_type:checkpoints'],
mode: false,
expected: false
}
])(
'category=$category with tags=$tags returns $expected',
({ category, tags, expected }) => {
const filter = filterByCategory(category)
'category=$category tags=$tags mode=$mode returns $expected',
({ category, tags, mode, expected }) => {
const filter = filterByCategory(category, mode)
const asset = createAsset('model.safetensors', { tags })
expect(filter(asset)).toBe(expected)
}

View File

@@ -1,21 +1,14 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
import {
getAssetBaseModels,
getAssetCategories
} from '@/platform/assets/utils/assetMetadataUtils'
export function filterByCategory(category: string) {
export function filterByCategory(category: string, modelTypeMode: boolean) {
return (asset: AssetItem) => {
if (category === 'all') return true
// Check if any tag matches the category (for exact matches)
if (asset.tags.includes(category)) return true
// Check if any tag's top-level folder matches the category
return asset.tags.some((tag) => {
if (typeof tag === 'string' && tag.includes('/')) {
return tag.split('/')[0] === category
}
return false
})
return getAssetCategories(asset, modelTypeMode).includes(category)
}
}

View File

@@ -2,22 +2,34 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import {
buildModelTypeTagUpdate,
getAssetAdditionalTags,
getAssetBaseModel,
getAssetBaseModels,
getAssetCardTitle,
getAssetCategories,
getAssetDescription,
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetNodeCategoryCandidates,
getAssetSourceUrl,
getPrimaryCategoryTag,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetTypeBadges,
getAssetUserDescription,
getEditableModelType,
getSourceName,
resolveDisplayImageDimensions
resolveDisplayImageDimensions,
stripModelTypePrefix,
toModelTypeTag
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -274,7 +286,17 @@ describe('assetMetadataUtils', () => {
tags: ['models'],
expected: null
},
{ name: 'returns null when tags empty', tags: [], expected: null }
{ name: 'returns null when tags empty', tags: [], expected: null },
{
name: 'never returns a raw model_type: literal (no round-trip into edit widgets)',
tags: ['models', 'model_type:checkpoints'],
expected: null
},
{
name: 'skips model_type: tags in favour of the bare twin',
tags: ['models', 'model_type:checkpoints', 'checkpoints'],
expected: 'checkpoints'
}
])('$name', ({ tags, expected }) => {
const asset = { ...mockAsset, tags }
expect(getAssetModelType(asset)).toBe(expected)
@@ -540,3 +562,353 @@ describe('assetMetadataUtils', () => {
})
})
})
describe('getAssetCategories', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('uses model_type:* values as the group and disregards other tags in model_type mode', () => {
expect(
getAssetCategories(
asset(['models', 'model_type:checkpoints', 'sdxl']),
true
)
).toEqual(['checkpoints'])
})
it('preserves the model_type value casing', () => {
expect(
getAssetCategories(asset(['models', 'model_type:LLM']), true)
).toEqual(['LLM'])
})
it('routes an uncovered asset by its bare tags in model_type mode', () => {
expect(getAssetCategories(asset(['models', 'checkpoints']), true)).toEqual([
'checkpoints'
])
})
it('ignores model_type: and uses bare-tag grouping when mode is off', () => {
expect(
getAssetCategories(
asset(['models', 'model_type:checkpoints', 'sdxl']),
false
)
).toEqual(['model_type:checkpoints', 'sdxl'])
})
it('never surfaces namespace residue as a category for uncovered assets in mode', () => {
expect(
getAssetCategories(asset(['models', 'model_type:', 'sdxl']), true)
).toEqual(['sdxl'])
})
})
describe('getPrimaryCategoryTag', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('uses the model_type value a covered asset groups under', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'sdxl', 'model_type:vae']), true)
).toBe('vae')
})
it('keeps the legacy verbatim tag for an uncovered hierarchical asset', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'Chatterbox/sub/model']), true)
).toBe('Chatterbox/sub/model')
})
it('skips namespace residue instead of titling off a raw model_type: tag', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'model_type:']), true)
).toBeUndefined()
})
it('returns the legacy first non-models tag when mode is off', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'model_type:vae']), false)
).toBe('model_type:vae')
})
})
describe('getAssetNodeCategoryCandidates', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('orders the most specific (deepest) tag ahead of a flat model_type value', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']),
true
)
).toEqual(['LLM/Qwen-VL/Qwen3-0.6B', 'LLM'])
})
it('strips the model_type: prefix when it is the only candidate', () => {
expect(
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), true)
).toEqual(['vae'])
})
it('keeps a model_type value ahead of an equally-deep bare tag', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:checkpoints', 'sdxl']),
true
)
).toEqual(['checkpoints', 'sdxl'])
})
it('demotes an unrelated deeper bare tag below the model_type value', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:vae', 'foo/bar']),
true
)
).toEqual(['vae', 'foo/bar'])
})
it('keeps unrelated bare tags as trailing fallbacks rather than dropping them', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL', 'foo/bar/baz']),
true
)
).toEqual(['LLM/Qwen-VL', 'LLM', 'foo/bar/baz'])
})
it('keeps a hierarchical tag intact', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'chatterbox/chatterbox_vc']),
true
)
).toEqual(['chatterbox/chatterbox_vc'])
})
it('returns no candidates when only reserved tags are present', () => {
expect(
getAssetNodeCategoryCandidates(asset(['models', 'missing']), true)
).toEqual([])
})
it('uses the first non-reserved tag verbatim when mode is off', () => {
expect(
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), false)
).toEqual(['model_type:vae'])
expect(
getAssetNodeCategoryCandidates(asset(['models', 'checkpoints']), false)
).toEqual(['checkpoints'])
})
})
describe('getAssetTypeBadges', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('strips the model_type: prefix in model_type mode (no raw leak)', () => {
expect(
getAssetTypeBadges(
asset(['models', 'model_type:checkpoints', 'sdxl']),
true
)
).toEqual(['checkpoints'])
})
it('badges the model_type value even when a bare tag comes first, matching the grouping', () => {
expect(
getAssetTypeBadges(asset(['models', 'foo', 'model_type:bar']), true)
).toEqual(['bar'])
})
it('badges every category a shared multi-type asset groups under', () => {
expect(
getAssetTypeBadges(
asset([
'models',
'model_type:checkpoints',
'model_type:diffusion_models'
]),
true
)
).toEqual(['checkpoints', 'diffusion_models'])
})
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
expect(getAssetTypeBadges(asset(['models', 'sdxl']), true)).toEqual([
'sdxl'
])
})
it('returns no badge rather than a blank one for a malformed empty model_type: tag', () => {
expect(getAssetTypeBadges(asset(['models', 'model_type:']), true)).toEqual(
[]
)
})
it('leaks the literal model_type: tag when mode is off', () => {
expect(
getAssetTypeBadges(asset(['models', 'model_type:checkpoints']), false)
).toEqual(['model_type:checkpoints'])
})
it('shows the segment after the first slash for a bare hierarchical tag', () => {
expect(
getAssetTypeBadges(asset(['models', 'checkpoint/xl']), false)
).toEqual(['xl'])
})
it('returns no badges when only the models tag is present', () => {
expect(getAssetTypeBadges(asset(['models']), true)).toEqual([])
})
})
describe('stripModelTypePrefix', () => {
it('removes the model_type: prefix when present', () => {
expect(stripModelTypePrefix('model_type:checkpoints')).toBe('checkpoints')
})
it('leaves a tag without the prefix unchanged', () => {
expect(stripModelTypePrefix('checkpoints')).toBe('checkpoints')
expect(stripModelTypePrefix('checkpoint/xl')).toBe('checkpoint/xl')
})
})
describe('toModelTypeTag', () => {
it('prefixes a folder_name with the model_type namespace', () => {
expect(toModelTypeTag('checkpoints')).toBe('model_type:checkpoints')
expect(toModelTypeTag('ultralytics_bbox')).toBe(
'model_type:ultralytics_bbox'
)
})
})
describe('getEditableModelType', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('returns the stripped model_type value in model_type mode', () => {
expect(
getEditableModelType(
asset(['models', 'checkpoints', 'model_type:checkpoints']),
true
)
).toBe('checkpoints')
})
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
expect(getEditableModelType(asset(['models', 'sam2']), true)).toBe('sam2')
})
it('uses the legacy first-non-models tag when mode is off', () => {
expect(
getEditableModelType(asset(['models', 'checkpoints', 'sdxl']), false)
).toBe('checkpoints')
})
it('returns null when only the models tag is present', () => {
expect(getEditableModelType(asset(['models']), true)).toBeNull()
})
})
describe('buildModelTypeTagUpdate', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('swaps the bare subtype tag when mode is off', () => {
expect(
buildModelTypeTagUpdate(asset(['models', 'checkpoints']), 'loras', false)
).toEqual(['models', 'loras'])
})
it('preserves user labels and swaps only the subtype tag when mode is off', () => {
expect(
buildModelTypeTagUpdate(
asset(['models', 'checkpoints', 'sdxl']),
'loras',
false
)
).toEqual(['models', 'sdxl', 'loras'])
})
it('writes only the model_type form for a covered asset, leaving the bare twin for the backend', () => {
expect(
buildModelTypeTagUpdate(
asset(['models', 'checkpoints', 'model_type:checkpoints']),
'loras',
true
)
).toEqual(['models', 'checkpoints', 'model_type:loras'])
})
it('replaces every existing model_type form for a shared-path dual-tagged asset', () => {
expect(
buildModelTypeTagUpdate(
asset([
'models',
'diffusion_models',
'model_type:diffusion_models',
'model_type:unet_gguf'
]),
'loras',
true
)
).toEqual(['models', 'diffusion_models', 'model_type:loras'])
})
it('drops the bare current type for an uncovered asset in model_type mode', () => {
expect(
buildModelTypeTagUpdate(asset(['models', 'sam2']), 'loras', true)
).toEqual(['models', 'model_type:loras'])
})
it('keeps user labels untouched in model_type mode', () => {
expect(
buildModelTypeTagUpdate(
asset(['models', 'checkpoints', 'model_type:checkpoints', 'sdxl']),
'loras',
true
)
).toEqual(['models', 'checkpoints', 'sdxl', 'model_type:loras'])
})
})
describe('reserved tag mirrors', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it("treats assetService's canonical reserved tags as reserved (locals must not drift)", () => {
expect(getAssetCategories(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
expect(
getAssetNodeCategoryCandidates(
asset([MODELS_TAG, MISSING_TAG, 'x']),
true
)
).toEqual(['x'])
expect(getAssetTypeBadges(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
expect(getAssetModelType(asset([MODELS_TAG]))).toBeNull()
})
})

View File

@@ -2,6 +2,11 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { isCivitaiUrl } from '@/utils/formatUtil'
// Reserved tag literals (mirror assetService's MODELS_TAG/MISSING_TAG). Kept
// local so this leaf util doesn't pull the heavier assetService -> i18n chain.
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
/**
* Type-safe utilities for extracting metadata from assets.
* These utilities check user_metadata first, then metadata, then fallback.
@@ -140,16 +145,236 @@ export function getSourceName(url: string): string {
return 'Source'
}
export const MODEL_TYPE_TAG_PREFIX = 'model_type:'
/**
* Extracts the model type from asset tags
* Extracts the model type from asset tags as a bare (non-namespaced) value.
* Never returns a raw `model_type:*` literal: this value feeds edit widgets
* whose save path writes tags back verbatim, so a namespaced tag leaking
* through here would round-trip the prefixed literal into the tag set.
* @param asset - The asset to extract model type from
* @returns The model type string or null if not present
*/
export function getAssetModelType(asset: AssetItem): string | null {
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
const typeTag = asset.tags?.find(
(tag) => tag && tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
return typeTag ?? null
}
/**
* Builds the namespaced subtype tag the backend stores in `model_type:` mode.
* The argument is a discovery folder_name (e.g. `checkpoints`,
* `ultralytics_bbox`); the backend keeps the bare directory-path twin in sync.
*/
export function toModelTypeTag(folderName: string): string {
return `${MODEL_TYPE_TAG_PREFIX}${folderName}`
}
/** Strips the `model_type:` prefix off each namespaced tag, dropping non-`model_type:` tags. */
function getModelTypeTagValues(asset: AssetItem): string[] {
return asset.tags
.filter((tag) => tag.startsWith(MODEL_TYPE_TAG_PREFIX))
.map((tag) => tag.slice(MODEL_TYPE_TAG_PREFIX.length))
.filter((tag) => tag.length > 0)
}
/**
* Resolves the folder_name shown as the asset's current model type in the edit
* dropdown. In `modelTypeMode` the stripped `model_type:` value is authoritative
* (covered assets); an uncovered asset with no `model_type:` tag falls back to
* its bare subtype tag, mirroring the read-side grouping. Outside the mode this
* is the legacy first-non-`models` tag.
*/
export function getEditableModelType(
asset: AssetItem,
modelTypeMode: boolean
): string | null {
if (modelTypeMode) {
const [modelType] = getModelTypeTagValues(asset)
if (modelType) return modelType
}
return getAssetModelType(asset)
}
/**
* Computes the tag set for re-typing a model asset to `newFolderName`. In
* `modelTypeMode` only the `model_type:` form is written — the backend keeps the
* bare directory-path twin in sync, so existing `model_type:` tags are dropped
* (covered assets) or the bare current type is dropped (uncovered assets) and
* the new `model_type:<folder_name>` is added. Outside the mode it swaps the
* legacy bare subtype tag, preserving the pre-namespace behavior.
*/
export function buildModelTypeTagUpdate(
asset: AssetItem,
newFolderName: string,
modelTypeMode: boolean
): string[] {
if (!modelTypeMode) {
const currentType = getAssetModelType(asset)
return asset.tags.filter((tag) => tag !== currentType).concat(newFolderName)
}
const modelTypeTags = asset.tags.filter((tag) =>
tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
const currentBareType = getAssetModelType(asset)
const tagsToRemove =
modelTypeTags.length > 0
? new Set(modelTypeTags)
: new Set(currentBareType ? [currentBareType] : [])
return asset.tags
.filter((tag) => !tagsToRemove.has(tag))
.concat(toModelTypeTag(newFolderName))
}
/** Legacy grouping: each non-`models` tag's top-level path segment. */
function getBareTagCategories(asset: AssetItem): string[] {
return asset.tags
.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
.map((tag) => tag.split('/')[0])
}
/**
* Resolves the category keys a model asset is grouped under.
*
* `modelTypeMode` reflects whether the backend declares the `model_type:` tag
* scheme (the `supports_model_type_tags` capability). When true, an asset's
* `model_type:*` values are authoritative; an asset with no `model_type:` tag
* still routes by its bare tags. When false (the default) categories come from
* the legacy bare-tag top-level grouping and `model_type:` is ignored.
*/
export function getAssetCategories(
asset: AssetItem,
modelTypeMode: boolean
): string[] {
if (modelTypeMode) {
const modelTypes = getModelTypeTagValues(asset)
if (modelTypes.length > 0) return modelTypes
// Uncovered assets route by bare tags, but namespace residue (e.g. a
// malformed empty `model_type:`) must not surface as a raw category.
return getBareTagCategories(asset).filter(
(category) => !category.startsWith(MODEL_TYPE_TAG_PREFIX)
)
}
return getBareTagCategories(asset)
}
/**
* Resolves the primary tag a browser surface titles itself after. In
* `modelTypeMode` a covered asset uses its first `model_type:*` value — the
* key it groups under — while an uncovered asset keeps the legacy selection
* (first non-`models` tag, verbatim, hierarchical paths intact). Outside the
* mode this is exactly the legacy selection.
*/
export function getPrimaryCategoryTag(
asset: AssetItem,
modelTypeMode: boolean
): string | undefined {
if (modelTypeMode) {
const [modelType] = getModelTypeTagValues(asset)
if (modelType) return modelType
return asset.tags.find(
(tag) => tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
}
return asset.tags.find((tag) => tag !== MODELS_TAG)
}
/** Number of `parent/child` segments in a tag, used to pick the most specific. */
function pathDepth(tag: string): number {
return tag.split('/').length
}
/** Removes the `model_type:` namespace prefix from a tag when present. */
export function stripModelTypePrefix(tag: string): string {
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
: tag
}
/**
* Resolves the labels shown as an asset card's type badges.
*
* In `modelTypeMode` a covered asset badges every `model_type:*` value — the
* same keys it groups under (`getAssetCategories`) — so a shared-root asset
* tagged with several categories carries each of them; whichever category
* view the card appears in is represented on the card. Uncovered assets (and
* legacy mode) keep the original single selection: first non-`models` tag,
* with bare hierarchical tags showing the segment after the first `/`.
*/
export function getAssetTypeBadges(
asset: AssetItem,
modelTypeMode: boolean
): string[] {
if (modelTypeMode) {
const modelTypes = getModelTypeTagValues(asset)
if (modelTypes.length > 0) return modelTypes
}
const typeTag = asset.tags.find(
(tag) =>
tag !== MODELS_TAG &&
!(modelTypeMode && tag.startsWith(MODEL_TYPE_TAG_PREFIX))
)
if (!typeTag) return []
return [
typeTag.includes('/') ? typeTag.slice(typeTag.indexOf('/') + 1) : typeTag
]
}
/**
* Ordered node-category candidates for an asset, most specific first.
*
* Callers resolve a node provider by trying each candidate in order and taking
* the first that maps to a provider. The full (possibly hierarchical) value is
* kept so `modelToNodeStore`'s `parent/child` fallback still works.
*
* In `modelTypeMode` (backend declares `supports_model_type_tags`) candidates
* come in two tiers. Tier 1: the stripped `model_type:*` values plus bare tags
* *related* to one of them (equal to it, or extending it as a `parent/child`
* path), ordered by descending depth — so a resolvable `LLM/Qwen-VL/...` twin
* wins over a flat `model_type:LLM`, while ties keep `model_type:*` values
* ahead of bare tags. Tier 2: unrelated bare tags (e.g. a user-added
* `foo/bar`), tried only when nothing authoritative resolves — they can no
* longer pre-empt a resolvable `model_type:*` value however deep they are.
* An uncovered asset (no `model_type:` tag) routes by all its bare tags,
* deepest first. Outside `modelTypeMode` the legacy first-non-reserved tag is
* used verbatim.
*/
export function getAssetNodeCategoryCandidates(
asset: AssetItem,
modelTypeMode: boolean
): string[] {
if (!modelTypeMode) {
const legacy = asset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
return legacy ? [legacy] : []
}
const bareTags = asset.tags.filter(
(tag) =>
tag !== MODELS_TAG &&
tag !== MISSING_TAG &&
!tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
const byDepthDesc = (a: string, b: string) => pathDepth(b) - pathDepth(a)
const modelTypes = getModelTypeTagValues(asset)
if (modelTypes.length === 0) return bareTags.toSorted(byDepthDesc)
const isRelated = (tag: string) =>
modelTypes.some((type) => tag === type || tag.startsWith(`${type}/`))
return [
...[...modelTypes, ...bareTags.filter(isRelated)].sort(byDepthDesc),
...bareTags.filter((tag) => !isRelated(tag)).sort(byDepthDesc)
]
}
/**
* Extracts user description from asset user_metadata
* @param asset - The asset to extract user description from

View File

@@ -1,14 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
}
}
})
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
@@ -49,6 +60,11 @@ describe('resolveModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockSupportsModelTypeTags.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('valid assets', () => {
@@ -68,6 +84,48 @@ describe('resolveModelNodeFromAsset', () => {
}
})
it('strips the model_type: prefix when resolving the provider in model_type mode', () => {
mockSupportsModelTypeTags.value = true
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
createMockAsset({ tags: ['models', 'model_type:vae'] })
)
expect(result.success).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
})
it('skips an unresolvable incidental tag and resolves via the model_type value', () => {
mockSupportsModelTypeTags.value = true
mockGetNodeProvider.mockImplementation((category: string) =>
category === 'vae' ? createMockNodeProvider() : undefined
)
const result = resolveModelNodeFromAsset(
createMockAsset({ tags: ['models', 'model_type:vae', 'foo/bar'] })
)
expect(result.success).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('foo/bar')
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
})
it('prefers the deepest resolvable path over a flat model_type value', () => {
mockSupportsModelTypeTags.value = true
mockGetNodeProvider.mockImplementation((category: string) =>
category === 'LLM/Qwen-VL/Qwen3-0.6B'
? createMockNodeProvider()
: undefined
)
const result = resolveModelNodeFromAsset(
createMockAsset({
tags: ['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']
})
)
expect(result.success).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('LLM/Qwen-VL/Qwen3-0.6B')
})
it('falls back to metadata.filename when user_metadata.filename missing', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
@@ -201,7 +259,7 @@ describe('resolveModelNodeFromAsset', () => {
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.category).toBe('checkpoints')
expect(result.error.details?.candidates).toEqual(['checkpoints'])
}
})
})

View File

@@ -4,7 +4,11 @@ import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import {
getAssetFilename,
getAssetNodeCategoryCandidates
} from '@/platform/assets/utils/assetMetadataUtils'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
@@ -81,10 +85,12 @@ export function resolveModelNodeFromAsset(
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
const { flags } = useFeatureFlags()
const candidates = getAssetNodeCategoryCandidates(
validAsset,
flags.supportsModelTypeTags
)
if (!category) {
if (candidates.length === 0) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
@@ -99,19 +105,31 @@ export function resolveModelNodeFromAsset(
}
}
const provider = useModelToNodeStore().getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
const modelToNodeStore = useModelToNodeStore()
const resolved = candidates
.map((category) => ({
category,
provider: modelToNodeStore.getNodeProvider(category)
}))
.find((candidate) => candidate.provider !== undefined)
if (!resolved?.provider) {
// Known gap (out of scope for FE-1076): flat `model_type:LLM`-style tags
// whose loaders are only registered hierarchically land here until the
// backend emits a subtype-carrying tag.
console.error(
`No node provider registered for category: ${candidates.join(', ')}`
)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${category}`,
message: `No node provider registered for category: ${candidates.join(', ')}`,
assetId: validAsset.id,
details: { category }
details: { candidates }
}
}
}
return { success: true, value: { provider, filename } }
return { success: true, value: { provider: resolved.provider, filename } }
}

View File

@@ -1,5 +1,12 @@
<template>
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<h1
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ $t('cloudOnboarding.survey.title') }}
</h1>
<DynamicSurveyForm
:key="activeSurvey.version"
:survey="activeSurvey"

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import CloudTemplate from './CloudTemplate.vue'
const renderWithMeta = async (meta: Record<string, unknown>) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'test', component: CloudTemplate, meta }]
})
await router.push('/')
await router.isReady()
return render(CloudTemplate, {
global: {
plugins: [router],
stubs: {
CloudHeroCarousel: { template: '<div data-testid="hero" />' },
CloudTemplateFooter: true
}
}
})
}
describe('CloudTemplate', () => {
it('shows the hero carousel when the route does not hide it', async () => {
await renderWithMeta({})
expect(screen.getByTestId('hero')).toBeInTheDocument()
})
it('hides the hero carousel when route.meta.hideHero is set', async () => {
await renderWithMeta({ hideHero: true })
expect(screen.queryByTestId('hero')).not.toBeInTheDocument()
})
})

View File

@@ -13,15 +13,22 @@
</div>
<CloudTemplateFooter />
</div>
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
<div
v-if="!route.meta.hideHero"
class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block"
>
<CloudHeroCarousel />
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
const route = useRoute()
</script>
<style>
@import '../assets/css/fonts.css';

View File

@@ -5,20 +5,22 @@
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-sm text-gray-600 no-underline"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-sm text-gray-600 no-underline"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
>
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
class="cursor-pointer text-sm text-gray-600 no-underline"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -94,7 +94,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
name: 'cloud-survey',
component: () =>
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true }
meta: { requiresAuth: true, hideHero: true }
},
{
path: 'oauth/consent',
@@ -106,7 +106,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
name: 'cloud-user-check',
component: () =>
import('@/platform/cloud/onboarding/UserCheckView.vue'),
meta: { requiresAuth: true }
meta: { requiresAuth: true, hideHero: true }
},
{
path: 'sorry-contact-support',

View File

@@ -0,0 +1,176 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { OnboardingSurveyField } from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
const renderField = (
field: OnboardingSurveyField,
props: {
modelValue?: string | string[]
otherValue?: string
errorMessage?: string
} = {},
locale = 'en'
) =>
render(DynamicSurveyField, {
global: {
plugins: [
createI18n({ legacy: false, locale, messages: { en: enMessages } })
]
},
props: { field, modelValue: undefined, ...props }
})
const optionButton = (label: string) =>
screen.getByRole('button', { name: label })
describe('DynamicSurveyField', () => {
const singleField: OnboardingSurveyField = {
id: 'intent',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'images', label: 'Images', icon: 'icon-[lucide--image]' },
{ value: 'video', label: 'Video' }
]
}
it('renders the label and one card per option', () => {
renderField(singleField)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Images')).toBeInTheDocument()
expect(screen.getByText('Video')).toBeInTheDocument()
})
it('emits the chosen value for a single-select card', async () => {
const user = userEvent.setup()
const { emitted } = renderField(singleField)
await user.click(screen.getByText('Images'))
expect(emitted()['update:modelValue']?.[0]).toEqual(['images'])
})
it('marks the selected single card as on (aria-pressed/state)', () => {
renderField(singleField, { modelValue: 'images' })
expect(optionButton('Images')).toHaveAttribute('data-state', 'on')
expect(optionButton('Video')).toHaveAttribute('data-state', 'off')
})
it('gives each option card a stable "<fieldId>-<value>" id', () => {
renderField(singleField)
expect(optionButton('Images')).toHaveAttribute('id', 'intent-images')
expect(optionButton('Video')).toHaveAttribute('id', 'intent-video')
})
const multiField: OnboardingSurveyField = {
id: 'making',
type: 'multi',
label: 'Pick some',
required: true,
options: [
{ value: 'a', label: 'Making A' },
{ value: 'b', label: 'Making B' }
]
}
it('emits an array for a multi-select card and reflects current selection', async () => {
const user = userEvent.setup()
const { emitted } = renderField(multiField, { modelValue: ['a'] })
expect(optionButton('Making A')).toHaveAttribute('data-state', 'on')
await user.click(screen.getByText('Making B'))
const events = emitted()['update:modelValue'] as unknown[][] | undefined
const last = events?.at(-1)?.[0]
expect(last).toEqual(expect.arrayContaining(['a', 'b']))
})
it('shows the "other" free-text input only when "other" is selected and emits it', async () => {
const user = userEvent.setup()
const otherField: OnboardingSurveyField = {
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
const { rerender, emitted } = renderField(otherField, {
modelValue: 'search'
})
expect(
screen.queryByPlaceholderText('Where did you find us?')
).not.toBeInTheDocument()
await rerender({ field: otherField, modelValue: 'other', otherValue: '' })
const input = screen.getByPlaceholderText('Where did you find us?')
await user.type(input, 'A podcast')
expect(emitted()['update:otherValue']?.length).toBeGreaterThan(0)
})
it('renders a text field and emits typed input', async () => {
const user = userEvent.setup()
const textField: OnboardingSurveyField = {
id: 'note',
type: 'text',
label: 'Anything else?',
placeholder: 'Your note'
}
const { emitted } = renderField(textField)
await user.type(screen.getByPlaceholderText('Your note'), 'Hi')
expect(emitted()['update:modelValue']?.length).toBeGreaterThan(0)
})
it('resolves labels via labelKey, locale map, and falls back to the value', () => {
renderField(
{
id: 'q',
type: 'single',
labelKey: 'cloudSurvey_steps_intent',
options: [
{ value: 'x', label: { en: 'Ex', ko: '엑스' } },
{ value: 'raw' } // no label → falls back to the value
]
},
{}
)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Ex')).toBeInTheDocument()
expect(screen.getByText('raw')).toBeInTheDocument()
})
it('resolves a field label from a locale map when no labelKey is set', () => {
renderField({
id: 'q',
type: 'single',
label: { en: 'Server question', ko: '서버 질문' },
options: [{ value: 'a', label: 'A' }]
})
expect(screen.getByText('Server question')).toBeVisible()
})
it('falls back to the field id when neither labelKey nor label resolves', () => {
renderField({
id: 'bare_field_id',
type: 'single',
options: [{ value: 'a', label: 'A' }]
})
expect(screen.getByText('bare_field_id')).toBeVisible()
})
it('renders the error message when provided', () => {
renderField(singleField, { errorMessage: 'Please choose an option.' })
expect(screen.getByText('Please choose an option.')).toBeVisible()
})
})

View File

@@ -2,62 +2,72 @@
<fieldset
v-if="field.type !== 'text'"
:aria-invalid="Boolean(errorMessage)"
class="flex flex-col gap-4 border-0 p-0"
class="m-0 flex flex-col gap-4 border-0 p-0"
>
<legend class="mb-2 block text-lg font-medium text-base-foreground">
<legend class="mb-2 block text-lg font-medium text-primary-comfy-canvas">
{{ resolvedLabel }}
</legend>
<template v-if="field.type === 'single'">
<div
<ToggleGroup
v-if="field.type === 'single'"
:model-value="(modelValue as string) ?? ''"
type="single"
class="flex w-full flex-col gap-2"
@update:model-value="onSingleChange"
>
<ToggleGroupItem
v-for="option in field.options"
:id="`${field.id}-${option.value}`"
:key="option.value"
class="flex items-center gap-3"
:value="option.value"
:class="optionCardClass"
>
<RadioButton
:model-value="(modelValue as string) ?? ''"
:input-id="`${field.id}-${option.value}`"
:name="field.id"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onSingleChange"
<i
v-if="option.icon"
:class="
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
"
aria-hidden="true"
/>
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<template v-else>
<div
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
<i :class="checkMarkClass" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
v-else
:model-value="(modelValue as string[]) ?? []"
type="multiple"
class="flex w-full flex-col gap-2"
@update:model-value="onMultiChange"
>
<ToggleGroupItem
v-for="option in field.options"
:id="`${field.id}-${option.value}`"
:key="option.value"
class="flex items-center gap-3"
:value="option.value"
:class="optionCardClass"
>
<Checkbox
:model-value="(modelValue as string[]) ?? []"
:input-id="`${field.id}-${option.value}`"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onMultiChange"
<i
v-if="option.icon"
:class="
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
"
aria-hidden="true"
/>
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
<i :class="checkMarkClass" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<Input
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
v-if="field.allowOther && field.otherFieldId && isOtherSelected"
:model-value="(otherValue as string) ?? ''"
:class="inputClass"
:maxlength="OTHER_TEXT_MAX_LENGTH"
:placeholder="
$t(
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
$t('cloudOnboarding.survey.otherPlaceholder')
)
"
class="ml-1"
@update:model-value="onOtherChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
@@ -65,7 +75,7 @@
<div v-else class="flex flex-col gap-3">
<label
:for="controlId"
class="block text-lg font-medium text-base-foreground"
class="block text-lg font-medium text-primary-comfy-canvas"
>
{{ resolvedLabel }}
</label>
@@ -74,6 +84,7 @@
:model-value="(modelValue as string) ?? ''"
:placeholder="field.placeholder"
:aria-invalid="Boolean(errorMessage)"
:class="inputClass"
@update:model-value="onTextChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
@@ -81,18 +92,20 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import { useId } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Input from '@/components/ui/input/Input.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type {
LocalizedString,
OnboardingSurveyField,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
import { OTHER_TEXT_MAX_LENGTH } from './surveySchema'
const {
field,
modelValue,
@@ -113,25 +126,31 @@ const emit = defineEmits<{
const { t, te, locale } = useI18n()
const controlId = useId()
const optionCardClass =
'group h-auto w-full items-center justify-start gap-3 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 px-4 py-3 text-left text-sm text-primary-comfy-canvas shadow-inset-highlight transition-colors hover:bg-sand-300/20 data-[state=on]:bg-sand-300/15 data-[state=on]:ring-1 data-[state=on]:ring-inset data-[state=on]:ring-brand-yellow'
const checkMarkClass =
'icon-[lucide--check] size-4 shrink-0 text-brand-yellow opacity-0 group-data-[state=on]:opacity-100'
const inputClass =
'border-smoke-800/10 bg-smoke-800/10 text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/50 focus-visible:ring-inset'
const isOtherSelected = computed(() =>
Array.isArray(modelValue)
? modelValue.includes('other')
: modelValue === 'other'
)
const resolveLocalized = (value: LocalizedString): string => {
if (typeof value === 'string') return value
return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? ''
}
const checkedTokens = {
checked: {
background: 'var(--color-electric-400)',
borderColor: 'var(--color-electric-400)',
hoverBackground: 'var(--color-electric-400)',
hoverBorderColor: 'var(--color-electric-400)'
}
}
const resolvedLabel = (() => {
const resolvedLabel = computed(() => {
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
if (field.label != null) return resolveLocalized(field.label)
return field.id
})()
})
const resolveOptionLabel = (option: OnboardingSurveyOption): string => {
if (option.labelKey && te(option.labelKey)) return t(option.labelKey)
@@ -143,13 +162,10 @@ const onSingleChange = (value: unknown) => {
emit('update:modelValue', typeof value === 'string' ? value : '')
}
const onMultiChange = (value: unknown) => {
if (!Array.isArray(value)) {
emit('update:modelValue', [])
return
}
const selected = Array.isArray(value) ? value : []
emit(
'update:modelValue',
value.filter((v): v is string => typeof v === 'string')
selected.filter((v): v is string => typeof v === 'string')
)
}
const onTextChange = (value: string | number | undefined) => {

View File

@@ -1,320 +1,383 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { render, screen, waitFor } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyForm from './DynamicSurveyForm.vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: 'Please choose an option.',
selectAtLeastOne: 'Please select at least one option.',
describeAnswer: 'Please describe your answer.'
}
}
}
}
}
})
import { defaultOnboardingSurvey } from './defaultSurveySchema'
const renderForm = (survey: OnboardingSurvey) =>
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, i18n] },
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
},
props: { survey }
})
const clickOption = (user: ReturnType<typeof userEvent.setup>, label: string) =>
user.click(screen.getByText(label))
const firstSubmitPayload = (
emitted: Record<string, unknown[]>
): Record<string, unknown> | undefined =>
(emitted.submit?.[0] as [Record<string, unknown>] | undefined)?.[0]
const twoStepSurvey: OnboardingSurvey = {
version: 1,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'usage',
type: 'single',
label: 'How do you plan to use ComfyUI?',
required: true,
options: [
{ value: 'personal', label: 'Personal use' },
{ value: 'work', label: 'Work' }
]
},
{
id: 'intent',
type: 'multi',
label: 'What do you want to create with ComfyUI?',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'images', label: 'Images' },
{ value: 'videos', label: 'Videos' }
{ value: 'video', label: 'Video' }
]
},
{
id: 'making',
type: 'multi',
label: 'Pick everything that applies',
required: true,
options: [
{ value: 'a', label: 'Making A' },
{ value: 'b', label: 'Making B' }
]
}
]
}
describe('DynamicSurveyForm', () => {
it('renders the intro text and the first field options', () => {
renderForm(twoStepSurvey)
const branchedSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'intent',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'workflows', label: 'Workflows' },
{ value: 'images', label: 'Images' }
]
},
{
id: 'focus',
type: 'single',
label: 'What are you building?',
required: true,
showWhen: { field: 'intent', equals: 'workflows' },
options: [{ value: 'custom_nodes', label: 'Custom nodes' }]
}
]
}
expect(
screen.getByText('Help us tailor your ComfyUI experience.')
).toBeInTheDocument()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
expect(screen.getByLabelText('Personal use')).toBeInTheDocument()
expect(screen.getByLabelText('Work')).toBeInTheDocument()
describe('DynamicSurveyForm', () => {
it('renders the real default schema (v3) with its first question and options', () => {
expect(defaultOnboardingSurvey.version).toBe(3)
const firstField = defaultOnboardingSurvey.fields[0]!
renderForm(defaultOnboardingSurvey)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Images')).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(
firstField.options!.length
)
})
it('disables Next until the user selects an option, then advances', async () => {
it('auto-advances when a single-select option is chosen', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeDisabled()
await user.click(screen.getByLabelText('Personal use'))
expect(next).toBeEnabled()
await user.click(next)
await flushPromises()
// No Next click — choosing the card advances the wizard.
await clickOption(user, 'Images')
expect(
screen.getByText('What do you want to create with ComfyUI?')
await screen.findByText('Pick everything that applies')
).toBeVisible()
expect(screen.getByLabelText('Images')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
})
it('does not auto-advance a multi-select step; Submit gates on a choice', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(submit).toBeDisabled()
await clickOption(user, 'Making A')
// Still on the multi step (no auto-advance), now submittable.
expect(screen.getByText('Pick everything that applies')).toBeVisible()
await waitFor(() => expect(submit).toBeEnabled())
})
it('navigates back to the previous step', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await user.click(screen.getByLabelText('Personal use'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
await clickOption(user, 'Images')
expect(
screen.getByText('What do you want to create with ComfyUI?')
await screen.findByText('Pick everything that applies')
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Back' }))
await flushPromises()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
expect(await screen.findByText('What do you want to make?')).toBeVisible()
})
it('resolves option and field labels via labelKey when provided', () => {
const localizedI18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
},
survey_label: 'Localized question?',
survey_a: 'Localized A',
survey_b: 'Localized B'
}
}
})
it('offers Next on an already-answered single-select reached via Back', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, localizedI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q',
type: 'single',
labelKey: 'survey_label',
required: true,
options: [
{ value: 'a', labelKey: 'survey_a' },
{ value: 'b', labelKey: 'survey_b' }
]
}
]
}
}
})
await clickOption(user, 'Images')
await screen.findByText('Pick everything that applies')
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByText('Localized question?')).toBeVisible()
expect(screen.getByLabelText('Localized A')).toBeInTheDocument()
expect(screen.getByLabelText('Localized B')).toBeInTheDocument()
const next = await screen.findByRole('button', { name: 'Next' })
await user.click(next)
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
})
it('renders server-supplied translations from a label locale map', () => {
const koreanI18n = createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
ko: { g: { back: '뒤로', next: '다음', submit: '제출' } }
}
})
it('reveals a branched follow-up step from the answer and submits it', async () => {
const user = userEvent.setup()
const { emitted } = renderForm(branchedSurvey)
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, koreanI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'usage',
type: 'single',
label: {
en: 'How will you use it?',
ko: '어떻게 사용하시겠어요?'
},
required: true,
options: [
{
value: 'personal',
label: { en: 'Personal use', ko: '개인 용도' }
},
{ value: 'work', label: { en: 'Work', ko: '업무' } }
]
}
]
}
}
})
await clickOption(user, 'Workflows')
expect(await screen.findByText('What are you building?')).toBeVisible()
expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible()
expect(screen.getByLabelText('개인 용도')).toBeInTheDocument()
expect(screen.getByLabelText('업무')).toBeInTheDocument()
await clickOption(user, 'Custom nodes')
await user.click(await screen.findByRole('button', { name: 'Submit' }))
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({
intent: 'workflows',
focus: 'custom_nodes'
})
)
})
it('falls back to English when current locale missing from label map', () => {
const fallbackI18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
fr: {}
}
})
it('hides the branched step when the answer does not match', async () => {
const user = userEvent.setup()
const { emitted } = renderForm(branchedSurvey)
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, fallbackI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q',
type: 'single',
label: { en: 'English question', ko: '한국어' },
required: true,
options: [
{ value: 'a', label: { en: 'English A', ko: '한국어 A' } }
]
}
// 'images' is the last visible step (focus hidden) → Submit, no branch.
await clickOption(user, 'Images')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(screen.queryByText('What are you building?')).not.toBeInTheDocument()
await user.click(submit)
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({
intent: 'images',
focus: ''
})
)
})
it('requires the "other" free-text before submitting, then submits it', async () => {
const user = userEvent.setup()
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
}
]
}
const { emitted } = renderForm(otherSurvey)
// Selecting 'other' must NOT auto-advance — the text box is required.
await clickOption(user, 'Somewhere else')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(submit).toBeDisabled()
await user.type(
await screen.findByPlaceholderText('Where did you find us?'),
'A newsletter'
)
await waitFor(() => expect(submit).toBeEnabled())
await user.click(submit)
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({ source: 'A newsletter' })
)
})
it('surfaces the free-text error once "other" text is touched then cleared', async () => {
const user = userEvent.setup()
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
]
}
renderForm(otherSurvey)
await clickOption(user, 'Somewhere else')
const input = await screen.findByPlaceholderText('Where did you find us?')
// Type then clear → the free-text field is touched but empty, so its
// required error surfaces.
await user.type(input, 'x')
await user.clear(input)
expect(
await screen.findByText('Please describe your answer.')
).toBeVisible()
})
it('shows a required-field error only after the user interacts, not before', async () => {
const user = userEvent.setup()
renderForm({
version: 1,
fields: [
{
id: 'making',
type: 'multi',
label: 'Pick everything that applies',
required: true,
options: [{ value: 'a', label: 'Making A' }]
}
]
})
// fr is not in the map → falls back to en
expect(screen.getByText('English question')).toBeVisible()
expect(screen.getByLabelText('English A')).toBeInTheDocument()
// No error on first render (field untouched).
expect(
screen.queryByText('Please select at least one option.')
).not.toBeInTheDocument()
// Select then clear → field is touched but empty → error surfaces.
await user.click(screen.getByText('Making A'))
await user.click(screen.getByText('Making A'))
expect(
await screen.findByText('Please select at least one option.')
).toBeVisible()
})
it('allows advancing past an optional field while still empty', async () => {
const user = userEvent.setup()
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, i18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
renderForm({
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
}
]
})
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeEnabled()
await user.click(next)
await flushPromises()
expect(screen.getByText('Required question?')).toBeVisible()
expect(await screen.findByText('Required question?')).toBeVisible()
})
it('enables Submit only after the multi-select field has at least one choice', async () => {
it('resets to the first step when the survey prop changes', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
const { rerender } = render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
},
props: { survey: twoStepSurvey }
})
await user.click(screen.getByLabelText('Work'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
await clickOption(user, 'Images')
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
const submitBtn = screen.getByRole('button', { name: 'Submit' })
expect(submitBtn).toBeDisabled()
await rerender({ survey: branchedSurvey })
// Back on step 0 of the new survey (no Back button on the first step).
expect(await screen.findByText('What do you want to make?')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Back' })
).not.toBeInTheDocument()
})
await user.click(screen.getByRole('checkbox', { name: /Images/i }))
await flushPromises()
expect(submitBtn).toBeEnabled()
it('renders server-supplied label translations and falls back to English', () => {
render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: { en: enMessages, ko: { g: { next: '다음' } } }
})
]
},
props: {
survey: {
version: 1,
fields: [
{
id: 'intent',
type: 'single',
label: { en: 'What will you make?', ko: '무엇을 만들 건가요?' },
required: true,
options: [
// ko provided → localized; ko missing → English fallback
{ value: 'images', label: { en: 'Images', ko: '이미지' } },
{ value: 'video', label: { en: 'Video' } }
]
}
]
}
}
})
expect(screen.getByText('무엇을 만들 건가요?')).toBeVisible()
expect(screen.getByText('이미지')).toBeInTheDocument()
expect(screen.getByText('Video')).toBeInTheDocument()
})
})

View File

@@ -1,109 +1,118 @@
<template>
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted">
<form class="flex w-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted-foreground">
{{ introText }}
</p>
<div
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
class="mb-8 h-1.5 w-full overflow-hidden rounded-full bg-primary-comfy-canvas/10"
>
<div
class="h-full bg-electric-400 transition-[width] duration-300 ease-out"
class="h-full bg-brand-yellow transition-[width] duration-300 ease-out"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="
errors[currentField.id] ??
(currentField.otherFieldId
? errors[currentField.otherFieldId]
: undefined)
"
@update:model-value="(value) => onFieldChange(currentField.id, value)"
@update:other-value="
(value) =>
currentField.otherFieldId &&
onFieldChange(currentField.otherFieldId, value)
"
/>
<div
class="overflow-hidden transition-[height] duration-300 ease-out"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-from-class="opacity-0"
leave-active-class="absolute inset-x-0 top-0 transition-opacity duration-300 ease-out"
leave-to-class="opacity-0"
>
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-col gap-4"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="currentError"
@update:model-value="
(value) => void onFieldChange(currentField.id, value)
"
@update:other-value="
(value) =>
currentField.otherFieldId &&
void onFieldChange(currentField.otherFieldId, value)
"
/>
</div>
</Transition>
</div>
</div>
<div class="flex gap-6 pt-4">
<div
v-if="!isFirst || showNext || isLast"
class="mt-8 flex items-center justify-between gap-4"
>
<Button
v-if="!isFirst"
type="button"
variant="secondary"
class="h-10 flex-1 text-white"
variant="link"
size="lg"
class="px-0 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
@click="goPrevious"
>
<i class="icon-[lucide--chevron-left] size-4" aria-hidden="true" />
{{ $t('g.back') }}
</Button>
<span v-else class="flex-1" />
<span v-else />
<Button
v-if="!isLast"
v-if="showNext"
type="button"
size="lg"
:disabled="!isCurrentValid"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
@click="goNext"
>
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" aria-hidden="true" />
</Button>
<Button
v-else
v-else-if="isLast"
type="submit"
size="lg"
:disabled="!isCurrentValid || isSubmitting"
:loading="isSubmitting"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid && !isSubmitting
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
>
{{ $t('g.submit') }}
</Button>
<span v-else />
</div>
</form>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useElementSize } from '@vueuse/core'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import type {
OnboardingSurvey,
OnboardingSurveyField
} from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
import {
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
hasNonEmptyValue,
isOtherValue,
prepareSurvey,
visibleFields
} from './surveySchema'
@@ -147,6 +156,8 @@ watch(
liveValues.value = { ...fresh }
resetForm({ values: fresh })
stepIndex.value = 0
touched.value = new Set()
isAdvancing.value = false
}
)
@@ -154,11 +165,43 @@ const visible = computed(() =>
visibleFields(preparedSurvey.value, values as SurveyValues)
)
const stepIndex = ref(0)
const touched = ref(new Set<string>())
const isAdvancing = ref(false)
const questionContent = useTemplateRef<HTMLElement>('questionContent')
const { height: contentHeight } = useElementSize(questionContent)
const animatedHeightStyle = computed(() =>
contentHeight.value ? { height: `${contentHeight.value}px` } : {}
)
const currentField = computed(() => visible.value[stepIndex.value])
const isFirst = computed(() => stepIndex.value === 0)
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
const showNext = computed(() => {
if (isLast.value || isAdvancing.value) return false
const field = currentField.value
if (!field) return false
if (field.type !== 'single') return true
return !(field.required && !hasNonEmptyValue(values[field.id]))
})
const currentError = computed(() => {
const field = currentField.value
if (!field) return undefined
if (touched.value.has(field.id) && errors.value[field.id]) {
return errors.value[field.id]
}
if (
field.otherFieldId &&
touched.value.has(field.otherFieldId) &&
errors.value[field.otherFieldId]
) {
return errors.value[field.otherFieldId]
}
return undefined
})
const totalSteps = computed(() => Math.max(visible.value.length, 1))
const progressPercent = computed(() =>
Math.max(
@@ -172,26 +215,41 @@ const isCurrentValid = computed(() => {
if (!field) return false
const value = values[field.id]
const isEmpty =
field.type === 'multi'
? !Array.isArray(value) || value.length === 0
: typeof value !== 'string' || value.length === 0
if (!hasNonEmptyValue(value)) return !field.required
if (isEmpty) return !field.required
if (field.allowOther && field.otherFieldId && value === 'other') {
if (field.allowOther && field.otherFieldId && isOtherValue(value)) {
const other = values[field.otherFieldId]
return typeof other === 'string' && other.trim().length > 0
}
return true
})
const onFieldChange = (id: string, value: string | string[]) => {
const isAutoAdvanceValue = (field: OnboardingSurveyField, value: unknown) =>
field.type === 'single' &&
typeof value === 'string' &&
value !== '' &&
value !== 'other'
const markTouched = (id: string) => {
touched.value = new Set(touched.value).add(id)
}
const onFieldChange = async (id: string, value: string | string[]) => {
if (isAdvancing.value) return
markTouched(id)
setFieldValue(id, value)
liveValues.value = { ...liveValues.value, [id]: value }
if (stepIndex.value > visible.value.length - 1) {
stepIndex.value = Math.max(0, visible.value.length - 1)
}
const field = currentField.value
if (field?.id === id && isAutoAdvanceValue(field, value)) {
isAdvancing.value = true
await nextTick()
goNext()
isAdvancing.value = false
}
}
const goNext = () => {
@@ -202,6 +260,11 @@ const goPrevious = () => {
}
const onSubmit = async () => {
const field = currentField.value
if (field) {
markTouched(field.id)
if (field.otherFieldId) markTouched(field.otherFieldId)
}
const result = await validate()
if (!result.valid) return
emit(

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