Compare commits

...

13 Commits

Author SHA1 Message Date
bigcat88
c3a13b5b4e Wait for interactive WebSocket session before queueing 2026-07-11 11:51:22 +03:00
bigcat88
d28875c4d8 feat: add interactive video stream widgets 2026-07-10 23:57:51 +03:00
nav-tej
df9b5bfa0a feat(website): rework MCP page setup + hero, hide promo banner on /mcp (#13514)
## Summary

Two changes to the comfy.org `/mcp` experience: reframe the Setup
section around the agent-driven install and tidy the hero CTAs, and stop
the sitewide MCP promo banner from showing on the page it links to.

## Changes

- **Setup Step 1** is now **"Ask your agent to install Comfy MCP"** with
a multi-line, copyable prompt (`Help me install Comfy MCP. / Follow the
setup guide at https://docs.comfy.org/agent-tools/cloud`).
`CopyableField` gains a `multiline` variant (wraps the text, top-aligns
the copy button); `FeatureGrid01`'s `code` action threads the flag
through.
- **Step 2** becomes the optional manual-connector path (**"Or add it by
hand"**) so the three-step flow stays coherent — no dangling "paste the
URL" that Step 1 no longer copies.
- **Hero** swaps the **"Run a workflow"** primary CTA for **"Install
MCP"**, which anchors to the on-page `#setup` steps; **"View Docs"**
stays as the secondary CTA.
- **Announcement banner** no longer advertises the page you're already
on. `evaluateBannerVisibility` gains a build-time gate: when the current
path matches the banner's CTA destination it's suppressed. Locale prefix
is stripped (so `/zh-CN/mcp` matches an unprefixed `/mcp` href),
trailing slashes are tolerated, and external CTA links never suppress.
Result: the MCP banner shows everywhere except `/mcp` and `/zh-CN/mcp`.
- New i18n keys (`mcp.setup.step1.command`, `mcp.hero.installMcp`) with
en + zh-CN parity; 5 new unit tests cover the banner suppression logic.
- **Breaking**: none.

## Review Focus

- @deepme987 @bertfy — copy + flow check. The Step 1 prompt is a
paraphrase of the `agent-tools/cloud` install guide. The raw
`cloud.comfy.org/mcp` URL is no longer surfaced on the page (the manual
path now points to the MCP docs instead) — flag if you'd rather keep the
raw URL visible in Step 2.
- Banner suppression keys off the CTA's `link.href` — general, so any
future banner pointing at an internal page auto-hides on that page. It's
a **build-time** gate (this is a static site), consistent with the
existing `startsAt`/`endsAt` behavior.
- Only the **hero** CTA changed. The "How it works" section deliberately
keeps its "Run a workflow" CTA.
- zh-CN strings are machine-drafted; a native check would be welcome.

## Screenshots

Verified locally against a production build (`astro build` + preview):

- **Hero** — `INSTALL MCP` + `VIEW DOCS` (the "Run a workflow" button is
gone). "Install MCP" scrolls to `#setup`.
- **Setup** — Step 1 shows the agent-install prompt in a multi-line
copyable field; Step 2 "Or add it by hand"; Step 3 unchanged.
- **Banner** — present on `/`, `/download`, `/cloud`; absent on `/mcp`
and `/zh-CN/mcp`.

A Vercel preview deploy attaches automatically for a live view.

---------

Co-authored-by: imick-io <153135517+imick-io@users.noreply.github.com>
2026-07-09 04:11:23 +00:00
Yousef R. Gamaleldin
a6b7ce11aa feat: add preview support for save text node (CORE:-176) (#12521)
## Summary

Add a frontend preview extension for the SaveText node that displays
saved text content after execution.

## Changes

The extension add a multiline text widget into the SaveText node and
populates it upon onExecuted
PR on core: https://github.com/Comfy-Org/ComfyUI/pull/14102

## Review Focus

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

Extension follows the same pattern as previewAny.ts

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

## Screenshots
<img width="1127" height="421" alt="Screenshot 2026-05-29 194925"
src="https://github.com/user-attachments/assets/e7a72807-858b-47c7-be07-595f9e539a49"
/>

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

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-08 23:27:32 -04:00
Matt Miller
d3b100be8d feat: render BYOK provider surface (labels / logos / help) from server data (#13509)
## ELI-5

The "API Keys & Secrets" settings screen lets you save a key for a
provider (HuggingFace, Civitai, and now video/image API providers). The
list of which providers you can pick is decided by the server. This PR
makes the picker and the saved-keys list show a nice name, logo, and
short help text for each provider the server offers — and, crucially,
actually render providers the server newly lists instead of silently
dropping them.

## What

- The provider dropdown in the add-secret dialog is now driven by the
providers the server returns from `GET /secrets/providers`. Each id is
mapped to its display label, logo, and optional help text through a
small presentational registry.
- Previously the dropdown took a hardcoded known-provider array and
*intersected* it with the server list, so any provider the server listed
that wasn't already hardcoded could never appear. That intersection is
gone: once the server list loads, it renders verbatim.
- Unknown provider ids fall back gracefully to the raw id with no logo,
so adding a provider server-side requires no frontend change; giving it
a first-class label/logo is an optional enhancement.
- The saved-keys list already resolved label/logo through the same
registry, so it picks up the new providers automatically.
- Added provider-specific help text under the picker (falls back to the
generic hint), plus placeholder logo assets under
`public/assets/images/` for the two new API providers.

## Why

Keeps the provider surface data-driven end to end: the server owns
*which* providers are configurable, and the frontend owns *how* each one
renders. This removes the last hardcoded gate that stopped server-listed
providers from showing up.

## Tests

- New `providers.test.ts`: label/logo/help lookups for all known
providers, graceful fallback for unknown ids and `undefined`, and that
the not-loaded fallback list stays the pre-existing baseline (does not
silently include the new providers).
- Extended `useSecretForm.test.ts`: server-listed providers render with
correct labels + logos; providers the server omits do not appear;
provider-specific vs generic help text selection.
- Full secrets suite green (66 tests). Changed files typecheck clean.

Note: the two new provider logos are simple placeholder SVGs and can be
swapped for final brand assets.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 03:07:47 +00:00
Hunter
54b0c10148 fix(auth): stop workspace auth from oscillating to personal identity (#13511)
## Summary

Fixes a "weird stale auth" bug where cloud requests oscillated between
workspace-scoped and personal (Firebase) identity.

**Root cause:** workspace membership lives in two decoupled places —
`teamWorkspaceStore.activeWorkspaceId` (durable intent) and
`workspaceAuthStore` (the mintable token). When the token was
transiently missing while `activeWorkspaceId` was still set (bootstrap
mint in flight, expired token, or a context cleared by a recoverable
refresh failure), `getAuthHeader`/`getAuthToken` silently downgraded to
the personal Firebase token. Depending on timing, consecutive requests
carried different identities, so the backend saw the user flip between
workspace and personal scope.

## Changes

- **On-demand recovery, fail closed:** when a workspace is active,
`getAuthHeader`/`getAuthToken` route through
`ensureWorkspaceToken(activeWorkspaceId)`, which re-mints the token on
demand and returns `null` rather than downgrading. Recovery also
revalidates expiry, so an expired token is reminted instead of sent
stale.
- **`getAuthToken` parity:** WebSocket/queue auth now recovers the same
way (previously only `getAuthHeader` did).
- **Coalescing:** a burst of callers collapses onto a single in-flight
mint (loop re-checks the in-flight promise), and only a token minted for
the requested workspace is accepted.
- **Backoff:** a 5s cooldown after any failed/empty recovery prevents
hammering `POST /auth/token`; reset on a successful mint and on context
teardown.
- **Lifecycle hygiene:** `clearWorkspaceContext()` now resets
`recoveryCooldownUntil` and `inFlightSwitchPromise` so logout/re-login
without a reload isn't wedged.
- **Transient vs permanent:** a missing Firebase ID token while the user
is still signed in (e.g. `NETWORK_REQUEST_FAILED`, which `getIdToken()`
swallows) is treated as transient, not a revoked session.
- **Revoked-workspace reconciliation:** on
`ACCESS_DENIED`/`WORKSPACE_NOT_FOUND`,
`teamWorkspaceStore.forgetRevokedActiveWorkspace()` drops the persisted
selection and reloads to fall back to the personal workspace (skipping
the personal workspace itself to avoid reload loops).
`INVALID_FIREBASE_TOKEN`/`NOT_AUTHENTICATED` do not trigger this.

## Testing

- `pnpm test:unit` for the three affected stores: **210 tests pass**.
- `pnpm lint` and `pnpm typecheck` pass locally (run with a raised Node
heap; the pre-commit/`pnpm typecheck` step OOMs in this environment, so
commits used `--no-verify` — CI should re-run the gates).

Draft pending green CI.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 02:10:13 +00:00
jaeone94
2b540a5281 fix: resolve cloud output video missing-media false positives (#13507)
## Summary

Fix Cloud missing-media false positives for output videos inserted as
loader nodes when the widget value includes a subfoldered output path
such as `video/<hash>.mp4 [output]`, while Cloud output assets expose
the generated media by a flat hash.

## Changes

- **What**: resolve Cloud output candidates with subfolders against
output asset hashes by falling back from the normalized candidate
basename only for Cloud output media.
- **What**: keep that fallback hash-only so unrelated flat output asset
names do not satisfy subfoldered candidates.
- **What**: make Cloud output pagination completion hash-aware so a
colliding `asset.name` does not stop loading before the hash match
appears.
- **What**: add unit regressions plus a small Cloud E2E fixture covering
`LoadVideo` with `video/cloud-video-hash.mp4 [output]`.
- No breaking changes or dependency changes.

## Review Focus

Root cause: Cloud output videos can be inserted as loader nodes with
widget values from workflow metadata that include a media subfolder,
e.g. `video/<hash>.mp4 [output]`. The Cloud output asset list resolves
generated media by a flat hash. The existing missing-media scan compared
the subfoldered candidate against flat asset identifiers, so valid
generated output media could be flagged as missing. Images often did not
reproduce because their metadata commonly lacked the subfolder, so the
existing exact/compact matching happened to work.

Why the fix is scoped to missing-media scan: the inserted widget value
is valid node/workflow state and may carry folder information for other
Cloud/runtime paths. Stripping the subfolder at insertion would broaden
the behavioral change to asset insertion and loader widgets.
Missing-media scan owns the decision of whether a candidate resolves to
an existing Cloud asset, so the smallest production change is to
recognize the Cloud output hash shape there.

Why this is safe for Cloud: the basename fallback is applied only for
candidates annotated as output and only in Cloud. It matches against
output asset hashes, not flat asset names, which prevents unrelated
assets named `<hash>.mp4` from masking a real miss. Input media and
non-Cloud exact path behavior continue to use the existing identifier
matching. Cloud output pagination early-exit is also hash-aware now, so
a flat name collision cannot stop paging before the real hash match is
fetched.

Red-green and validation: added the unit regression first to reproduce
`video/<hash>.mp4 [output]` as missing before the production fix, then
verified it green after the scanner/resolver change. Added a compact
Cloud E2E for the same `LoadVideo` subfoldered output case. Local
validation run:

- `pnpm test:unit src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts` - 59 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "subfoldered output video" --repeat-each=5
--reporter=line` - 5 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "resolves compact annotated output media|resolves
subfoldered output video" --reporter=line` - 2 passed
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm knip`
- `pnpm lint`
- staged pre-commit hooks: oxfmt, oxlint, eslint, typecheck,
typecheck:browser

## Screenshots (if applicable)

Before


https://github.com/user-attachments/assets/4d980546-a981-4764-9c81-4eaed69e4679


After


https://github.com/user-attachments/assets/6a4ddb26-6c16-4cbf-b7bc-b9cd55eece58
2026-07-09 00:31:25 +00:00
ShihChi Huang
51156c5503 chore: route CodeRabbit test reviews through guidance docs (#13486)
## Summary

Adds CodeRabbit `path_instructions` so changed test files are reviewed
with the repo's existing test guidance docs.

## Changes

- **What**: Routes Vitest `**/*.test.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md`.
- **What**: Routes Playwright
`{browser_tests,apps/website/e2e}/**/*.spec.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/playwright.md`.
- **What**: Routes LiteGraph Vitest files to the same Vitest docs plus
`docs/testing/litegraph-testing.md`, which now only documents shared
factory usage and preferring real LiteGraph instances where practical.

## Review Focus

Confirm the path globs cover the intended test files and that
LiteGraph-specific review guidance stays scoped to LiteGraph tests.

## Testing

- Parsed `.coderabbit.yaml` with Ruby YAML
- Ran `git diff --check`
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, and `pnpm typecheck`
- Push hook ran `knip --cache`

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation and CodeRabbit review configuration only; no runtime or
test execution behavior changes.
> 
> **Overview**
> Configures **CodeRabbit `path_instructions`** so automated reviews of
changed tests pull in the repo’s written testing standards instead of
generic heuristics.
> 
> **Vitest** (`**/*.test.ts`) reviews must treat
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md` as required context. **LiteGraph Vitest**
under `src/lib/litegraph/**/*.test.ts` adds
`docs/testing/litegraph-testing.md`. **Playwright** specs under
`browser_tests/` and `apps/website/e2e/` use the test-quality and
testing README docs plus `docs/guidance/playwright.md`.
> 
> The testing index now lists a fourth guide, **LiteGraph Testing**, and
the new `litegraph-testing.md` doc steers authors toward
`litegraphTestUtils` factories and real LiteGraph instances over broad
mocks.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efa11d080e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-09 00:03:11 +00:00
Matt Miller
e11f98b91f feat: render secrets provider dropdown from server data (#13494)
## ELI-5

The "Secrets" settings panel lets you add an API key for a provider
(HuggingFace, Civitai). Until now the list of providers in that dropdown
was hardcoded in the frontend. This PR makes the dropdown ask the server
which providers you're allowed to configure (`GET
/api/secrets/providers`) and shows that set instead. For everyone today
it looks exactly the same — the endpoint returns the same two base
providers — but it means the backend can now control the list (needed
for the upcoming bring-your-own-key providers) without a frontend
change.

## Summary

Render the Secrets provider dropdown from server data (`GET
/api/secrets/providers`) instead of the hardcoded provider list, with no
visual change for existing users.

## Changes

- **What**:
- `secretsApi.ts`: add `listSecretProviders()` → `GET
/api/secrets/providers`, returning the provider ids.
- `useSecrets`: fetch the available providers on panel mount into
`availableProviders`; failures are logged and swallowed so no new error
surfaces to users.
- `useSecretForm`: `providerOptions` is now gated by the server response
— when providers are returned, the dropdown shows exactly that set; when
the list is empty (endpoint absent/unreachable) it falls back to the
base providers, preserving today's UX.
- Thread `availableProviders` from the panel through both
`SecretFormDialog` instances.
- **Breaking**: none — panel visibility is still gated on the
`userSecretsEnabled` feature flag; only the *contents* of the provider
dropdown are now server-driven.

## Review Focus

- **Ships dark / byte-for-byte UX.** The server's base response is
exactly the two hardcoded providers, and any failure/empty response
falls back to the hardcoded list, so existing users see zero change. The
membership of the dropdown is what becomes server-driven.
- **Response types come from the generated `@comfyorg/ingest-types`.**
`SecretMetadata` aliases the generated `SecretResponse`, and
`secretsApi` consumes `SecretListResponse` / `SecretProvidersResponse`
for the list + providers envelopes — no hand-typed duplicates.
`provider` follows the schema as a free-form string; the known-provider
union stays only for the label/logo UI in `providers.ts`.
- **Provider metadata (labels/logos) still comes from `providers.ts`.**
The endpoint returns identifiers only; label/logo per provider is a
follow-up (provider surface labels/logos). So this first cut
intentionally shows base providers only — an id the server returns that
has no local label/logo config is not rendered yet.

## Test plan

- `useSecrets`: `fetchProviders` populates `availableProviders`; API
failure leaves it empty and raises no toast.
- `useSecretForm`: options restrict to the server-returned providers,
fall back to base providers when empty, and react to the list changing.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:53:10 +00:00
Dante
bab2a22428 fix(dialog): restore backdrop scrim on non-modal Reka dialogs (#13502)
## Summary

Restore the backdrop scrim on Reka dialogs opened with `modal: false`
(Settings, Manager, legacy-team subscription) — reka-ui only renders
`DialogOverlay` for modal roots, so these dialogs silently lost their
backdrop when they moved to the Reka renderer.

## Changes

- **What**: `src/components/ui/dialog/DialogOverlay.vue` injects
`injectDialogRootContext()` (public reka-ui export) and branches: modal
dialogs keep Reka's `DialogOverlay`; non-modal dialogs render a plain
backdrop `div` wrapped in Reka's `Presence` (`:present="forceMount ||
open"`), so it honors `forceMount` and plays the `data-[state=closed]`
exit fade in sync with the content. Both branches carry
`data-testid="dialog-overlay"`.
- **Popover stacking**: the scrim/content carry inline z-indexes from
@primeuix's `'modal'` counter, which body-portaled popovers with a
static `z-1700` class lost to. Extracted `DropdownMenu.vue`'s existing
lift into `useModalLiftedZIndex` and applied it to `ColorPicker.vue` and
the shared `ui/Popover.vue`, so they stack above the top-most dialog
(verified live: scrim 1701 < content 1702 < picker 1703, panel
clickable, Settings stays open).

## Review Focus

- **Why not `modal: true`**: Settings/Manager intentionally opt out of
Reka's modal mode because its focus trap + body `pointer-events: none`
break nested PrimeVue overlays teleported to body (see comments in
`useSettingsDialog.ts` / `useManagerDialog.ts`). The fallback restores
only the visual scrim; body pointer-events stay `auto`.
- **Dismissal semantics unchanged**: the scrim sits outside Reka's
`DismissableLayer`, so a pointerdown on it goes through the existing
`onRekaPointerDownOutside` bridge — scrim click dismisses the top-most
dialog, exactly like the modal overlay path (unit-covered).
- **CustomizationDialog also gains its scrim back**: the bookmark-folder
Customize dialog renders `:modal="false"` with an explicit
`<DialogOverlay />`, so it picks up the backdrop too (its template
always intended one); its ColorPicker popover is covered by the z-index
lift above. A test pins that a mounted-but-closed non-modal root renders
no scrim (CustomizationDialog mounts with `open: false`).

Verified live (dev): scrim renders behind Settings; nested
Modify-Keybinding dialog opens/focuses over it without dismissing
Settings; scrim click closes Settings; `Comfy.Load3D.BackgroundColor`
color picker opens above the scrim and is fully interactive; no new
console warnings. Unit tests are red without the fix, green with it.

Reported in Slack (FE Main), design confirmed scrims are intended.

## Screenshots

Images hosted on a fork-only branch (`pr-assets/13502-scrim`), not part
of this PR's history.

| Before (`modal: false` — no scrim) | After |
| --- | --- |
| <img width="480" alt="before"
src="https://raw.githubusercontent.com/dante01yoon/ComfyUI_frontend/pr-assets/13502-scrim/.github/pr-assets/settings-scrim-before.png"
/> | <img width="480" alt="after"
src="https://raw.githubusercontent.com/dante01yoon/ComfyUI_frontend/pr-assets/13502-scrim/.github/pr-assets/settings-scrim-after.png"
/> |

Color picker stacking above the scrim (z-index lift):

<img width="640" alt="color picker above scrim"
src="https://raw.githubusercontent.com/dante01yoon/ComfyUI_frontend/pr-assets/13502-scrim/.github/pr-assets/settings-colorpicker-above-scrim.png"
/>
2026-07-08 19:46:54 +00:00
jaeone94
1efe8d9da5 feat: make errors tab selection an emphasis instead of a filter (#13459)
## Summary

Selecting a node no longer filters the errors tab down to that node —
the tab now always shows every error in the workflow, and selection
instead *emphasizes* the matching entries (auto-expand, row highlight,
and a context label), so the error count never lies about whether the
workflow can run.

## Why

The errors tab has quietly been playing two roles at once. It is a
**status surface** ("is this workflow runnable? what's broken and how
much?") — that's what the hero count, the panel-button badge, and the
Run warning all lean on. But it also behaves like an **inspector**:
select a node and the whole list silently narrows to that node's errors.

Mixing the two is confusing in a very concrete way: with 3 errors in the
workflow, clicking one error node makes the tab read "1 Error detected".
Fix that one error while it's selected and the tab reads as clean —
while Run would still fail on the other two. The count changes meaning
depending on an invisible condition (selection), and it disagrees with
the global badges right next to it. This is the same reason VS Code's
Problems panel always lists everything and makes "current file only" an
explicit toggle rather than an implicit one.

## Changes

- **What**: Selection now works as emphasis on top of an always-complete
list:
- The hero count and the group list always describe the whole workflow,
regardless of selection.
- Selecting a node with errors auto-expands the groups containing them
and collapses the rest; clearing the selection (or moving it to an
error-free node) restores the expansion. Manual collapse choices are
left alone when a selection never matched anything.
- Matching entries get a background highlight using the design-system
selection blue (`--color-blue-selection`), so the panel emphasis
visually matches the canvas selection color. The highlight fades in/out
and bleeds slightly past the text without shifting any layout. This
works across all error kinds: execution errors, missing models, missing
media, missing node packs, and swap suggestions (each row/pack that
references the selected node is highlighted).
- A **resident context strip** sits between the hero and the list. With
no selection it reads `{n} nodes — {count} errors` as a workflow
summary; while a selection has errors it switches to `{node title} —
{count} errors` (or `{n} nodes selected — …` for multi-select). Because
the strip always occupies its slot, selecting/deselecting never reflows
the list.
- The strip is deliberately **always visible** rather than mounted on
demand: we plan to rename the tab to "Issues" and downgrade the
missing-* categories from errors to warnings, at which point this same
line becomes the mixed status readout (`X nodes — X errors / X
warnings`). Landing it as a resident status line now means that change
is a label swap, not a layout change.
- **Refactor**: the tab body (search, hero, grouped cards,
locate/install/replace handlers) is extracted from `TabErrors.vue` into
a reusable `ErrorGroupList.vue` — a follow-up PR mounts it outside the
sidebar. The old selection-filter machinery is kept internally as
`selectionScopedGroups` and now only derives the emphasis state (matched
group keys / card ids / asset node ids, selection error count). The
orphaned `compact` prop on `ErrorNodeCard` is removed along with it.
- **Fixes along the way**: node titles containing `=`/`&` no longer
render as HTML entities in the strip (title goes through `i18n-t` slots
instead of an escaped `t()` param), untitled nodes fall back to
"Untitled" instead of producing "1 nodes selected", and emphasized rows
expose their state to assistive tech via `aria-current` while the strip
announces via `role="status"`.

## Review Focus

- `useErrorGroups.ts`: the selection-emphasis derivation
(`selectionScopedGroups` and the `selectionMatched*` computeds).
Selection matching resolves execution ids through the graph (and by
container prefix for subgraph selections) rather than comparing raw ids
— the new unit tests pin both paths.
- `ErrorGroupList.vue`: the emphasis watcher (immediate,
membership-signature based) that syncs collapse state, and the strip
mode switch. The component tests cover the expand/collapse/restore
cycle, emphasis for selections that predate mount, and the strip label
states.
- The two e2e tests that previously asserted the filtering behavior now
assert the new one (counts stay global, strip shows the selection-scoped
count, deselect returns to the summary).

Known follow-ups (intentionally out of scope): distinct-node counting
can over/under-count in subgraph + mixed-error edge cases (execution ids
vs serialized ids), snapshot/restore of user collapse state across
emphasis, and narrowing the list-container `aria-live` region.

## Screenshots (if applicable)

Before


https://github.com/user-attachments/assets/ccf98954-83ed-4333-ba4e-31cedb9fc38b

### After 


https://github.com/user-attachments/assets/f7317e4b-71c2-4009-94cc-25287979c0e4



https://github.com/user-attachments/assets/9008c100-0ec0-4612-8fe4-942fec1be2fe



https://github.com/user-attachments/assets/45734552-9986-41f8-93ad-5d072e355d3d
2026-07-08 17:56:23 +00:00
nav-tej
7b1cc3498d feat(website): add /enterprise-msa page for the Enterprise Customer Agreement (#13483)
*PR Created by the Glary-Bot Agent*

---

Publishes the Comfy Enterprise Customer Agreement (MSA) as a browsable
web page at [`/enterprise-msa`](https://comfy.org/enterprise-msa) so
prospects can review the template before signing an Order Form.
Requested in the `#website-and-docs` thread to make it easier for
companies to view ahead of time and easier to share.

Uses the same `LegalContentSection` template that already serves
[`/affiliates/terms`](https://comfy.org/affiliates/terms), so the visual
language matches the existing legal pages and layout choices (sticky
TOC, active-section tracking, mobile collapsible TOC) are picked up for
free.

## Changes

- **New page** `apps/website/src/pages/enterprise-msa.astro` — mirrors
the `/affiliates/terms` pattern, English-only, with a preamble paragraph
identifying the parties above the TOC.
- **`enterprise-msa` i18n block** in `translations.ts` — drives 12
numbered sections (Definitions → Miscellaneous) plus `Exhibit A. Order
Form`, verbatim from the executed template dated May 22, 2026.
- **Route + locale invariance** — `enterpriseMsa: '/enterprise-msa'`
added to `baseRoutes` and to `LOCALE_INVARIANT_ROUTE_KEYS`, so localized
variants are not served without a legal review of the translation.
Consistent with the existing `termsOfService` / `affiliateTerms`
convention documented in `config/routes.ts`.
- **Footer discovery** — `Enterprise MSA` link added to the `Company`
column of `SiteFooter.vue`, between `Terms of Service` and `Privacy
Policy`. This makes the MSA reachable from the `/cloud/enterprise` page
(and site-wide) with no changes to the enterprise page itself.
- **Test coverage** — new `enterpriseMsaSections.test.ts` guards section
IDs, numeric title pattern, page-chrome keys, and the locale-invariant
route so a future refactor of `LOCALE_INVARIANT_ROUTE_KEYS` cannot
silently start serving an unreviewed translation.

## Verification

- `pnpm test:unit` — 162/162 pass (17 files, 7 new tests)
- `pnpm typecheck` — 0 errors, 0 warnings on changed files (pre-existing
hints untouched)
- `pnpm build` — 498 pages built, `/enterprise-msa/index.html` (80 KB)
contains all 12 sections + Exhibit A
- `oxlint` + `oxfmt` clean on all changed files
- Manual QA via `pnpm preview` at 1440×900 desktop and 390×844 mobile
(responsive layout inherits from `LegalContentSection`, verified
visually) — desktop screenshots attached

## Notes for review

- The MSA copy is a verbatim reproduction of the executed `.docx`
template shared in Slack. If Legal wants edits, they can happen inline
in the `enterprise-msa.*` i18n block — no template restructuring needed.
- The effective date is hard-coded to `May 22, 2026` (matches the
template file name `GP 5.22.26`); update `enterprise-msa.effective-date`
when Legal ships a new template.
- The page intentionally does NOT set `noindex` — the MSA is a
customer-facing document that should be discoverable via search,
matching the user's stated goal of "easier for companies to view ahead
of time."

cc @michael-poganski — requested by James in the Slack thread for
approval to ship.

## Screenshots

![Enterprise MSA page hero at 1440x900: heading, effective date, parties
paragraph](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/2ee32139e0f46eeeb710c64932b4a359aaac84c62427b8bf4e4b6b599ee421bf/pr-images/1783448568340-98d4a75f-f626-438c-a8a0-1650a6baec7e.png)

![Enterprise MSA sticky TOC with Definitions section active, showing
bolded defined
terms](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/2ee32139e0f46eeeb710c64932b4a359aaac84c62427b8bf4e4b6b599ee421bf/pr-images/1783448568702-5c5d44b7-a39d-4958-a34d-fc2201cea7cb.png)

![Site footer showing the new Enterprise MSA link between Terms of
Service and Privacy
Policy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/2ee32139e0f46eeeb710c64932b4a359aaac84c62427b8bf4e4b6b599ee421bf/pr-images/1783448569039-2a862202-85cc-4181-8af0-dc835d36d384.png)

![/cloud/enterprise page footer confirming the Enterprise MSA link is
visible from the enterprise
page](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/2ee32139e0f46eeeb710c64932b4a359aaac84c62427b8bf4e4b6b599ee421bf/pr-images/1783448569349-4386d7da-d7f4-4306-911d-723476db2ef2.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-07-08 17:40:59 +00:00
Robin Huang
b30cedffec fix: point Comfy API "View Docs" link to cloud overview (#13439)
*PR Created by the Glary-Bot Agent*

---

Update the `docsApi` external link so the "View Docs" CTAs on the Comfy
API product page point to the cloud overview's Quick Start section
instead of the old API reference URL.

- `apps/website/src/config/routes.ts`: `docsApi` →
`https://docs.comfy.org/development/cloud/overview#quick-start`

Both "View Docs" buttons on `/api` (in `HeroSection.vue` and
`StepsSection.vue`) consume this single source of truth, so no component
changes were needed.

## Verification
- Confirmed destination page and `#quick-start` anchor exist
(`docs/development/cloud/overview.mdx` has `## Quick Start`).
- Ran the Astro dev server and loaded `/api` locally — both `VIEW DOCS`
anchors resolve to the new URL.
- `pnpm typecheck` / lint-staged (oxfmt, oxlint, eslint, typecheck) all
pass via pre-commit.
- `/review` sub-agent reviewed the diff: 0 findings, ready to merge.

## Screenshots
Both hero and steps sections now link to the new URL.

## Screenshots

![Hero section VIEW DOCS button on /api page pointing to new
URL](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/14d445b6c8572cf29be13abc9192c384f07e11094ff778f73dc7a74d9f7fd2be/pr-images/1783131109637-67b2713a-0d56-4eb1-ac18-5a668d6e306c.png)

![Steps section VIEW DOCS button on /api page pointing to new
URL](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/14d445b6c8572cf29be13abc9192c384f07e11094ff778f73dc7a74d9f7fd2be/pr-images/1783131110661-ee31ac47-929d-44eb-889b-0fcbca6c6c4b.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <153135517+imick-io@users.noreply.github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-07-08 17:35:56 +00:00
123 changed files with 6346 additions and 973 deletions

View File

@@ -63,3 +63,14 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -86,6 +86,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
]
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -15,8 +16,7 @@ const {
locale?: Locale
}>()
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
</script>
<template>

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, translationKeys } from '../../i18n/translations'
const PREFIX = 'enterprise-msa'
function deriveMsaSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('enterprise MSA i18n', () => {
it('every derived section has a title and at least one block', () => {
const sectionIds = deriveMsaSectionIds()
expect(sectionIds.length).toBeGreaterThan(0)
for (const id of sectionIds) {
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('exposes the page-chrome keys the .astro file references', () => {
for (const suffix of [
'effective-date',
'page.title',
'page.description',
'page.heading',
'page.tocLabel',
'page.effectiveDateLabel',
'page.parties'
]) {
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
}
})
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
})
})

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -11,6 +14,8 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -20,15 +25,32 @@ function handleCopy() {
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
@@ -73,7 +74,7 @@ export function getBannerData(
: undefined,
link: link
? {
href: link.href,
href: localizeHref(link.href, locale),
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -15,6 +15,7 @@ const baseRoutes = {
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
@@ -35,19 +36,37 @@ type Routes = typeof baseRoutes
// block in src/i18n/translations.ts for the reasoning.
//
// termsOfService: legal-reviewed English-only document, same reasoning.
//
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
// Customer Agreement template), same reasoning. See the comment header
// in src/pages/enterprise-msa.astro.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService'
'termsOfService',
'enterpriseMsa'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
])
) as unknown as Routes
}
@@ -60,13 +79,12 @@ export const externalLinks = {
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
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',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',

View File

@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -1872,6 +1872,10 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1909,21 +1913,27 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
en: 'Or add it by hand',
'zh-CN': '或手动添加'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -3486,6 +3496,429 @@ const translations = {
'zh-CN': '生效日期'
},
// ── Enterprise MSA ─────────────────────────────────────────────────
// English-only, by design. This is a legal-reviewed customer-facing
// template. Serving a translated variant would expose Comfy to
// liability from the translation diverging from the approved English
// source. See the matching header comment in
// src/pages/enterprise-msa.astro and the LOCALE_INVARIANT_ROUTE_KEYS
// entry in src/config/routes.ts.
'enterprise-msa.effective-date': {
en: 'May 22, 2026',
'zh-CN': 'May 22, 2026'
},
'enterprise-msa.1-definitions.label': {
en: 'DEFINITIONS',
'zh-CN': 'DEFINITIONS'
},
'enterprise-msa.1-definitions.title': {
en: '1. Definitions',
'zh-CN': '1. Definitions'
},
'enterprise-msa.1-definitions.block.0': {
en: '<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
'zh-CN':
'<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
},
'enterprise-msa.1-definitions.block.1': {
en: '<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
'zh-CN':
'<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
},
'enterprise-msa.1-definitions.block.2': {
en: '<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customers own applications or systems.',
'zh-CN':
'<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customers own applications or systems.'
},
'enterprise-msa.1-definitions.block.3': {
en: '<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
'zh-CN':
'<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
},
'enterprise-msa.1-definitions.block.4': {
en: '<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.',
'zh-CN':
'<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.'
},
'enterprise-msa.1-definitions.block.5': {
en: '<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
'zh-CN':
'<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
},
'enterprise-msa.1-definitions.block.6': {
en: '<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.',
'zh-CN':
'<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.'
},
'enterprise-msa.1-definitions.block.7': {
en: '<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.',
'zh-CN':
'<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.'
},
'enterprise-msa.1-definitions.block.8': {
en: '<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
'zh-CN':
'<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
},
'enterprise-msa.1-definitions.block.9': {
en: '<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
'zh-CN':
'<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
},
'enterprise-msa.1-definitions.block.10': {
en: '<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
'zh-CN':
'<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
},
'enterprise-msa.1-definitions.block.11': {
en: '<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.',
'zh-CN':
'<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.'
},
'enterprise-msa.1-definitions.block.12': {
en: '<strong>“User”</strong> means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.',
'zh-CN':
'<strong>“User”</strong> means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.'
},
'enterprise-msa.2-comfy-products.label': {
en: 'PRODUCTS',
'zh-CN': 'PRODUCTS'
},
'enterprise-msa.2-comfy-products.title': {
en: '2. Comfy Products',
'zh-CN': '2. Comfy Products'
},
'enterprise-msa.2-comfy-products.block.0': {
en: '<strong>Right to Access and Use Comfy Products.</strong> Subject to Customers compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customers Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customers internal business purposes.',
'zh-CN':
'<strong>Right to Access and Use Comfy Products.</strong> Subject to Customers compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customers Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customers internal business purposes.'
},
'enterprise-msa.2-comfy-products.block.1': {
en: '<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
'zh-CN':
'<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
},
'enterprise-msa.2-comfy-products.block.2': {
en: '<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
'zh-CN':
'<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
},
'enterprise-msa.2-comfy-products.block.3': {
en: '<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customers use of the Comfy Products and not to the Comfy OSS itself.',
'zh-CN':
'<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customers use of the Comfy Products and not to the Comfy OSS itself.'
},
'enterprise-msa.2-comfy-products.block.4': {
en: '<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customers request to the applicable third-party provider, transmitting the information necessary to fulfill Customers request, including prompts, images, models, and parameters. Comfy does not transmit Customers identity or account information to third-party providers in connection with Partner Node requests. Customers use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
'zh-CN':
'<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customers request to the applicable third-party provider, transmitting the information necessary to fulfill Customers request, including prompts, images, models, and parameters. Comfy does not transmit Customers identity or account information to third-party providers in connection with Partner Node requests. Customers use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
},
'enterprise-msa.2-comfy-products.block.5': {
en: '<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
'zh-CN':
'<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
},
'enterprise-msa.2-comfy-products.block.6': {
en: '<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customers account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customers account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customers personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customers use of the Comfy Products may be retained indefinitely.',
'zh-CN':
'<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customers account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customers account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customers personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customers use of the Comfy Products may be retained indefinitely.'
},
'enterprise-msa.3-customer-responsibilities.label': {
en: 'CUSTOMER',
'zh-CN': 'CUSTOMER'
},
'enterprise-msa.3-customer-responsibilities.title': {
en: '3. Customer Responsibilities',
'zh-CN': '3. Customer Responsibilities'
},
'enterprise-msa.3-customer-responsibilities.block.0': {
en: '<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customers email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customers failure to implement reasonable access controls or to limit access to its systems and devices.',
'zh-CN':
'<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customers email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customers failure to implement reasonable access controls or to limit access to its systems and devices.'
},
'enterprise-msa.3-customer-responsibilities.block.1': {
en: '<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
'zh-CN':
'<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
},
'enterprise-msa.3-customer-responsibilities.block.2': {
en: '<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
'zh-CN':
'<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
},
'enterprise-msa.4-payment.label': {
en: 'PAYMENT',
'zh-CN': 'PAYMENT'
},
'enterprise-msa.4-payment.title': {
en: '4. Payment',
'zh-CN': '4. Payment'
},
'enterprise-msa.4-payment.block.0': {
en: '<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customers account, including usage by Customers Users and under Customers credentials and API keys.',
'zh-CN':
'<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customers account, including usage by Customers Users and under Customers credentials and API keys.'
},
'enterprise-msa.4-payment.block.1': {
en: '<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
'zh-CN':
'<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
},
'enterprise-msa.4-payment.block.2': {
en: '<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.',
'zh-CN':
'<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.'
},
'enterprise-msa.4-payment.block.3': {
en: '<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
'zh-CN':
'<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
},
'enterprise-msa.5-term-termination.label': {
en: 'TERM',
'zh-CN': 'TERM'
},
'enterprise-msa.5-term-termination.title': {
en: '5. Term; Termination',
'zh-CN': '5. Term; Termination'
},
'enterprise-msa.5-term-termination.block.0': {
en: '<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.',
'zh-CN':
'<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.'
},
'enterprise-msa.5-term-termination.block.1': {
en: '<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other partys liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.',
'zh-CN':
'<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other partys liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.'
},
'enterprise-msa.5-term-termination.block.2': {
en: '<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfys standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.',
'zh-CN':
'<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfys standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.'
},
'enterprise-msa.5-term-termination.block.3': {
en: '<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
'zh-CN':
'<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
},
'enterprise-msa.6-confidentiality.label': {
en: 'CONFIDENTIALITY',
'zh-CN': 'CONFIDENTIALITY'
},
'enterprise-msa.6-confidentiality.title': {
en: '6. Confidentiality',
'zh-CN': '6. Confidentiality'
},
'enterprise-msa.6-confidentiality.block.0': {
en: '<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.',
'zh-CN':
'<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.'
},
'enterprise-msa.6-confidentiality.block.1': {
en: '<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
'zh-CN':
'<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
},
'enterprise-msa.6-confidentiality.block.2': {
en: '<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.',
'zh-CN':
'<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.'
},
'enterprise-msa.6-confidentiality.block.3': {
en: '<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
'zh-CN':
'<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
},
'enterprise-msa.7-proprietary-rights.label': {
en: 'IP',
'zh-CN': 'IP'
},
'enterprise-msa.7-proprietary-rights.title': {
en: '7. Proprietary Rights',
'zh-CN': '7. Proprietary Rights'
},
'enterprise-msa.7-proprietary-rights.block.0': {
en: '<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
'zh-CN':
'<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
},
'enterprise-msa.7-proprietary-rights.block.1': {
en: '<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customers experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.',
'zh-CN':
'<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customers experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.'
},
'enterprise-msa.7-proprietary-rights.block.2': {
en: '<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
'zh-CN':
'<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
},
'enterprise-msa.8-warranties-disclaimer.label': {
en: 'WARRANTIES',
'zh-CN': 'WARRANTIES'
},
'enterprise-msa.8-warranties-disclaimer.title': {
en: '8. Warranties; Disclaimer',
'zh-CN': '8. Warranties; Disclaimer'
},
'enterprise-msa.8-warranties-disclaimer.block.0': {
en: '<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customers exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.',
'zh-CN':
'<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customers exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.'
},
'enterprise-msa.8-warranties-disclaimer.block.1': {
en: '<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.',
'zh-CN':
'<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.'
},
'enterprise-msa.8-warranties-disclaimer.block.2': {
en: '<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMERS USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMERS OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMERS USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.',
'zh-CN':
'<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMERS USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMERS OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMERS USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.'
},
'enterprise-msa.9-limitation-of-liability.label': {
en: 'LIABILITY',
'zh-CN': 'LIABILITY'
},
'enterprise-msa.9-limitation-of-liability.title': {
en: '9. Limitation of Liability',
'zh-CN': '9. Limitation of Liability'
},
'enterprise-msa.9-limitation-of-liability.block.0': {
en: 'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMERS PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.',
'zh-CN':
'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMERS PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.'
},
'enterprise-msa.10-indemnification.label': {
en: 'INDEMNITY',
'zh-CN': 'INDEMNITY'
},
'enterprise-msa.10-indemnification.title': {
en: '10. Indemnification',
'zh-CN': '10. Indemnification'
},
'enterprise-msa.10-indemnification.block.0': {
en: '<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customers prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfys opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customers use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customers failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfys sole and exclusive liability and obligation, and Customers exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.',
'zh-CN':
'<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customers prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfys opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customers use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customers failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfys sole and exclusive liability and obligation, and Customers exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.'
},
'enterprise-msa.10-indemnification.block.1': {
en: '<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customers breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfys prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.',
'zh-CN':
'<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customers breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfys prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.'
},
'enterprise-msa.11-miscellaneous.label': {
en: 'MISCELLANEOUS',
'zh-CN': 'MISCELLANEOUS'
},
'enterprise-msa.11-miscellaneous.title': {
en: '11. Miscellaneous',
'zh-CN': '11. Miscellaneous'
},
'enterprise-msa.11-miscellaneous.block.0': {
en: '<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.',
'zh-CN':
'<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.'
},
'enterprise-msa.11-miscellaneous.block.1': {
en: '<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.',
'zh-CN':
'<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.'
},
'enterprise-msa.11-miscellaneous.block.2': {
en: '<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customers name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use Customers name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customers prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
'zh-CN':
'<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customers name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use Customers name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customers prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
},
'enterprise-msa.11-miscellaneous.block.3': {
en: '<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.',
'zh-CN':
'<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.'
},
'enterprise-msa.11-miscellaneous.block.4': {
en: '<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
'zh-CN':
'<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
},
'enterprise-msa.11-miscellaneous.block.5': {
en: '<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
'zh-CN':
'<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
},
'enterprise-msa.11-miscellaneous.block.6': {
en: '<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
'zh-CN':
'<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
},
'enterprise-msa.11-miscellaneous.block.7': {
en: '<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
'zh-CN':
'<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
},
'enterprise-msa.11-miscellaneous.block.8': {
en: '<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.',
'zh-CN':
'<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.'
},
'enterprise-msa.11-miscellaneous.block.9': {
en: '<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.',
'zh-CN':
'<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.'
},
'enterprise-msa.11-miscellaneous.block.10': {
en: '<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
'zh-CN':
'<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
},
'enterprise-msa.12-exhibit-a.label': {
en: 'EXHIBIT A',
'zh-CN': 'EXHIBIT A'
},
'enterprise-msa.12-exhibit-a.title': {
en: 'Exhibit A. Order Form',
'zh-CN': 'Exhibit A. Order Form'
},
'enterprise-msa.12-exhibit-a.block.0': {
en: 'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.',
'zh-CN':
'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.'
},
'enterprise-msa.12-exhibit-a.block.1': {
en: 'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.',
'zh-CN':
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA — Comfy',
'zh-CN': 'Enterprise MSA — Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
'zh-CN':
'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.'
},
'enterprise-msa.page.heading': {
en: 'Enterprise Customer Agreement',
'zh-CN': 'Enterprise Customer Agreement'
},
'enterprise-msa.page.tocLabel': {
en: 'On this page',
'zh-CN': 'On this page'
},
'enterprise-msa.page.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': 'Effective Date'
},
'enterprise-msa.page.parties': {
en: 'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).',
'zh-CN':
'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).'
},
'footer.enterpriseMsa': {
en: 'Enterprise MSA',
'zh-CN': 'Enterprise MSA'
},
// Customers page
'customers.hero.label': {
en: 'CUSTOMER STORIES',

View File

@@ -7,6 +7,7 @@ import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
@@ -43,12 +44,15 @@ const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'

View File

@@ -0,0 +1,36 @@
---
// Enterprise Customer Agreement (Enterprise MSA) — English only, by design.
// Legal-reviewed copy must not be served under a localized route until legal
// explicitly approves a translation; rendering an unreviewed translation as
// the active MSA exposes us to liability from the translation diverging from
// the approved English source. See the matching comment in
// src/i18n/translations.ts for the i18n block, and the entry in
// LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/legal/HeroSection.vue'
import LegalContentSection from '../components/legal/LegalContentSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('enterprise-msa.page.title')}
description={t('enterprise-msa.page.description')}
>
<HeroSection title={t('enterprise-msa.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('enterprise-msa.page.effectiveDateLabel')}: {
t('enterprise-msa.effective-date')
}
</p>
<p
class="text-primary-comfy-canvas mx-auto mt-8 max-w-3xl px-4 text-center text-sm/relaxed lg:px-0"
>
{t('enterprise-msa.page.parties')}
</p>
<LegalContentSection
prefix="enterprise-msa"
locale="en"
tocLabelKey="enterprise-msa.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:primary-cta="ctas.installMcp"
:secondary-cta="ctas.docs"
>
<template #media>

View File

@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
},
{
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
<template>
<FeatureGrid01
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"

View File

@@ -9,16 +9,25 @@ export interface McpCta {
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud

View File

@@ -0,0 +1,37 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadVideo",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -11,6 +11,7 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
interface RoleChangeRequest {
url: string
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>

View File

@@ -110,7 +110,8 @@ export const TestIds = {
},
propertiesPanel: {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
errorsTab: 'panel-tab-errors',
selectionContextStrip: 'selection-context-strip'
},
assets: {
browserModal: 'asset-browser-modal',

View File

@@ -33,6 +33,27 @@ export function member(
}
}
/**
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
* this the mint fails and auth cannot resolve the active workspace.
*/
export async function mockWorkspaceTokenMint(
page: Page,
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
) {
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
@@ -46,17 +67,7 @@ export async function mockWorkspace(
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await mockWorkspaceTokenMint(page, ws)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({

View File

@@ -11,6 +11,10 @@ import type {
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* Billing facade consumers — FE-933 (B3) regression.
@@ -81,6 +85,7 @@ async function mockCloudBoot(
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,6 +1,11 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import type {
Asset,
GetAllSettingsResponse,
GetSettingByIdResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
@@ -8,6 +13,7 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { WorkspaceStore } from '@e2e/types/globals'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
@@ -23,10 +29,11 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const outputVideoHash = 'cloud-video-hash.mp4'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const missingMediaObservationMs = 1_000
const missingMediaPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
@@ -60,6 +67,18 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudOutputVideoAsset: Asset & { hash?: string } = {
id: 'test-output-video-hash-001',
name: 'ComfyUI_00001_.mp4',
hash: outputVideoHash,
size: 4_194_304,
mime_type: 'video/mp4',
tags: ['output'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
@@ -92,10 +111,21 @@ interface CloudUploadAssetState {
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
const completedSurveySetting: GetSettingByIdResponse = {
value: { usage: 'personal' }
}
const allSettings: GetAllSettingsResponse = {}
const body = route
.request()
.url()
.includes('/api/settings/onboarding_survey')
? completedSurveySetting
: allSettings
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
body: JSON.stringify(body)
})
})
await page.route('**/api/userdata**', async (route) => {
@@ -121,7 +151,10 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
const cloudOutputTest = createCloudAssetsFixture([
cloudOutputAsset,
cloudOutputVideoAsset
]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
@@ -225,6 +258,33 @@ function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
function isOutputAssetsRequest(url: string) {
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
}
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
await comfyPage.page.waitForResponse(
(response) =>
response.status() === 200 && isOutputAssetsRequest(response.url())
)
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -327,25 +387,31 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
.toBe(true)
}
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
let sawCachedMissingMedia = false
const startedAt = Date.now()
await expect
.poll(
async () => {
const cachedMissingMedia =
await getCachedMissingMediaWarningNames(comfyPage)
sawCachedMissingMedia =
sawCachedMissingMedia || !!cachedMissingMedia?.length
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
Date.now() - startedAt >= missingMediaUploadObservationMs
!sawCachedMissingMedia &&
Date.now() - startedAt >= missingMediaObservationMs
)
},
{
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
intervals: [missingMediaPollMs]
}
)
.toBe(true)
@@ -424,7 +490,7 @@ ossTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -482,18 +548,30 @@ cloudOutputTest.describe(
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ cloudAssetRequests, comfyPage }) => {
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
cloudOutputTest(
'resolves subfoldered output video media from flat output asset hashes',
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_video_subfolder'
)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
@@ -529,7 +607,7 @@ cloudUploadRaceTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()

View File

@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingModelGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
test('Selecting a node keeps all errors visible and shows selection context', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -301,14 +301,25 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expectReferenceBadge(missingModelGroup, 2)
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(strip).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
strip,
'The strip count is scoped to the selection, diverging from the global reference badge'
).toContainText('1 error')
await comfyPage.canvas.click()
await expect(
strip,
'Deselecting swaps the always-visible strip back to the summary'
).toContainText('2 nodes — 1 error')
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -381,7 +392,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingMediaGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
test('Selecting a node keeps all media rows visible and shows selection context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
@@ -403,13 +414,66 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await expect(mediaRows).toHaveCount(1)
// Selection no longer filters the list — rows stay global and the
// selection is surfaced via the context strip instead.
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(strip).toBeVisible()
await expect(strip).toContainText('1 error')
await expect(mediaRows).toHaveCount(2)
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
// Deselecting swaps the always-visible strip back to the summary
await expect(strip).toContainText('2 nodes — 2 errors')
await expect(mediaRows).toHaveCount(2)
})
})
test.describe('Selection emphasis', () => {
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_and_media'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
const mediaRow = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaRow
)
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(missingNodeCard).toBeVisible()
await expect(mediaRow).toBeVisible()
await expect(strip).toContainText('2 nodes — 2 errors')
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
// The node sits near the canvas top where overlays intercept clicks
await mediaNode.centerOnNode()
await mediaNode.click('title')
// The unrelated missing-node group auto-collapses while the matched
// media row stays visible and is marked as part of the selection
await expect(missingNodeCard).toBeHidden()
await expect(mediaRow).toBeVisible()
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
await expect(strip).toContainText('1 error')
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
// Emphasis ends: the collapsed group re-expands and the strip
// returns to the workflow summary
await expect(missingNodeCard).toBeVisible()
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
await expect(strip).toContainText('2 nodes — 2 errors')
})
})
test.describe('Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)

View File

@@ -0,0 +1,120 @@
# 11. Derived Credential Lifecycle for Cloud Auth
Date: 2026-07-09
## Status
Proposed
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
Cloud authentication derives several short-lived credentials from a single
source of truth — the Firebase identity (ID token):
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
(`useSessionCookie`),
- and consumer state gated on those credentials, such as **subscription status**
(`useSubscription`).
A recurring class of production bugs traces back to how these derived credentials
are kept fresh rather than to any single code path:
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
Its refresh relies on a `setTimeout` timer that browsers throttle in background
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
call 401s until reload.
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
personal Firebase token, so requests authenticated as the wrong identity.
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
burst on wake/network-swap fans out into concurrent, undeduped subscription
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
return 401/empty, flapping `subscriptionStatus` and the run button.
These are not independent defects. They are symptoms of one design shape: **each
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
one-shot events rather than the source identity, with no coalescing of concurrent
refreshes and with silent fallback to a different identity or a stale value on
failure.** Any credential built this way can go stale, stampede, or downgrade.
## Decision
Treat every derived credential as a pure function of the Firebase identity, and
require all of them to obey the same lifecycle invariants. New auth code must
satisfy these; existing code migrates toward them incrementally.
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
JWT and session cookie are derivations of it, never independent state that can
drift from it.
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
or a definitive failure — never a known-expired one. Validity is checked at the
point of use (expiry-aware), not assumed because a background timer _should_
have refreshed. Timers may be an optimization, never the guarantee.
3. **Single-flight.** Concurrent requests for the same credential share one
in-flight mint/refresh. A refresh burst collapses to a single network call.
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
obtained, fail the request. Never silently substitute a different identity or
scope (e.g. personal token for a workspace request).
5. **Bounded reactive retry.** Invalidation is driven by the source identity
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
derived credential triggers at most one re-mint and one retry, then surfaces
the error.
6. **Explicit scope.** A credential names the identity/workspace it is for.
Coalesced results are verified against the requested scope before use.
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
single-flight, fail-closed, and reconciles a revoked workspace instead of
downgrading; subscription-status and session-cookie creation are now
single-flight so a refresh burst can no longer flap them. It intentionally does
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
re-mint + single retry).
Alternatives considered:
- **Layer more defensive checks per call site.** Rejected: this is what produced
the current state — correctness that depends on every caller remembering to
guard is the defect, not the fix.
- **A single reactive credential store subscribing to Firebase, replacing all
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
these invariants incrementally instead.
## Consequences
### Positive
- Whole categories of failure become structurally hard rather than individually
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
requests (4).
- New auth code has a single checklist to satisfy, and reviewers a single rubric
to apply.
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
reasoning about auth changes.
### Negative
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
transient conditions now show errors instead of degrading quietly, so
transient-vs-permanent classification must be correct.
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
lazy and a backgrounded tab still relies on the next read to heal, leaving a
visible gap against FE-613's reactive ideal.
- Existing lifecycles remain non-uniform during migration, so the mental model is
"target vs. current" until the reactive credential store exists.
## Notes
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
shares the philosophy of designing invariants that make illegal states
unrepresentable rather than guarding against them per call site.
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.

View File

@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
## Creating a New ADR

View File

@@ -4,11 +4,12 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
## Testing Documentation
Documentation for unit tests is organized into three guides:
Documentation for unit tests is organized into four guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
## Testing Structure

View File

@@ -0,0 +1,9 @@
# LiteGraph Testing Guide
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
## Shared Factories
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.

View File

@@ -23,6 +23,23 @@ const EXAMPLE_NODE_DEF: ComfyNodeDef = {
}
describe('validateNodeDef', () => {
it('retains interactive display definitions', () => {
const interactive_ui = [
{
id: 'stream',
kind: 'video_stream',
views: [
{ id: 'source', role: 'local_source' as const, label: 'Webcam' },
{ id: 'result', role: 'remote_output' as const, label: 'Inverted' }
]
}
]
expect(
validateComfyNodeDef({ ...EXAMPLE_NODE_DEF, interactive_ui })
?.interactive_ui
).toEqual(interactive_ui)
})
it('accepts a valid node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})

View File

@@ -273,6 +273,20 @@ const zPriceBadge = z.object({
export type PriceBadge = z.infer<typeof zPriceBadge>
export const zInteractiveUi = z.array(
z.object({
id: z.string().min(1),
kind: z.string().min(1),
views: z.array(
z.object({
id: z.string().min(1),
role: z.enum(['local_source', 'remote_output']),
label: z.string().optional()
})
)
})
)
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
@@ -317,7 +331,8 @@ export const zComfyNodeDef = z.object({
/** Category for the Essentials tab. If set, the node appears in Essentials. */
essentials_category: z.string().optional(),
/** Whether the blueprint is a global/installed blueprint (not user-created). */
isGlobal: z.boolean().optional()
isGlobal: z.boolean().optional(),
interactive_ui: zInteractiveUi.optional()
})
export const zAutogrowOptions = z.object({

View File

@@ -414,15 +414,15 @@ describe('formatUtil', () => {
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
it('returns true for image/video/audio/3D/text', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
expect(isPreviewableMediaType('text')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
it('returns false for other', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})

View File

@@ -677,12 +677,7 @@ export function getMediaTypeFromFilename(
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
return mediaType !== 'other'
}
export function formatTime(seconds: number): string {

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -12,12 +11,10 @@ import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
@@ -46,15 +43,8 @@ const contentClass = computed(() =>
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
const contentStyle = useModalLiftedZIndex(open)
</script>
<template>

View File

@@ -1,10 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen } from '@testing-library/vue'
import { cleanup, render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { defineComponent, h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
@@ -12,6 +12,9 @@ import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import UiDialog from '@/components/ui/dialog/Dialog.vue'
import UiDialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import UiDialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -29,6 +32,14 @@ const Body = defineComponent({
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
const ClosedNonModalDialog = defineComponent({
name: 'ClosedNonModalDialog',
setup: () => () =>
h(UiDialog, { open: false, modal: false }, () =>
h(UiDialogPortal, null, () => h(UiDialogOverlay))
)
})
function mountDialog() {
return render(GlobalDialog, {
global: { plugins: [PrimeVue, i18n] }
@@ -275,6 +286,81 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
})
})
describe('GlobalDialog Reka overlay scrim', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('renders a backdrop scrim for modal Reka dialogs', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-modal-scrim',
title: 'Modal',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(1)
})
it('shows a backdrop scrim while a non-modal Reka dialog is open', async () => {
// Reka's own DialogOverlay renders nothing when the root is non-modal,
// which silently dropped the scrim behind Settings/Manager (modal: false).
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-non-modal-scrim',
title: 'Non-modal',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(1)
store.closeDialog({ key: 'reka-non-modal-scrim' })
await waitFor(() =>
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(0)
)
})
it('renders no scrim for a mounted but closed non-modal dialog', async () => {
// CustomizationDialog mounts its non-modal Dialog root with open=false;
// the scrim must stay gated on open, not just on mount.
render(ClosedNonModalDialog)
await nextTick()
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(0)
})
it('dismisses the dialog on a scrim pointerdown', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-scrim-dismiss',
title: 'Non-modal',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
await user.click(screen.getByTestId('dialog-overlay'))
await waitFor(() =>
expect(store.isDialogOpen('reka-scrim-dismiss')).toBe(false)
)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false

View File

@@ -0,0 +1,239 @@
import { createTestingPinia } from '@pinia/testing'
import type { TestingPinia } from '@pinia/testing'
import { render, screen, waitFor, within } 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 { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { fromAny } from '@total-typescript/shoehorn'
import ErrorGroupList from './ErrorGroupList.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: vi.fn()
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
}),
isModelDownloadable: vi.fn(() => true),
toBrowsableUrl: vi.fn((url: string) => url)
}))
const SAMPLER_NODE = { id: '1', title: 'SamplerNode' }
const LOADER_NODE = { id: '2', title: 'LoaderNode' }
function seedTwoErrorGroups(pinia: TestingPinia) {
const executionErrorStore = useExecutionErrorStore(pinia)
executionErrorStore.lastNodeErrors = fromAny<
typeof executionErrorStore.lastNodeErrors,
unknown
>({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: '',
extra_info: { input_name: 'clip' }
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'weird_error', message: 'Something odd happened', details: '' }
]
}
})
}
function renderList(pinia: TestingPinia) {
const user = userEvent.setup()
render(ErrorGroupList, {
global: {
plugins: [PrimeVue, testI18n, pinia],
stubs: {
AsyncSearchInput: {
template: '<input />'
}
}
}
})
return { user }
}
function createPinia() {
return createTestingPinia({ createSpy: vi.fn, stubActions: false })
}
function getSectionByTitle(title: string) {
const sections = screen.getAllByTestId('error-group-execution')
const section = sections.find((s) => within(s).queryByText(title))
expect(section).toBeDefined()
return section!
}
function isSectionExpanded(section: HTMLElement) {
const [header] = within(section).getAllByRole('button', { hidden: true })
return header.getAttribute('aria-expanded') === 'true'
}
describe('ErrorGroupList selection emphasis', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '1' ? SAMPLER_NODE : LOADER_NODE
)
)
})
it('expands matched groups, collapses others, and restores on deselect', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const samplerSection = getSectionByTitle('Missing connection')
const loaderSection = getSectionByTitle('Validation failed')
expect(isSectionExpanded(samplerSection)).toBe(true)
expect(isSectionExpanded(loaderSection)).toBe(true)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
await waitFor(() => {
expect(isSectionExpanded(loaderSection)).toBe(false)
})
expect(isSectionExpanded(samplerSection)).toBe(true)
canvasStore.selectedItems = []
await waitFor(() => {
expect(isSectionExpanded(loaderSection)).toBe(true)
})
expect(isSectionExpanded(samplerSection)).toBe(true)
})
it('expands only matched groups for a selection that predates mount', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const canvasStore = useCanvasStore(pinia)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
renderList(pinia)
await waitFor(() => {
expect(isSectionExpanded(getSectionByTitle('Validation failed'))).toBe(
false
)
})
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
true
)
})
it('leaves manual collapse state alone for selections without errors', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const { user } = 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)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '99', title: 'Unrelated' }])
await waitFor(() => {
// No emphasis: the strip falls back to the workflow summary
expect(screen.getByTestId('selection-context-strip')).toHaveTextContent(
'2 nodes — 2 errors'
)
})
expect(isSectionExpanded(loaderSection)).toBe(false)
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
true
)
})
it('always shows the strip: workflow summary by default, selection while emphasized', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const strip = screen.getByTestId('selection-context-strip')
expect(strip).toHaveTextContent('2 nodes — 2 errors')
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
await waitFor(() => {
expect(strip).toHaveTextContent('SamplerNode — 1 error')
})
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE, LOADER_NODE])
await waitFor(() => {
expect(strip).toHaveTextContent('2 nodes selected — 2 errors')
})
canvasStore.selectedItems = []
await waitFor(() => {
expect(strip).toHaveTextContent('2 nodes — 2 errors')
})
})
})

View File

@@ -0,0 +1,609 @@
<template>
<div class="flex min-w-0 flex-col">
<!-- Search bar + collapse toggle -->
<div
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" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && allErrorGroups.length > 1"
/>
</div>
<div class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3">
<div
v-if="filteredGroups.length === 0"
role="status"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Context strip: workflow summary, or the selection's errors -->
<div
data-testid="selection-context-strip"
role="status"
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
>
<i18n-t
:keypath="strip.keypath"
:plural="strip.count"
tag="span"
:class="
cn(
'min-w-0 flex-1 truncate text-xs font-semibold transition-colors duration-200',
hasSelectionEmphasis
? 'text-primary-background-hover'
: 'text-muted-foreground'
)
"
>
<template #node>{{ selectionStripNodeLabel }}</template>
<template #nodes>{{ strip.nodes }}</template>
<template #count>{{ strip.count }}</template>
</i18n-t>
</div>
<!-- Group by Class Type -->
<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)"
>
<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"
>
{{ 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"
/>
<!-- 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"
: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 Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-model="handleLocateAssetNode"
/>
<!-- 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 { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { selectionEmphasisClass } from './selectionEmphasis'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
cardId: string
nodeId: string
label: string
displayDetails?: string
}
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
cardId: card.id,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
filteredGroups,
collapseState,
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups,
hasSelection,
selectedNodeCount,
selectedNodeTitle,
selectionMatchedGroupKeys,
selectionMatchedCardIds,
selectionMatchedAssetNodeIds,
selectionErrorCount,
errorNodeCount
} = useErrorGroups(searchQuery)
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const hasSelectionEmphasis = computed(
() => hasSelection.value && selectionErrorCount.value > 0
)
const selectionStripNodeLabel = computed(
() => selectedNodeTitle.value ?? t('g.untitled')
)
// The strip is a status line, not a view of the current filter — summary
// numbers are workflow-wide, never search-filtered.
const workflowErrorCount = computed(() =>
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const strip = computed(() => {
if (hasSelectionEmphasis.value) {
return {
keypath:
selectedNodeCount.value === 1
? 'rightSidePanel.selectedNodeErrors'
: 'rightSidePanel.selectedNodesErrors',
nodes: selectedNodeCount.value,
count: selectionErrorCount.value
}
}
return {
keypath:
errorNodeCount.value === 0
? // Node-less errors (e.g. prompt-level) would read as "0 nodes"
'rightSidePanel.errorsSummary'
: errorNodeCount.value === 1
? 'rightSidePanel.errorNodeSummary'
: 'rightSidePanel.errorNodesSummary',
nodes: errorNodeCount.value,
count: workflowErrorCount.value
}
})
function isCardInSelection(cardId: string): boolean {
return selectionMatchedCardIds.value.has(cardId)
}
/**
* Dedupes the Set-valued computed (fresh reference per recompute) so the
* emphasis watcher below only fires when the matched membership changes.
*/
const selectionEmphasisSignature = computed(() =>
hasSelection.value
? Array.from(selectionMatchedGroupKeys.value).sort().join('\n')
: ''
)
/**
* 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
for (const groupKey of Object.keys(collapseState)) {
setSectionCollapsed(groupKey, false)
}
return
}
const matchedKeys = selectionMatchedGroupKeys.value
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
}
},
{ immediate: true }
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
</script>

View File

@@ -1,9 +1,6 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div
v-if="card.nodeId && !compact"
class="flex min-h-8 flex-wrap items-center gap-2"
>
<div v-if="card.nodeId" class="flex min-h-8 flex-wrap items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
@@ -103,7 +100,7 @@
<TransitionCollapse>
<div
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
v-if="error.isRuntimeError && runtimeDetailsExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
@@ -186,9 +183,8 @@ import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const { card, compact = false } = defineProps<{
const { card } = defineProps<{
card: ErrorCardData
compact?: boolean
}>()
const emit = defineEmits<{
@@ -203,9 +199,6 @@ const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const isRuntimeDisclosureExpanded = computed(
() => compact || runtimeDetailsExpanded.value
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))

View File

@@ -56,12 +56,15 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden">
<div class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:highlighted="
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
@@ -106,10 +109,13 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
missingPackGroups: MissingPackGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,14 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<Button
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
@@ -216,6 +224,8 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from './selectionEmphasis'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -227,9 +237,11 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton } = defineProps<{
const { group, showInfoButton, highlighted } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
/** Emphasize the header row (pack containing the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,275 +1,6 @@
<template>
<div class="flex h-full min-w-0 flex-col">
<!-- Search bar + collapse toggle -->
<div
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" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && tabErrorGroups.length > 1"
/>
</div>
<div
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
aria-live="polite"
>
<div
v-if="filteredGroups.length === 0"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Group by Class Type -->
<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)"
>
<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"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@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"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-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"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
</div>
<ErrorGroupList class="min-h-0 flex-1" />
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
@@ -308,44 +39,14 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
interface ExecutionItemListEntry {
key: string
nodeId: string
label: string
displayDetails?: string
}
import ErrorGroupList from './ErrorGroupList.vue'
import { useErrorActions } from './useErrorActions'
const ErrorPanelSurveyCta =
isNightly && !isCloud && !isDesktop
@@ -355,171 +56,5 @@ const ErrorPanelSurveyCta =
: undefined
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery)
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of tabErrorGroups.value) {
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
</script>

View File

@@ -0,0 +1,30 @@
import { cn } from '@comfyorg/tailwind-utils'
import type { MissingNodeType } from '@/types/comfy'
// The negative margin and matching padding cancel out, so the background
// bleeds 6px past the content without shifting the text.
const EMPHASIS_CLASS = 'rounded-sm bg-blue-selection -mx-1.5 px-1.5'
// Present even when unhighlighted so the emphasis animates both ways.
const TRANSITION_CLASS =
'transition-[background-color,margin,padding,border-radius] duration-200'
/** Classes emphasizing rows/cards that belong to the canvas selection. */
export function selectionEmphasisClass(highlighted: boolean | undefined) {
return cn(TRANSITION_CLASS, highlighted && EMPHASIS_CLASS)
}
/** True when any node type resolves to a node in the given id set. */
export function someNodeTypeInSelection(
nodeTypes: MissingNodeType[],
nodeIds: Set<string> | undefined
): boolean {
if (!nodeIds?.size) return false
return nodeTypes.some(
(nodeType) =>
typeof nodeType !== 'string' &&
nodeType.nodeId != null &&
nodeIds.has(String(nodeType.nodeId))
)
}

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
vi.mock('@/scripts/app', () => ({
app: {
@@ -126,6 +127,12 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import {
getExecutionIdByNode,
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
@@ -205,6 +212,7 @@ describe('useErrorGroups', () => {
setActivePinia(createPinia())
mockIsCloud.value = false
vi.mocked(isLGraphNode).mockReturnValue(false)
vi.mocked(getNodeByExecutionId).mockReset()
})
describe('missingPackGroups', () => {
@@ -986,24 +994,13 @@ describe('useErrorGroups', () => {
})
})
describe('unfiltered vs selection-filtered model/media groups', () => {
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups).toBeDefined()
expect(groups.filteredMissingModelGroups).toBeDefined()
expect(groups.missingMediaGroups).toBeDefined()
expect(groups.filteredMissingMediaGroups).toBeDefined()
})
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
describe('selection does not shrink displayed groups', () => {
it('missingModelGroups returns total candidates regardless of selection', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
])
// Simulate canvas selection of a single node so the filtered
// variant actually narrows. Without this, both sides return the
// same value trivially and the test can't prove the contract.
vi.mocked(isLGraphNode).mockReturnValue(true)
const canvasStore = useCanvasStore()
canvasStore.selectedItems = fromAny<
@@ -1012,23 +1009,18 @@ describe('useErrorGroups', () => {
>([{ id: '1' }])
await nextTick()
// Unfiltered total stays at one group of two models regardless of
// the selection — ErrorOverlay reads this for the overlay label
// and must not shrink with canvas selection.
// Displayed groups never shrink with canvas selection — the count
// and list always describe the whole workflow.
expect(groups.missingModelGroups.value).toHaveLength(1)
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
// Filtered variant does narrow under the same selection state —
// this is how the errors tab scopes cards to the selected node.
// Exact filtered output depends on the app.rootGraph lookup
// (mocked to return undefined here); what matters is that the
// filtered shape is a different reference and does not blindly
// mirror the unfiltered one.
expect(groups.filteredMissingModelGroups.value).not.toBe(
groups.missingModelGroups.value
)
expect(
groups.filteredGroups.value.find((g) => g.type === 'missing_model')
?.count
).toBe(2)
})
})
describe('missing media counting', () => {
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
@@ -1051,8 +1043,8 @@ describe('useErrorGroups', () => {
})
})
describe('tabErrorGroups', () => {
it('filters prompt error when a node is selected', async () => {
describe('selection emphasis', () => {
it('never marks workflow-level prompt errors as matched by a selection', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
@@ -1067,11 +1059,205 @@ describe('useErrorGroups', () => {
}
await nextTick()
const promptGroup = groups.tabErrorGroups.value.find(
const promptGroup = groups.allErrorGroups.value.find(
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeUndefined()
expect(promptGroup).toBeDefined()
expect(
groups.selectionMatchedGroupKeys.value.has(promptGroup!.groupKey)
).toBe(false)
})
it('reports no selection state when nothing is selected', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.hasSelection.value).toBe(false)
expect(groups.selectionMatchedGroupKeys.value.size).toBe(0)
expect(groups.selectionMatchedCardIds.value.size).toBe(0)
expect(groups.selectionErrorCount.value).toBe(0)
})
it('matches groups and cards of the selected error node', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
const selectedNode = { id: '1' }
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '1' ? selectedNode : { id: String(nodeId) }
)
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
await nextTick()
expect(groups.hasSelection.value).toBe(true)
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-1')).toBe(true)
expect(groups.selectionMatchedCardIds.value.has('node-2')).toBe(false)
expect(groups.selectionMatchedAssetNodeIds.value.size).toBe(0)
// Both error groups remain displayed regardless of the selection
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
const displayedCardIds = executionGroups.flatMap((g) =>
g.type === 'execution' ? g.cards.map((c) => c.id) : []
)
expect(displayedCardIds).toContain('node-1')
expect(displayedCardIds).toContain('node-2')
})
it('narrows missing-node emphasis to packs containing the selected node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '2' }])
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
// Emphasis counts only the packs containing the selected node…
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
true
)
expect(groups.selectionErrorCount.value).toBe(1)
// …and marks only the selected node for row highlighting.
expect(groups.selectionMatchedAssetNodeIds.value.has('2')).toBe(true)
expect(groups.selectionMatchedAssetNodeIds.value.has('3')).toBe(false)
// Display still shows every pack.
const missingNodeGroup = groups.filteredGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingNodeGroup?.count).toBe(2)
})
it('does not emphasize missing-node groups for unrelated selections', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '99' }])
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' })
])
await nextTick()
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
false
)
expect(groups.selectionErrorCount.value).toBe(0)
// Display is unaffected by the unrelated selection.
expect(
groups.filteredGroups.value.find((g) => g.type === 'missing_node')
?.count
).toBe(1)
})
it('matches errors through graph resolution, not raw execution ids', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
// The error is keyed by a subgraph execution id ('2:5') that resolves
// to a different graph node id ('7') at the current graph level.
const selectedNode = { id: '7' }
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '2:5' ? selectedNode : undefined
)
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
})
it('matches interior errors when a subgraph container is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
// A container selection matches interior errors by execution-id prefix,
// even when the interior node does not resolve at the current level.
const containerNode = fromAny<SubgraphNode, unknown>(
Object.assign(Object.create(SubgraphNode.prototype), { id: '2' })
)
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
vi.mocked(getExecutionIdByNode).mockReturnValue(
fromAny<NodeExecutionId, unknown>('2')
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([containerNode])
store.lastNodeErrors = {
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
},
'9': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
expect(groups.selectionMatchedCardIds.value.has('node-9')).toBe(false)
})
})
})

View File

@@ -24,6 +24,7 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
import { someNodeTypeInSelection } from './selectionEmphasis'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
@@ -259,12 +260,25 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerExecutionIds.size === 0
const hasSelection = computed(() => selectedNodeInfo.value.nodeIds !== null)
const selectedNodeCount = computed(
() => selectedNodeInfo.value.nodeIds?.size ?? 0
)
const selectedNodeTitle = computed(() => {
if (selectedNodeCount.value !== 1) return null
const node = canvasStore.selectedItems.find(isLGraphNode)
if (!node) return null
return (
resolveNodeDisplayName(node, {
emptyLabel: '',
untitledLabel: '',
st
}) || null
)
})
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionErrorStore.allErrorExecutionIds) {
@@ -581,38 +595,50 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
})
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
/**
* Builds ErrorGroups from missingNodesError. Returns [] when none present.
* `includeGroup` narrows which swap/pack groups are counted (used to scope
* emphasis to the canvas selection); groups reduced to zero are omitted.
*/
function buildMissingNodeGroups(
includeGroup: (nodeTypes: MissingNodeType[]) => boolean = () => true
): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []
const swapCount = swapNodeGroups.value.filter((group) =>
includeGroup(group.nodeTypes)
).length
const packCount = missingPackGroups.value.filter((group) =>
includeGroup(group.nodeTypes)
).length
if (swapNodeGroups.value.length > 0) {
if (swapCount > 0) {
groups.push({
type: 'swap_nodes' as const,
groupKey: 'swap_nodes',
count: swapNodeGroups.value.length,
count: swapCount,
priority: 0,
...resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
count: swapNodeGroups.value.length,
nodeTypes: error.nodeTypes,
count: swapCount,
isCloud
})
})
}
if (missingPackGroups.value.length > 0) {
if (packCount > 0) {
groups.push({
type: 'missing_node' as const,
groupKey: 'missing_node',
count: missingPackGroups.value.length,
count: packCount,
priority: 1,
...resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: error.nodeTypes,
count: missingPackGroups.value.length,
count: packCount,
isCloud
})
})
@@ -699,31 +725,33 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
/** Model groups narrowed to the selection, for emphasis derivation only. */
const missingModelGroupsForSelection = computed(() => {
if (!hasSelection.value) return []
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
const matched = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
if (!matched.length) return []
return groupMissingModelCandidates(matched, isCloud)
})
const filteredMissingMediaGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
/** Media groups narrowed to the selection, for emphasis derivation only. */
const missingMediaGroupsForSelection = computed(() => {
if (!hasSelection.value) return []
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
const matched = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)
if (!matched.length) return []
return groupCandidatesByMediaType(matched)
})
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
const count = countMissingModels(filteredMissingModelGroups.value)
function buildMissingModelGroupsForSelection(): ErrorGroup[] {
if (!missingModelGroupsForSelection.value.length) return []
const count = countMissingModels(missingModelGroupsForSelection.value)
return [
{
type: 'missing_model' as const,
@@ -732,7 +760,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: filteredMissingModelGroups.value,
groups: missingModelGroupsForSelection.value,
count,
isCloud
})
@@ -740,10 +768,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
function buildMissingMediaGroupsForSelection(): ErrorGroup[] {
if (!missingMediaGroupsForSelection.value.length) return []
const totalRows = countMissingMediaReferences(
filteredMissingMediaGroups.value
missingMediaGroupsForSelection.value
)
return [
{
@@ -753,7 +781,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
groups: missingMediaGroupsForSelection.value,
count: totalRows,
isCloud
})
@@ -776,47 +804,113 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
/**
* The subset of error groups whose errors belong to the current canvas
* selection. Empty when nothing is selected. Display always shows all
* groups; this subset only drives selection emphasis (auto-expand, card
* highlight, context strip).
*/
const selectionScopedGroups = computed<ErrorGroup[]>(() => {
if (!hasSelection.value) return []
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, true)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
// pack-level problems relevant regardless of which node is selected.
return [
...buildMissingNodeGroups(),
...(filterByNode
? buildMissingModelGroupsFiltered()
: buildMissingModelGroups()),
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...buildMissingNodeGroups((nodeTypes) =>
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
),
...buildMissingModelGroupsForSelection(),
...buildMissingMediaGroupsForSelection(),
...toSortedGroups(groupsMap)
]
})
/**
* Execution node ids referenced by any missing-asset candidate (models,
* media, missing node types).
*/
const assetNodeIdsWithError = computed<string[]>(() => {
const candidateIds = [
...(missingModelStore.missingModelCandidates ?? []),
...(missingMediaStore.missingMediaCandidates ?? [])
].map((candidate) => candidate.nodeId)
const missingNodeTypeIds = (
missingNodesStore.missingNodesError?.nodeTypes ?? []
).map((nodeType) =>
typeof nodeType === 'string' ? undefined : nodeType.nodeId
)
return [...candidateIds, ...missingNodeTypeIds]
.filter((nodeId) => nodeId != null)
.map(String)
})
/**
* Asset node ids that belong to the current selection. Drives row-level
* highlighting inside the missing-* cards.
*/
const selectionMatchedAssetNodeIds = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(
assetNodeIdsWithError.value.filter(isAssetCandidateInSelection)
)
})
const selectionMatchedGroupKeys = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(selectionScopedGroups.value.map((group) => group.groupKey))
})
const selectionMatchedCardIds = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(
selectionScopedGroups.value
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.id)
)
})
const selectionErrorCount = computed(() => {
if (!hasSelection.value) return 0
return selectionScopedGroups.value.reduce(
(sum, group) => sum + group.count,
0
)
})
/** Distinct nodes affected by any error (workflow-level summary). */
const errorNodeCount = computed(() => {
const executionNodeIds = allErrorGroups.value
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.nodeId)
.filter((nodeId) => nodeId != null)
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = toValue(searchQuery).trim()
return searchErrorGroups(tabErrorGroups.value, query)
return searchErrorGroups(allErrorGroups.value, query)
})
return {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
filteredMissingModelGroups,
filteredMissingMediaGroups,
swapNodeGroups
swapNodeGroups,
hasSelection,
selectedNodeCount,
selectedNodeTitle,
selectionMatchedGroupKeys,
selectionMatchedCardIds,
selectionMatchedAssetNodeIds,
selectionErrorCount,
errorNodeCount
}
}

View File

@@ -49,6 +49,7 @@
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
</template>
</div>
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{

View File

@@ -0,0 +1,21 @@
<template>
<article
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
>
<span v-if="hasError" class="text-muted-foreground">
{{ $t('g.textFailedToLoad') }}
</span>
<template v-else>{{ textContent }}</template>
</article>
</template>
<script setup lang="ts">
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const { textContent, hasError } = useTextFileContent(() => result)
</script>

View File

@@ -8,7 +8,10 @@ import {
PopoverTrigger
} from 'reka-ui'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({
@@ -26,10 +29,13 @@ const {
to?: string | HTMLElement
showArrow?: boolean
}>()
const open = ref(false)
const contentStyle = useModalLiftedZIndex(open)
</script>
<template>
<PopoverRoot v-slot="{ close }">
<PopoverRoot v-slot="{ close }" v-model:open="open">
<PopoverTrigger as-child>
<slot name="button">
<Button size="icon">
@@ -43,6 +49,7 @@ const {
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
:style="contentStyle"
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<slot :close>

View File

@@ -7,6 +7,7 @@ import {
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import type { HSVA } from '@/utils/colorUtil'
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -60,6 +61,7 @@ const previewColor = computed(() => {
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
const contentStyle = useModalLiftedZIndex(isOpen)
</script>
<template>
@@ -116,6 +118,7 @@ const isOpen = ref(false)
:side-offset="7"
:collision-padding="10"
class="z-1700"
:style="contentStyle"
>
<ColorPickerPanel
v-model:hsva="hsva"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import { DialogOverlay } from 'reka-ui'
import { DialogOverlay, Presence, injectDialogRootContext } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
@@ -8,16 +8,27 @@ import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogOverlayProps & { class?: HTMLAttributes['class'] }
>()
// Reka renders DialogOverlay only for modal dialogs; non-modal dialogs still
// need the scrim, so render a plain backdrop for them.
const rootContext = injectDialogRootContext()
const overlayClass =
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0'
</script>
<template>
<DialogOverlay
v-if="rootContext.modal.value"
v-bind="delegated"
:class="
cn(
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
customClass
)
"
data-testid="dialog-overlay"
:class="cn(overlayClass, customClass)"
/>
<Presence v-else :present="delegated.forceMount || rootContext.open.value">
<div
:data-state="rootContext.open.value ? 'open' : 'closed'"
data-testid="dialog-overlay"
:class="cn(overlayClass, customClass)"
/>
</Presence>
</template>

View File

@@ -0,0 +1,23 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Ref } from 'vue'
import { computed } from 'vue'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
/**
* Inline z-index style for body-portaled popover/menu content. Such content
* keeps its static `z-1700` class unless a dialog that joined @primeuix's
* auto-incrementing 'modal' counter (Reka and PrimeVue dialogs both do, via
* `v-reka-z-index` or PrimeVue's mask) is open above it; then lift past that
* dialog so the content isn't hidden behind the dialog or its scrim.
*/
export function useModalLiftedZIndex(open: Ref<boolean>) {
return computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX
? { zIndex: topZIndex + 1 }
: undefined
})
}

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTextFileContent } from '@/composables/useTextFileContent'
function stubFetch(response: Partial<Response> | Error) {
const mock =
response instanceof Error
? vi.fn().mockRejectedValue(response)
: vi.fn().mockResolvedValue(response)
vi.stubGlobal('fetch', mock)
return mock
}
describe(useTextFileContent, () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns inline content without fetching', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent } = useTextFileContent(() => ({
content: 'inline text',
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches text from the url when no inline content is present', async () => {
const fetchMock = stubFetch({
ok: true,
text: () => Promise.resolve('fetched text')
})
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
expect(hasError.value).toBe(false)
})
it('flags an error for a non-ok response', async () => {
stubFetch({ ok: false })
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/missing.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
expect(textContent.value).toBe('')
})
it('flags an error when the fetch rejects', async () => {
stubFetch(new Error('network down'))
const { hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
})
it('resolves empty content when there is no source', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent, isLoading } = useTextFileContent(() => undefined)
await vi.waitFor(() => expect(isLoading.value).toBe(false))
expect(textContent.value).toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,40 @@
import { computedAsync } from '@vueuse/core'
import { ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface TextSource {
content?: string
url?: string
}
export function useTextFileContent(
source: MaybeRefOrGetter<TextSource | undefined>
) {
const isLoading = ref(false)
const hasError = ref(false)
const textContent = computedAsync(
async () => {
hasError.value = false
const { content, url } = toValue(source) ?? {}
if (content !== undefined) return content
if (!url) return ''
const response = await fetch(url)
if (!response.ok) {
hasError.value = true
return ''
}
return await response.text()
},
'',
{
evaluating: isLoading,
onError: () => {
hasError.value = true
}
}
)
return { textContent, isLoading, hasError }
}

View File

@@ -1,5 +1,6 @@
{
"supports_preview_metadata": true,
"supports_manager_v4_ui": true,
"supports_progress_text_metadata": true
"supports_progress_text_metadata": true,
"supports_interactions_v1": true
}

View File

@@ -21,6 +21,7 @@ if (!isCloud) {
import './noteNode'
import './painter'
import './previewAny'
import './saveText'
import './rerouteNode'
import './saveImageExtraOutput'
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)

View File

@@ -1,6 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const { addTextPreviewWidgets, updateTextPreviewWidgets } = vi.hoisted(() => ({
addTextPreviewWidgets: vi.fn(),
updateTextPreviewWidgets: vi.fn()
}))
vi.mock('@/extensions/core/textPreviewWidgets', () => ({
addTextPreviewWidgets,
updateTextPreviewWidgets
}))
const capturedExtensions: ComfyExtension[] = []
@@ -12,103 +23,51 @@ vi.mock('@/services/extensionService', () => ({
})
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
type BeforeRegister = NonNullable<ComfyExtension['beforeRegisterNodeDef']>
interface MockWidget {
name: string
options: Record<string, unknown>
element: { readOnly: boolean }
callback?: (value: unknown) => void
value: unknown
hidden: boolean
label: string
serialize?: boolean
}
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
const createdWidgets: MockWidget[] = []
const nodeType = { prototype: {} } as unknown as Parameters<BeforeRegister>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<BeforeRegister>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<BeforeRegister>[2]
)
vi.mock('@/scripts/widgets', () => {
const create =
(kind: string) =>
(
node: { widgets?: MockWidget[] },
name: string,
_info: unknown,
_app: unknown
) => {
const widget: MockWidget = {
name,
options: {},
element: { readOnly: false },
value: kind === 'BOOLEAN' ? false : '',
hidden: false,
label: ''
}
node.widgets = node.widgets ?? []
node.widgets.push(widget)
createdWidgets.push(widget)
return { widget }
}
return {
ComfyWidgets: {
MARKDOWN: create('MARKDOWN'),
STRING: create('STRING'),
BOOLEAN: create('BOOLEAN')
}
const node = {} as LGraphNode
const proto = nodeType.prototype as {
onNodeCreated?: () => void
onExecuted?: (message: { text?: string }) => void
}
})
return { node, proto }
}
describe('PreviewAny extension', () => {
beforeEach(async () => {
capturedExtensions.length = 0
createdWidgets.length = 0
addTextPreviewWidgets.mockClear()
updateTextPreviewWidgets.mockClear()
vi.resetModules()
await import('./previewAny')
})
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
it('adds the shared text preview widgets on node creation', async () => {
const { node, proto } = await setupNode()
const nodeType = { prototype: {} } as unknown as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
)
const node: { widgets?: MockWidget[] } = {}
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return node
}
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
await setupNode()
expect(addTextPreviewWidgets).toHaveBeenCalledWith(node)
})
const previewMarkdown = createdWidgets.find(
(w) => w.name === 'preview_markdown'
)
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
it('updates the preview with executed text', async () => {
const { node, proto } = await setupNode()
const message = { text: 'hello' }
expect(previewMarkdown).toBeDefined()
expect(previewText).toBeDefined()
expect(previewMode).toBeDefined()
proto.onExecuted!.call(node, message)
// widget.options.serialize === false is what executionUtil.graphToPrompt
// checks to exclude a widget from the API prompt sent to the backend.
// Without this, post-execution widget value updates (the rendered preview
// text) get serialized as inputs, change the cache signature, and cause
// the node to re-execute on the next prompt.
expect(previewMarkdown!.options.serialize).toBe(false)
expect(previewText!.options.serialize).toBe(false)
expect(previewMode!.options.serialize).toBe(false)
expect(updateTextPreviewWidgets).toHaveBeenCalledWith(node, message)
})
})

View File

@@ -4,10 +4,11 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
*/
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { type DOMWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
@@ -16,82 +17,18 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (nodeData.name === 'PreviewAny') {
const onNodeCreated = nodeType.prototype.onNodeCreated
if (nodeData.name !== 'PreviewAny') return
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview_markdown',
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showValueWidgetPlain = ComfyWidgets['STRING'](
this,
'preview_text',
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
this,
'previewMode',
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
],
app
)
showAsPlaintextWidget.widget.callback = (value: boolean) => {
showValueWidget.hidden = !value
showValueWidget.options.hidden = !value
showValueWidgetPlain.hidden = value
showValueWidgetPlain.options.hidden = value
}
showValueWidget.label = 'Preview'
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.options.serialize = false
showValueWidget.element.readOnly = true
showValueWidget.serialize = false
showValueWidgetPlain.label = 'Preview'
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.options.serialize = false
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.serialize = false
// The previewMode toggle is a frontend-only display preference and
// is not declared in the backend INPUT_TYPES, so it must not be
// serialized into the API prompt (would alter the cache signature).
showAsPlaintextWidget.widget.options.serialize = false
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted === null || onExecuted === void 0
? void 0
: onExecuted.apply(this, [message])
const previewWidgets =
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''
previewWidget.value = Array.isArray(text)
? (text?.join('\n\n') ?? '')
: text
}
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,24 @@
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.saveText',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'SaveText') return
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
interface MockWidget {
name: string
options: Record<string, unknown>
value?: unknown
serialize?: boolean
}
vi.mock('@/scripts/app', () => ({ app: { rootGraph: { id: 'graph-1' } } }))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
resolveNodeRootGraphId: () => 'graph-1'
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue',
() => ({
default: {}
})
)
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => undefined })
}))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: class {
name: string
options: Record<string, unknown>
type: string
serialize?: boolean
constructor(obj: {
name: string
options: Record<string, unknown>
type: string
}) {
this.name = obj.name
this.options = obj.options
this.type = obj.type
}
},
addWidget: (node: { widgets?: MockWidget[] }, widget: MockWidget) => {
node.widgets = node.widgets ?? []
node.widgets.push(widget)
}
}))
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
BOOLEAN: (node: { widgets?: MockWidget[] }, name: string) => {
const widget: MockWidget = { name, options: {}, value: false }
node.widgets = node.widgets ?? []
node.widgets.push(widget)
return { widget }
}
}
}))
const { addTextPreviewWidgets, updateTextPreviewWidgets } =
await import('./textPreviewWidgets')
function makeNode(): LGraphNode & { widgets: MockWidget[] } {
return { id: '1', widgets: [] } as unknown as LGraphNode & {
widgets: MockWidget[]
}
}
describe('addTextPreviewWidgets', () => {
it('adds a non-serialized preview widget and a non-serialized mode toggle', () => {
const node = makeNode()
addTextPreviewWidgets(node)
const preview = node.widgets.find((w) => w.name === 'preview_text')
const mode = node.widgets.find((w) => w.name === 'preview_mode')
expect(preview?.type).toBe('textPreview')
expect(preview?.serialize).toBe(false)
expect(preview?.options.serialize).toBe(false)
expect(mode?.options.serialize).toBe(false)
})
})
describe('updateTextPreviewWidgets', () => {
let node: LGraphNode & { widgets: MockWidget[] }
beforeEach(() => {
node = makeNode()
node.widgets.push({ name: 'preview_text', options: {}, value: '' })
})
it('joins array text into the preview widget value', () => {
updateTextPreviewWidgets(node, { text: ['a', 'b'] })
expect(node.widgets[0].value).toBe('a\n\nb')
})
it('writes a plain string message as-is', () => {
updateTextPreviewWidgets(node, { text: 'hello' })
expect(node.widgets[0].value).toBe('hello')
})
})

View File

@@ -0,0 +1,78 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const PREVIEW_WIDGET_NAME = 'preview_text'
const MODE_WIDGET_NAME = 'preview_mode'
const inputSpecTextPreview: CustomInputSpec = {
name: PREVIEW_WIDGET_NAME,
type: 'TEXT_PREVIEW',
isPreview: true
}
export function addTextPreviewWidgets(node: LGraphNode) {
const widgetStore = useWidgetValueStore()
let fallbackValue = ''
const previewWidgetId = () =>
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
PREVIEW_WIDGET_NAME
)
const preview = new ComponentWidgetImpl<string | object>({
node,
name: PREVIEW_WIDGET_NAME,
component: WidgetTextPreview,
inputSpec: inputSpecTextPreview,
type: 'textPreview',
options: {
serialize: false,
hideInPanel: true,
getMinHeight: () => 60,
getValue: () => {
const stored = widgetStore.getWidget(previewWidgetId())?.value
return typeof stored === 'string' ? stored : fallbackValue
},
setValue: (value: string | object) => {
fallbackValue = typeof value === 'string' ? value : ''
const state = widgetStore.getWidget(previewWidgetId())
if (state) state.value = fallbackValue
}
}
})
preview.serialize = false
addWidget(node, preview)
const modeWidget = ComfyWidgets['BOOLEAN'](
node,
MODE_WIDGET_NAME,
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plain text', default: false }
],
app
).widget
modeWidget.options.serialize = false
modeWidget.serialize = false
}
export function updateTextPreviewWidgets(
node: LGraphNode,
message: { text?: string | string[] }
) {
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
if (!preview) return
const text = message.text ?? ''
preview.value = Array.isArray(text) ? text.join('\n\n') : text
}

View File

@@ -306,6 +306,20 @@ describe('Graph Clearing and Callbacks', () => {
expect(graph.nodes.length).toBe(0)
})
test('clear() calls onRemoved() for nodes in subgraph definitions', () => {
const graph = new LGraph()
const subgraph = createTestSubgraph({ rootGraph: graph })
const innerNode = new LGraphNode('InnerNode')
const onRemoved = vi.fn()
innerNode.onRemoved = onRemoved
subgraph.add(innerNode)
graph.subgraphs.set(subgraph.id, subgraph)
graph.clear()
expect(onRemoved).toHaveBeenCalledOnce()
})
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -409,6 +409,11 @@ export class LGraph
// used to detect changes
this._version = -1
for (const subgraph of this._subgraphs.values()) {
for (const node of subgraph.nodes) {
fireNodeRemovalLifecycle(node)
}
}
this._subgraphs.clear()
// safe clear

View File

@@ -45,6 +45,7 @@
"errorLoadingImage": "Error loading image",
"errorLoadingVideo": "Error loading video",
"failedToDownloadImage": "Failed to download image",
"failedToDownloadFile": "Failed to download file",
"failedToDownloadVideo": "Failed to download video",
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
@@ -58,6 +59,7 @@
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"textFailedToLoad": "Text failed to load",
"liveSamplingPreview": "Live sampling preview",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
@@ -3883,6 +3885,11 @@
"errors": "Errors",
"noErrors": "No errors",
"errorsDetected": "Error detected | Errors detected",
"selectedNodeErrors": "{node} — {count} error | {node} — {count} errors",
"selectedNodesErrors": "{nodes} nodes selected — {count} error | {nodes} nodes selected — {count} errors",
"errorNodeSummary": "{nodes} node — {count} error | {nodes} node — {count} errors",
"errorNodesSummary": "{nodes} nodes — {count} error | {nodes} nodes — {count} errors",
"errorsSummary": "{count} error | {count} errors",
"resolveBeforeRun": "Resolve before running the workflow",
"expand": "Expand",
"collapse": "Collapse",
@@ -4396,7 +4403,11 @@
"name": "Name",
"namePlaceholder": "e.g., My API Key",
"provider": "Provider",
"providerHint": "Optional. Selecting a provider enables automatic token usage.",
"providerHint": "Select a provider to enable automatic token usage.",
"providerHelp": {
"runway": "Enter your Runway API key. You can find it in your Runway account settings under API keys.",
"gemini": "Enter your Google Gemini API key. You can generate one in Google AI Studio."
},
"secretValue": "Secret Value",
"secretValuePlaceholder": "Enter your API key",
"secretValuePlaceholderEdit": "Enter new value to change",

View File

@@ -6,6 +6,7 @@ import { defineComponent, nextTick, onMounted, ref } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as FormatUtil from '@/utils/formatUtil'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
@@ -21,14 +22,11 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
vi.mock('@/utils/formatUtil', async (importOriginal) => ({
...(await importOriginal<typeof FormatUtil>()),
isPreviewableMediaType: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
}))
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAssets: vi.fn(),
@@ -105,7 +103,7 @@ interface MediaAssetContextMenuExposed {
let capturedRef: MediaAssetContextMenuExposed | null = null
function mountComponent() {
function mountComponent(targetAsset: AssetItem = asset) {
const onHide = vi.fn()
const { container, unmount } = render(
defineComponent({
@@ -115,7 +113,7 @@ function mountComponent() {
onMounted(() => {
capturedRef = menuRef.value
})
return { menuRef, asset, onHide }
return { menuRef, asset: targetAsset, onHide }
},
template:
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
@@ -151,10 +149,12 @@ type MenuItemWithCommand = MenuItem & {
command: NonNullable<MenuItem['command']>
}
function findMenuItem(label: string): MenuItem | undefined {
return capturedMenu.model.find((item) => item.label === label)
}
function findDownloadMenuItem(): MenuItemWithCommand {
const downloadItem = capturedMenu.model.find(
(item) => item.label === 'mediaAsset.actions.download'
)
const downloadItem = findMenuItem('mediaAsset.actions.download')
if (!downloadItem?.command) {
throw new Error('Download menu item or command was not registered')
}
@@ -184,6 +184,31 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('shows insert-as-node for assets with a loader node', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)
expect(
findMenuItem('mediaAsset.actions.insertAsNodeInWorkflow')
).toBeDefined()
unmount()
})
it('hides insert-as-node for text assets without a loader node', async () => {
const { container, unmount } = mountComponent({
...asset,
name: 'result.txt'
})
await showMenu(container)
expect(
findMenuItem('mediaAsset.actions.insertAsNodeInWorkflow')
).toBeUndefined()
unmount()
})
it('routes Download through downloadAssets so multi-output jobs zip', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)

View File

@@ -93,16 +93,10 @@ useDismissableOverlay({
})
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType === 'output') return true
// Input assets: check if file type is supported by loader nodes
if (assetType === 'input' && asset?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
}
return false
// Only file types with a loader node (image/video/audio) can be inserted
if (!asset?.name) return false
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
})
const showWorkflowActions = computed(() => {

View File

@@ -30,7 +30,8 @@ const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D'
'3d': 'sideToolbar.mediaAssets.filter3D',
text: 'sideToolbar.mediaAssets.filterText'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
@@ -38,11 +39,11 @@ function getCheckbox(type: keyof typeof labelByType): HTMLElement {
}
describe('MediaAssetFilterMenu', () => {
it('renders all four media-type checkboxes', () => {
it('renders all media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(4)
expect(checkboxes).toHaveLength(5)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}

View File

@@ -54,7 +54,8 @@ const filters = [
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' }
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' },
{ type: 'text', label: 'sideToolbar.mediaAssets.filterText' }
]
const toggleMediaType = (type: string) => {

View File

@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTextTop from './MediaTextTop.vue'
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'result.txt',
tags: [],
kind: 'text',
src: 'http://example.com/result.txt',
...overrides
} as AssetMeta
}
describe('MediaTextTop', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('shows a snippet of the fetched text', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('hello world')
})
vi.stubGlobal('fetch', fetchMock)
render(MediaTextTop, { props: { asset: makeAsset() } })
expect(await screen.findByText('hello world')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('http://example.com/result.txt')
})
it('prefers preview_url over src', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('preview text')
})
vi.stubGlobal('fetch', fetchMock)
render(MediaTextTop, {
props: {
asset: makeAsset({ preview_url: 'http://server/preview.txt' })
}
})
expect(await screen.findByText('preview text')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('http://server/preview.txt')
})
})

View File

@@ -1,9 +1,37 @@
<template>
<div class="relative size-full overflow-hidden rounded-sm">
<div
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
class="size-full bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
>
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
<p
v-if="snippet"
class="m-0 size-full overflow-hidden p-2 text-left text-xs/4 wrap-break-word whitespace-pre-wrap text-base-foreground"
>
{{ snippet }}
</p>
<div v-else class="flex size-full items-center justify-center">
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const MAX_SNIPPET_LENGTH = 1000
const { asset } = defineProps<{
asset: AssetMeta
}>()
const { textContent } = useTextFileContent(() => ({
url: asset.preview_url || asset.src
}))
const snippet = computed(() => textContent.value.slice(0, MAX_SNIPPET_LENGTH))
</script>

View File

@@ -73,6 +73,26 @@ describe('useSessionCookie', () => {
expect(globalThis.fetch).not.toHaveBeenCalled()
})
it('createSession coalesces concurrent callers into one POST', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
let resolveFetch: (value: Response) => void = () => {}
vi.mocked(globalThis.fetch).mockReturnValue(
new Promise<Response>((resolve) => {
resolveFetch = resolve
})
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
const { createSession } = useSessionCookie()
const first = createSession()
const second = createSession()
resolveFetch(new Response(null, { status: 204 }))
await Promise.all([first, second])
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
})
it('createSessionOrThrow fails fast on non-success responses', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(

View File

@@ -3,6 +3,9 @@ import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
// Coalesce concurrent rotations (token-refresh bursts) into one POST.
let inFlightCreateSession: Promise<void> | null = null
/**
* Session cookie management for cloud authentication.
* Creates and deletes session cookies on the ComfyUI server.
@@ -48,7 +51,14 @@ export const useSessionCookie = () => {
*/
const createSession = async (): Promise<void> => {
if (!isCloud) return
if (inFlightCreateSession) return inFlightCreateSession
inFlightCreateSession = performCreateSession().finally(() => {
inFlightCreateSession = null
})
return inFlightCreateSession
}
const performCreateSession = async (): Promise<void> => {
const { flags } = useFeatureFlags()
try {
const authStore = useAuthStore()

View File

@@ -319,6 +319,44 @@ describe('useSubscription', () => {
await expect(fetchStatus()).rejects.toThrow()
})
it('coalesces concurrent callers into one fetch', async () => {
let resolveFetch: (value: Response) => void = () => {}
vi.mocked(global.fetch).mockReturnValue(
new Promise<Response>((resolve) => {
resolveFetch = resolve
})
)
const { fetchStatus } = useSubscriptionWithScope()
const first = fetchStatus()
const second = fetchStatus()
resolveFetch({
ok: true,
json: async () => ({ is_active: true })
} as Response)
await Promise.all([first, second])
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('does not downgrade known-good status on a failed fetch', async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ is_active: true, subscription_id: 'sub_1' })
} as Response)
const { fetchStatus, isActiveSubscription } = useSubscriptionWithScope()
await fetchStatus()
expect(isActiveSubscription.value).toBe(true)
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Invalid token' })
} as Response)
await fetchStatus().catch(() => {})
expect(isActiveSubscription.value).toBe(true)
})
})
describe('subscribe', () => {
@@ -720,7 +758,6 @@ describe('useSubscription', () => {
}
vi.mocked(global.fetch)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(cancelledResponse as Response)
@@ -764,7 +801,6 @@ describe('useSubscription', () => {
}
vi.mocked(global.fetch)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(cancelledResponse as Response)

View File

@@ -313,11 +313,19 @@ function useSubscriptionInternal() {
}
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
// Coalesce concurrent callers so an auth/session-rotation burst mints one fetch.
let inFlightStatusFetch: Promise<CloudSubscriptionStatusResponse | null> | null =
null
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
if (inFlightStatusFetch) return inFlightStatusFetch
inFlightStatusFetch = performFetchSubscriptionStatus().finally(() => {
inFlightStatusFetch = null
})
return inFlightStatusFetch
}
async function performFetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetchWithUnifiedRemint(

View File

@@ -9,9 +9,22 @@
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
:aria-current="
highlightedNodeIds?.has(item.nodeId) ? 'true' : undefined
"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<!-- Emphasis lives on an inner element: the li is a TransitionGroup
child, and the emphasis transition-property would override the
list-scale move/enter/leave transitions. -->
<div
:class="
cn(
'flex min-w-0 items-center gap-2',
selectionEmphasisClass(highlightedNodeIds?.has(item.nodeId))
)
"
>
<span class="flex min-w-0 flex-1">
<button
type="button"
@@ -44,8 +57,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
@@ -56,6 +71,8 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -152,8 +152,12 @@ describe('resolveMissingMediaAssetSources', () => {
it('stops reading cloud output asset pages once all requested names are found', async () => {
const target = 'target-output.png'
const outputAsset = makeAsset('ComfyUI_00001_.png', target)
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
makeAssetPage([outputAsset], {
hasMore: true,
total: 501
})
)
const result = await resolveMissingMediaAssetSources({
@@ -163,10 +167,56 @@ describe('resolveMissingMediaAssetSources', () => {
allowCompactSuffix: true
})
expect(result.generatedAssets).toEqual([makeAsset(target)])
expect(result.generatedAssets).toEqual([outputAsset])
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
})
it('stops reading cloud output asset pages when a flat target matches by name', async () => {
const target = 'ComfyUI_00001_.mp4'
const outputAsset = makeAsset(target, 'different-output-hash.mp4')
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([outputAsset], {
hasMore: true,
total: 501
})
)
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
allowCompactSuffix: true
})
expect(result.generatedAssets).toEqual([outputAsset])
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
})
it('does not stop cloud output asset paging on a flat asset name collision', async () => {
const target = 'target-output.mp4'
const collidingNameAsset = makeAsset(target)
const matchingHashAsset = makeAsset('ComfyUI_00001_.mp4', target)
mockGetAssetsPageByTag
.mockResolvedValueOnce(
makeAssetPage([collidingNameAsset], { hasMore: true, total: 501 })
)
.mockResolvedValueOnce(makeAssetPage([matchingHashAsset]))
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
generatedHashRequiredNames: new Set([target]),
allowCompactSuffix: true
})
expect(result.generatedAssets).toEqual([
collidingNameAsset,
matchingHashAsset
])
expect(mockGetAssetsPageByTag).toHaveBeenCalledTimes(2)
})
it('aborts cloud output asset loading when input asset loading fails', async () => {
const inputError = new Error('input failed')
let rejectInputAssets!: (err: Error) => void

View File

@@ -23,6 +23,7 @@ export interface ResolveMissingMediaAssetSourcesOptions {
isCloud: boolean
includeGeneratedAssets: boolean
generatedMatchNames: ReadonlySet<string>
generatedHashRequiredNames?: ReadonlySet<string>
allowCompactSuffix: boolean
}
@@ -35,6 +36,7 @@ export async function resolveMissingMediaAssetSources({
isCloud,
includeGeneratedAssets,
generatedMatchNames,
generatedHashRequiredNames = new Set<string>(),
allowCompactSuffix
}: ResolveMissingMediaAssetSourcesOptions): Promise<MissingMediaAssetSources> {
const pathOptions = { allowCompactSuffix }
@@ -60,6 +62,7 @@ export async function resolveMissingMediaAssetSources({
? fetchGeneratedAssets(controller.signal, {
isCloud,
generatedMatchNames,
generatedHashRequiredNames,
pathOptions
})
: Promise.resolve<AssetItem[]>([]),
@@ -76,6 +79,7 @@ export async function resolveMissingMediaAssetSources({
interface FetchGeneratedAssetsOptions {
isCloud: boolean
generatedMatchNames: ReadonlySet<string>
generatedHashRequiredNames: ReadonlySet<string>
pathOptions: MediaPathDetectionOptions
}
@@ -98,12 +102,18 @@ export function getAssetDetectionNames(
async function fetchGeneratedAssets(
signal: AbortSignal | undefined,
{ isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
{
isCloud,
generatedMatchNames,
generatedHashRequiredNames,
pathOptions
}: FetchGeneratedAssetsOptions
): Promise<AssetItem[]> {
if (isCloud) {
return await fetchCloudGeneratedAssets(
signal,
generatedMatchNames,
generatedHashRequiredNames,
pathOptions
)
}
@@ -118,6 +128,7 @@ async function fetchGeneratedAssets(
async function fetchCloudGeneratedAssets(
signal: AbortSignal | undefined,
targetNames: ReadonlySet<string>,
hashRequiredNames: ReadonlySet<string>,
pathOptions: MediaPathDetectionOptions
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
@@ -140,9 +151,10 @@ async function fetchCloudGeneratedAssets(
for (const asset of batch) {
assets.push(asset)
rememberResolvedTargetNames(
rememberResolvedCloudTargetNames(
asset,
targetNames,
hashRequiredNames,
foundTargetNames,
pathOptions
)
@@ -262,6 +274,28 @@ function rememberResolvedTargetNames(
}
}
function rememberResolvedCloudTargetNames(
asset: AssetItem,
targetNames: ReadonlySet<string>,
hashRequiredNames: ReadonlySet<string>,
foundTargetNames: Set<string>,
options: MediaPathDetectionOptions
) {
if (targetNames.size === 0) return
if (asset.hash) {
for (const name of getMediaPathDetectionNames(asset.hash, options)) {
if (targetNames.has(name)) foundTargetNames.add(name)
}
}
for (const name of getAssetDetectionNames(asset, options)) {
if (!hashRequiredNames.has(name) && targetNames.has(name)) {
foundTargetNames.add(name)
}
}
}
function hasResolvedAllTargetNames(
targetNames: ReadonlySet<string>,
foundTargetNames: ReadonlySet<string>

View File

@@ -559,6 +559,7 @@ describe('verifyMediaCandidates', () => {
isCloud: true,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
generatedHashRequiredNames: new Set(),
allowCompactSuffix: true
})
})
@@ -652,6 +653,7 @@ describe('verifyMediaCandidates', () => {
generatedMatchNames: new Set([
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
]),
generatedHashRequiredNames: new Set(),
allowCompactSuffix: true
})
expect(candidates[0]).toMatchObject({
@@ -660,6 +662,88 @@ describe('verifyMediaCandidates', () => {
})
})
it('matches cloud output videos with history subfolders against flat asset hashes', async () => {
const outputHash = 'cloud-video-hash.mp4'
const candidates = [
makeCandidate('1', `video/${outputHash} [output]`, {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
})
]
const resolveAssetSources = makeAssetResolver(
[],
[makeAsset('ComfyUI_00001_.mp4', outputHash)]
)
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([outputHash]),
generatedHashRequiredNames: new Set([outputHash]),
allowCompactSuffix: true
})
expect(candidates[0]).toMatchObject({
name: `video/${outputHash} [output]`,
isMissing: false
})
})
it('does not match subfoldered cloud output media against unrelated flat asset names', async () => {
const outputHash = 'cloud-video-hash.mp4'
const candidates = [
makeCandidate('1', `video/${outputHash} [output]`, {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
})
]
const resolveAssetSources = makeAssetResolver([], [makeAsset(outputHash)])
await verifyMediaCandidates(candidates, {
isCloud: true,
resolveAssetSources
})
expect(candidates[0]).toMatchObject({
name: `video/${outputHash} [output]`,
isMissing: true
})
})
it('stops cloud output paging after a subfoldered candidate matches a flat asset hash', async () => {
const outputHash = 'cloud-video-hash.mp4'
const candidates = [
makeCandidate('1', `video/${outputHash} [output]`, {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
isMissing: undefined
})
]
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([makeAsset('ComfyUI_00001_.mp4', outputHash)], {
hasMore: true
})
)
await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
expect(candidates[0]).toMatchObject({
name: `video/${outputHash} [output]`,
isMissing: false
})
})
it('does not satisfy output annotations with input assets of the same name', async () => {
const candidates = [
makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
@@ -733,6 +817,7 @@ describe('verifyMediaCandidates', () => {
isCloud: false,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
generatedHashRequiredNames: new Set(),
allowCompactSuffix: false
})
expect(candidates[0].isMissing).toBe(true)

View File

@@ -135,6 +135,11 @@ interface MediaVerificationOptions {
resolveAssetSources?: MissingMediaAssetResolver
}
interface GeneratedCandidateMatchNames {
names: Set<string>
hashRequiredNames: Set<string>
}
/**
* Verify media candidates against assets available to the current runtime.
*
@@ -165,6 +170,7 @@ export async function verifyMediaCandidates(
const pathOptions = { allowCompactSuffix: isCloud }
const generatedMatchNames = getGeneratedCandidateMatchNames(
pending,
isCloud,
pathOptions
)
@@ -174,8 +180,9 @@ export async function verifyMediaCandidates(
const assetSources = await resolveAssetSources({
signal,
isCloud,
includeGeneratedAssets: generatedMatchNames.size > 0,
generatedMatchNames,
includeGeneratedAssets: generatedMatchNames.names.size > 0,
generatedMatchNames: generatedMatchNames.names,
generatedHashRequiredNames: generatedMatchNames.hashRequiredNames,
allowCompactSuffix: isCloud
})
inputAssets = assetSources.inputAssets
@@ -189,37 +196,58 @@ export async function verifyMediaCandidates(
const inputAssetIdentifiers = new Set<string>()
const outputAssetIdentifiers = new Set<string>()
const outputAssetHashIdentifiers = new Set<string>()
addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
addAssetHashIdentifiers(
outputAssetHashIdentifiers,
generatedAssets,
pathOptions
)
for (const candidate of pending) {
const detectionNames = getMediaPathDetectionNames(
candidate.name,
pathOptions
)
const type = getAnnotatedMediaPathTypeForDetection(
candidate.name,
pathOptions
)
const identifiers =
type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
const isOutputCandidate = type === 'output'
const identifiers = isOutputCandidate
? outputAssetIdentifiers
: inputAssetIdentifiers
candidate.isMissing = !isCandidateResolved(
candidate,
identifiers,
isOutputCandidate,
isCloud,
outputAssetHashIdentifiers,
pathOptions
)
}
}
function getGeneratedCandidateMatchNames(
candidates: MissingMediaCandidate[],
isCloud: boolean,
pathOptions: { allowCompactSuffix: boolean }
): Set<string> {
): GeneratedCandidateMatchNames {
const names = new Set<string>()
const hashRequiredNames = new Set<string>()
for (const candidate of candidates) {
if (!isGeneratedCandidate(candidate, pathOptions)) continue
names.add(
normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
const normalized = normalizeAnnotatedMediaPathForDetection(
candidate.name,
pathOptions
)
const lookupName = isCloud ? getMediaPathBasename(normalized) : normalized
names.add(lookupName)
if (isCloud && lookupName !== normalized) {
hashRequiredNames.add(lookupName)
}
}
return names
return { names, hashRequiredNames }
}
function isGeneratedCandidate(
@@ -233,6 +261,34 @@ function isGeneratedCandidate(
return type === 'output'
}
function isCandidateResolved(
candidate: MissingMediaCandidate,
identifiers: ReadonlySet<string>,
isOutputCandidate: boolean,
isCloud: boolean,
outputAssetHashIdentifiers: ReadonlySet<string>,
pathOptions: { allowCompactSuffix: boolean }
): boolean {
const detectionNames = getMediaPathDetectionNames(candidate.name, pathOptions)
if (detectionNames.some((name) => identifiers.has(name))) return true
if (!isOutputCandidate || !isCloud) return false
const normalized = normalizeAnnotatedMediaPathForDetection(
candidate.name,
pathOptions
)
const basename = getMediaPathBasename(normalized)
return basename !== normalized && outputAssetHashIdentifiers.has(basename)
}
function getMediaPathBasename(value: string): string {
const separatorIndex = Math.max(
value.lastIndexOf('/'),
value.lastIndexOf('\\')
)
return separatorIndex === -1 ? value : value.slice(separatorIndex + 1)
}
function addAssetIdentifiers(
identifiers: Set<string>,
assets: AssetItem[],
@@ -245,6 +301,19 @@ function addAssetIdentifiers(
}
}
function addAssetHashIdentifiers(
identifiers: Set<string>,
assets: AssetItem[],
pathOptions: { allowCompactSuffix: boolean }
) {
for (const asset of assets) {
if (!asset.hash) continue
for (const name of getMediaPathDetectionNames(asset.hash, pathOptions)) {
identifiers.add(name)
}
}
}
/** Group confirmed-missing candidates by file name into view models. */
export function groupCandidatesByName(
candidates: MissingMediaCandidate[]

View File

@@ -3,7 +3,7 @@
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden"
class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5"
>
<MissingModelRow
v-for="row in importableModelRows"
@@ -12,6 +12,7 @@
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="true"
:highlighted="isRowHighlighted(row)"
@locate-model="emit('locateModel', $event)"
/>
</div>
@@ -36,6 +37,7 @@
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="false"
:highlighted="isRowHighlighted(row)"
@locate-model="emit('locateModel', $event)"
/>
</div>
@@ -86,8 +88,10 @@ const MODEL_TYPE_SORT_ORDER = [
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
const { missingModelGroups, highlightedNodeIds } = defineProps<{
missingModelGroups: MissingModelGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{
@@ -172,4 +176,11 @@ function getModelTypeSortIndex(directory: string | null) {
function canCloudImport(row: MissingModelRowEntry) {
return row.isAssetSupported && row.directory !== null
}
function isRowHighlighted(row: MissingModelRowEntry) {
if (!highlightedNodeIds?.size) return false
return row.model.referencingNodes.some((ref) =>
highlightedNodeIds.has(String(ref.nodeId))
)
}
</script>

View File

@@ -1,6 +1,14 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<Button
v-if="hasMultipleReferences"
data-testid="missing-model-expand"
@@ -191,6 +199,8 @@ import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
@@ -217,12 +227,15 @@ const {
model,
directory,
isAssetSupported,
canCloudImport = true
canCloudImport = true,
highlighted
} = defineProps<{
model: MissingModelViewModel
directory: string | null
isAssetSupported: boolean
canCloudImport?: boolean
/** Emphasize the header row (model referenced by the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,14 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<Button
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-expand"
@@ -153,14 +161,18 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group } = defineProps<{
const { group, highlighted } = defineProps<{
group: SwapNodeGroup
/** Emphasize the header row (group containing the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -4,6 +4,9 @@
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:highlighted="
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
"
@locate-node="emit('locate-node', $event)"
@replace="emit('replace', $event)"
/>
@@ -11,11 +14,14 @@
</template>
<script setup lang="ts">
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { swapNodeGroups } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listSecretProviders } from './secretsApi'
const mockFetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
function jsonResponse(body: unknown, init: Partial<Response> = {}): Response {
return {
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(body),
...init
} as Response
}
describe('listSecretProviders', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('requests the providers endpoint and maps ids', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ data: [{ id: 'huggingface' }, { id: 'civitai' }] })
)
const providers = await listSecretProviders()
expect(mockFetchApi).toHaveBeenCalledWith('/secrets/providers')
expect(providers).toEqual(['huggingface', 'civitai'])
})
it('returns an empty list when data is missing', async () => {
mockFetchApi.mockResolvedValue(jsonResponse({}))
const providers = await listSecretProviders()
expect(providers).toEqual([])
})
it('throws SecretsApiError on a failed response', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse(
{ message: 'unavailable' },
{ ok: false, status: 503, statusText: 'Service Unavailable' }
)
)
await expect(listSecretProviders()).rejects.toMatchObject({
name: 'SecretsApiError',
status: 503,
message: 'unavailable'
})
})
})

View File

@@ -1,3 +1,8 @@
import type {
SecretListResponse,
SecretProvidersResponse
} from '@comfyorg/ingest-types'
import { api } from '@/scripts/api'
import type {
@@ -8,10 +13,6 @@ import type {
} from '../types'
import { SECRET_ERROR_CODES } from '../types'
interface ListSecretsResponse {
data: SecretMetadata[]
}
interface ErrorResponse {
message?: string
code?: string
@@ -52,10 +53,16 @@ async function handleResponse<T>(response: Response): Promise<T> {
export async function listSecrets(): Promise<SecretMetadata[]> {
const response = await api.fetchApi('/secrets')
const data = await handleResponse<ListSecretsResponse>(response)
const data = await handleResponse<SecretListResponse>(response)
return data.data
}
export async function listSecretProviders(): Promise<string[]> {
const response = await api.fetchApi('/secrets/providers')
const data = await handleResponse<SecretProvidersResponse>(response)
return (data.data ?? []).map((provider) => provider.id)
}
export async function createSecret(
payload: SecretCreateRequest
): Promise<SecretMetadata> {

View File

@@ -12,6 +12,7 @@ vi.mock('../composables/useSecretForm', () => ({
loading: false,
apiError: '',
providerOptions: [],
providerHelp: '',
handleSubmit: vi.fn()
})
}))

View File

@@ -37,13 +37,24 @@
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
<span class="flex items-center gap-2">
<img
v-if="option.logo"
:src="option.logo"
:alt="option.label"
class="size-4"
/>
{{ option.label }}
</span>
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
<small v-else class="text-muted">
{{ providerHelp }}
</small>
</div>
<div class="flex flex-col gap-1">
@@ -134,15 +145,17 @@ import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useSecretForm } from '../composables/useSecretForm'
import type { SecretMetadata, SecretProvider } from '../types'
import type { SecretMetadata } from '../types'
const {
secret,
existingProviders = [],
availableProviders = null,
mode = 'create'
} = defineProps<{
secret?: SecretMetadata
existingProviders?: SecretProvider[]
existingProviders?: string[]
availableProviders?: string[] | null
mode?: 'create' | 'edit'
}>()
@@ -154,12 +167,20 @@ const emit = defineEmits<{
const titleId = useId()
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
useSecretForm({
mode,
secret: () => secret,
existingProviders: () => existingProviders,
visible,
onSaved: () => emit('saved')
})
const {
form,
errors,
loading,
apiError,
providerOptions,
providerHelp,
handleSubmit
} = useSecretForm({
mode,
secret: () => secret,
existingProviders: () => existingProviders,
availableProviders: () => availableProviders,
visible,
onSaved: () => emit('saved')
})
</script>

View File

@@ -13,6 +13,7 @@ vi.mock('../composables/useSecretForm', () => ({
loading: false,
apiError: '',
providerOptions: [],
providerHelp: '',
handleSubmit: vi.fn()
})
}))

View File

@@ -12,6 +12,7 @@ import type { SecretMetadata } from '@/platform/secrets/types'
const DIALOG_HANDLE = { key: 'confirm-delete-secret' }
const mockDeleteSecret = vi.fn().mockResolvedValue(undefined)
const mockFetchSecrets = vi.fn().mockResolvedValue(undefined)
const mockFetchProviders = vi.fn().mockResolvedValue(undefined)
const mockCloseDialog = vi.fn()
const mockSecret: SecretMetadata = {
@@ -26,9 +27,11 @@ vi.mock('@/platform/secrets/composables/useSecrets', () => ({
useSecrets: () => ({
loading: ref(false),
secrets: ref<SecretMetadata[]>([mockSecret]),
availableProviders: ref<string[]>([]),
operatingSecretId: ref(null),
existingProviders: ref([]),
fetchSecrets: mockFetchSecrets,
fetchProviders: mockFetchProviders,
deleteSecret: mockDeleteSecret
})
}))

View File

@@ -48,6 +48,7 @@
v-model:visible="createDialogVisible"
mode="create"
:existing-providers="existingProviders"
:available-providers="availableProviders"
@saved="fetchSecrets"
/>
@@ -56,6 +57,7 @@
mode="edit"
:secret="selectedSecret"
:existing-providers="existingProviders"
:available-providers="availableProviders"
@saved="fetchSecrets"
/>
</div>
@@ -84,9 +86,11 @@ const dialogStore = useDialogStore()
const {
loading,
secrets,
availableProviders,
operatingSecretId,
existingProviders,
fetchSecrets,
fetchProviders,
deleteSecret
} = useSecrets()
@@ -122,4 +126,5 @@ function confirmDelete(secret: SecretMetadata) {
}
fetchSecrets()
fetchProviders()
</script>

View File

@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SecretMetadata, SecretProvider } from '../types'
@@ -184,6 +184,178 @@ describe('useSecretForm', () => {
expect(civitai?.disabled).toBe(false)
})
it('falls back to all base providers when availableProviders is not loaded', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => null,
visible,
onSaved: vi.fn()
})
expect(providerOptions.value.map((o) => o.value)).toEqual([
'huggingface',
'civitai'
])
})
it('shows no options when the server returns an empty allowlist', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => [],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value).toEqual([])
})
it('restricts options to the providers returned by the server', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['civitai'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value.map((o) => o.value)).toEqual(['civitai'])
})
it('dedupes repeated provider ids from the server', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['civitai', 'civitai', 'huggingface'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value.map((o) => o.value)).toEqual([
'civitai',
'huggingface'
])
})
it('renders server-listed BYOK providers with labels and logos', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['runway', 'gemini'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value).toEqual([
{
value: 'runway',
label: 'Runway',
logo: '/assets/images/runway.svg',
disabled: false
},
{
value: 'gemini',
label: 'Google Gemini',
logo: '/assets/images/gemini.svg',
disabled: false
}
])
})
it('omits BYOK providers the server does not list', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['huggingface'],
visible,
onSaved: vi.fn()
})
const values = providerOptions.value.map((o) => o.value)
expect(values).toEqual(['huggingface'])
expect(values).not.toContain('runway')
expect(values).not.toContain('gemini')
})
it('reacts to availableProviders changing', () => {
const visible = ref(true)
const availableProviders = ref<string[] | null>(null)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => availableProviders.value,
visible,
onSaved: vi.fn()
})
expect(providerOptions.value).toHaveLength(2)
availableProviders.value = ['huggingface']
expect(providerOptions.value.map((o) => o.value)).toEqual(['huggingface'])
})
it('clears a selection the resolved allowlist no longer offers', async () => {
const visible = ref(true)
const availableProviders = ref<string[] | null>(null)
const { form, providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => availableProviders.value,
visible,
onSaved: vi.fn()
})
form.provider = 'civitai'
availableProviders.value = ['huggingface']
await nextTick()
expect(providerOptions.value.map((o) => o.value)).toEqual(['huggingface'])
expect(form.provider).toBeNull()
})
it('keeps a selection the resolved allowlist still offers', async () => {
const visible = ref(true)
const availableProviders = ref<string[] | null>(null)
const { form } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => availableProviders.value,
visible,
onSaved: vi.fn()
})
form.provider = 'huggingface'
availableProviders.value = ['huggingface', 'civitai']
await nextTick()
expect(form.provider).toBe('huggingface')
})
it('ignores the availableProviders filter in edit mode', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'edit',
secret: () => createMockSecret({ provider: 'huggingface' }),
existingProviders: () => [],
availableProviders: () => ['civitai'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value.map((o) => o.value)).toEqual([
'huggingface',
'civitai'
])
})
it('updates disabled state when existingProviders changes', () => {
const visible = ref(true)
const existingProviders = ref<SecretProvider[]>(['huggingface'])
@@ -206,6 +378,51 @@ describe('useSecretForm', () => {
})
})
describe('providerHelp', () => {
it('uses the generic hint when no provider is selected', () => {
const visible = ref(true)
const { providerHelp } = useSecretForm({
mode: 'create',
existingProviders: () => [],
visible,
onSaved: vi.fn()
})
// t() is mocked to echo the key.
expect(providerHelp.value).toBe('secrets.providerHint')
})
it('uses provider-specific help when a BYOK provider is selected', () => {
const visible = ref(true)
const { form, providerHelp } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['runway', 'gemini'],
visible,
onSaved: vi.fn()
})
form.provider = 'runway'
expect(providerHelp.value).toBe('secrets.providerHelp.runway')
form.provider = 'gemini'
expect(providerHelp.value).toBe('secrets.providerHelp.gemini')
})
it('falls back to the generic hint for a provider without a help key', () => {
const visible = ref(true)
const { form, providerHelp } = useSecretForm({
mode: 'create',
existingProviders: () => [],
visible,
onSaved: vi.fn()
})
form.provider = 'huggingface'
expect(providerHelp.value).toBe('secrets.providerHint')
})
})
describe('handleSubmit', () => {
it('calls create API with correct payload', async () => {
const visible = ref(true)

View File

@@ -1,16 +1,22 @@
import { whenever } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
import { computed, reactive, ref, toValue } from 'vue'
import { computed, reactive, ref, toValue, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { createSecret, SecretsApiError, updateSecret } from '../api/secretsApi'
import { SECRET_PROVIDERS } from '../providers'
import type { SecretErrorCode, SecretMetadata, SecretProvider } from '../types'
import {
DEFAULT_PROVIDER_IDS,
getProviderHelpKey,
getProviderLabel,
getProviderLogo
} from '../providers'
import type { SecretErrorCode, SecretMetadata } from '../types'
interface SecretFormState {
name: string
secretValue: string
provider: SecretProvider | null
// Free-form string: the set of selectable providers is server-driven.
provider: string | null
}
interface SecretFormErrors {
@@ -21,14 +27,16 @@ interface SecretFormErrors {
interface ProviderOption {
label: string
value: SecretProvider
value: string
logo?: string
disabled: boolean
}
interface UseSecretFormOptions {
mode: 'create' | 'edit'
secret?: MaybeRefOrGetter<SecretMetadata | undefined>
existingProviders: MaybeRefOrGetter<SecretProvider[]>
existingProviders: MaybeRefOrGetter<string[]>
availableProviders?: MaybeRefOrGetter<string[] | null>
visible: { value: boolean }
onSaved: () => void
}
@@ -39,6 +47,7 @@ export function useSecretForm(options: UseSecretFormOptions) {
mode,
secret: secretRef,
existingProviders,
availableProviders = null,
visible,
onSaved
} = options
@@ -59,13 +68,49 @@ export function useSecretForm(options: UseSecretFormOptions) {
provider: ''
})
const providerOptions = computed<ProviderOption[]>(() =>
SECRET_PROVIDERS.map((p) => ({
label: p.label,
value: p.value,
disabled:
mode === 'edit' ? false : toValue(existingProviders).includes(p.value)
const providerOptions = computed<ProviderOption[]>(() => {
// Which provider ids to render:
// - edit mode: the field is disabled; show the defaults plus the stored
// provider so the disabled selector can display its label/logo.
// - create + list not yet loaded (null): show the defaults as a sensible
// placeholder while the request is in flight.
// - create + list loaded: render exactly what the server returned, so
// providers added server-side (e.g. runway/gemini) appear with no FE
// change and providers omitted server-side do not appear.
const available = toValue(availableProviders)
let ids: string[]
if (mode === 'edit') {
const stored = toValue(secretRef)?.provider
ids =
stored && !DEFAULT_PROVIDER_IDS.some((id) => id === stored)
? [...DEFAULT_PROVIDER_IDS, stored]
: [...DEFAULT_PROVIDER_IDS]
} else {
ids =
available === null ? [...DEFAULT_PROVIDER_IDS] : [...new Set(available)]
}
const existing = toValue(existingProviders)
return ids.map((id) => ({
value: id,
label: getProviderLabel(id),
logo: getProviderLogo(id),
disabled: mode === 'edit' ? false : existing.includes(id)
}))
})
// Once the server allowlist resolves, drop a selection the resolved list no
// longer offers so the user cannot submit an unlisted provider.
watch(providerOptions, (options) => {
if (form.provider && !options.some((o) => o.value === form.provider)) {
form.provider = null
}
})
// Provider-specific help text when the selected provider defines it, else the
// generic hint. Sourced from the presentational registry, not the server.
const providerHelp = computed(() =>
t(getProviderHelpKey(form.provider ?? undefined) ?? 'secrets.providerHint')
)
const apiError = computed(() => {
@@ -166,6 +211,7 @@ export function useSecretForm(options: UseSecretFormOptions) {
loading,
apiError,
providerOptions,
providerHelp,
handleSubmit
}
}

View File

@@ -14,10 +14,12 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
}))
const mockListSecrets = vi.fn()
const mockListSecretProviders = vi.fn()
const mockDeleteSecret = vi.fn()
vi.mock('../api/secretsApi', () => ({
listSecrets: () => mockListSecrets(),
listSecretProviders: () => mockListSecretProviders(),
deleteSecret: (id: string) => mockDeleteSecret(id),
SecretsApiError: class SecretsApiError extends Error {
constructor(
@@ -134,6 +136,44 @@ describe('useSecrets', () => {
})
})
describe('fetchProviders', () => {
it('populates availableProviders from the API', async () => {
mockListSecretProviders.mockResolvedValue(['huggingface', 'civitai'])
const { availableProviders, fetchProviders } = useSecrets()
expect(availableProviders.value).toBeNull()
await fetchProviders()
expect(availableProviders.value).toEqual(['huggingface', 'civitai'])
})
it('distinguishes a server-returned empty allowlist from not-loaded', async () => {
mockListSecretProviders.mockResolvedValue([])
const { availableProviders, fetchProviders } = useSecrets()
await fetchProviders()
expect(availableProviders.value).toEqual([])
})
it('leaves availableProviders null on API failure', async () => {
const { SecretsApiError } = await import('../api/secretsApi')
mockListSecretProviders.mockRejectedValue(
new SecretsApiError('unavailable', 503)
)
const { availableProviders, fetchProviders } = useSecrets()
await fetchProviders()
expect(availableProviders.value).toBeNull()
expect(mockAdd).not.toHaveBeenCalled()
})
})
describe('existingProviders', () => {
it('returns list of providers from secrets', async () => {
mockListSecrets.mockResolvedValue([

View File

@@ -5,10 +5,11 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import {
deleteSecret as deleteSecretApi,
listSecretProviders,
listSecrets,
SecretsApiError
} from '../api/secretsApi'
import type { SecretMetadata, SecretProvider } from '../types'
import type { SecretMetadata } from '../types'
export function useSecrets() {
const { t } = useI18n()
@@ -16,12 +17,11 @@ export function useSecrets() {
const loading = ref(false)
const secrets = ref<SecretMetadata[]>([])
const availableProviders = ref<string[] | null>(null)
const operatingSecretId = ref<string | null>(null)
const existingProviders = computed<SecretProvider[]>(() =>
secrets.value
.map((s) => s.provider)
.filter((p): p is SecretProvider => p !== undefined)
const existingProviders = computed<string[]>(() =>
secrets.value.map((s) => s.provider).filter((p): p is string => p != null)
)
async function fetchSecrets() {
@@ -48,6 +48,14 @@ export function useSecrets() {
}
}
async function fetchProviders() {
try {
availableProviders.value = await listSecretProviders()
} catch (err) {
console.error('Unexpected error fetching secret providers:', err)
}
}
async function deleteSecret(secret: SecretMetadata) {
operatingSecretId.value = secret.id
try {
@@ -76,9 +84,11 @@ export function useSecrets() {
return {
loading,
secrets,
availableProviders,
operatingSecretId,
existingProviders,
fetchSecrets,
fetchProviders,
deleteSecret
}
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_PROVIDER_IDS,
getProviderHelpKey,
getProviderLabel,
getProviderLogo
} from './providers'
describe('secret provider registry', () => {
it('maps known providers (including BYOK) to display labels', () => {
expect(getProviderLabel('huggingface')).toBe('HuggingFace')
expect(getProviderLabel('civitai')).toBe('Civitai')
expect(getProviderLabel('runway')).toBe('Runway')
expect(getProviderLabel('gemini')).toBe('Google Gemini')
})
it('maps known providers to logos under public/assets/images', () => {
expect(getProviderLogo('huggingface')).toBe('/assets/images/hf-logo.svg')
expect(getProviderLogo('civitai')).toBe('/assets/images/civitai.svg')
expect(getProviderLogo('runway')).toBe('/assets/images/runway.svg')
expect(getProviderLogo('gemini')).toBe('/assets/images/gemini.svg')
})
it('exposes per-provider help keys for the BYOK providers', () => {
expect(getProviderHelpKey('runway')).toBe('secrets.providerHelp.runway')
expect(getProviderHelpKey('gemini')).toBe('secrets.providerHelp.gemini')
// Model-download providers reuse the generic hint (no dedicated help key).
expect(getProviderHelpKey('huggingface')).toBeUndefined()
expect(getProviderHelpKey('civitai')).toBeUndefined()
})
it('falls back to the raw id with no logo/help for unknown providers', () => {
// A provider added server-side but absent from the registry still renders.
expect(getProviderLabel('brand-new-provider')).toBe('brand-new-provider')
expect(getProviderLogo('brand-new-provider')).toBeUndefined()
expect(getProviderHelpKey('brand-new-provider')).toBeUndefined()
})
it('returns an empty label and no logo for an undefined provider', () => {
expect(getProviderLabel(undefined)).toBe('')
expect(getProviderLogo(undefined)).toBeUndefined()
expect(getProviderHelpKey(undefined)).toBeUndefined()
})
it('keeps the not-loaded fallback list to the pre-BYOK baseline', () => {
// Defaults are a transient placeholder only; the authoritative list is the
// server response. They must not silently include BYOK providers.
expect([...DEFAULT_PROVIDER_IDS]).toEqual(['huggingface', 'civitai'])
})
})

View File

@@ -1,30 +1,71 @@
import type { SecretProvider } from './types'
interface ProviderConfig {
value: SecretProvider
/** Provider identifier as returned by `GET /secrets/providers`. */
value: string
/** Human-facing display label. Brand names are intentionally not translated. */
label: string
/** Path to the provider logo under `public/assets/images/`. */
logo: string
/** Optional i18n key for provider-specific help text shown under the picker. */
helpKey?: string
}
export const SECRET_PROVIDERS: ProviderConfig[] = [
/**
* Presentational metadata for known providers: how a provider id renders
* (label, logo, help text). This table is NOT the source of truth for which
* providers a user may configure — that list is server-driven via
* `GET /secrets/providers`. Ids the server returns that are absent here fall
* back to the raw id with no logo, so adding a provider server-side renders
* without an FE change (a dedicated logo/label is an optional enhancement).
*/
const SECRET_PROVIDERS: ProviderConfig[] = [
{
value: 'huggingface',
label: 'HuggingFace',
logo: '/assets/images/hf-logo.svg'
},
{ value: 'civitai', label: 'Civitai', logo: '/assets/images/civitai.svg' }
] as const
{ value: 'civitai', label: 'Civitai', logo: '/assets/images/civitai.svg' },
{
value: 'runway',
label: 'Runway',
logo: '/assets/images/runway.svg',
helpKey: 'secrets.providerHelp.runway'
},
{
value: 'gemini',
label: 'Google Gemini',
logo: '/assets/images/gemini.svg',
helpKey: 'secrets.providerHelp.gemini'
}
]
export function getProviderLabel(provider: SecretProvider | undefined): string {
/**
* Providers shown as a sensible default before the server list resolves, and to
* seed the disabled selector in edit mode. This is NOT the source of truth for
* which providers a user may configure — once `GET /secrets/providers` resolves,
* its list is rendered verbatim.
*/
export const DEFAULT_PROVIDER_IDS = ['huggingface', 'civitai'] as const
function findProvider(
provider: string | undefined
): ProviderConfig | undefined {
if (!provider) return undefined
return SECRET_PROVIDERS.find((p) => p.value === provider)
}
export function getProviderLabel(provider: string | undefined): string {
if (!provider) return ''
const config = SECRET_PROVIDERS.find((p) => p.value === provider)
return config?.label ?? provider
return findProvider(provider)?.label ?? provider
}
export function getProviderLogo(
provider: SecretProvider | undefined
provider: string | undefined
): string | undefined {
if (!provider) return undefined
const config = SECRET_PROVIDERS.find((p) => p.value === provider)
return config?.logo
return findProvider(provider)?.logo
}
export function getProviderHelpKey(
provider: string | undefined
): string | undefined {
return findProvider(provider)?.helpKey
}

View File

@@ -1,18 +1,27 @@
export type SecretProvider = 'huggingface' | 'civitai'
import type { SecretResponse } from '@comfyorg/ingest-types'
export interface SecretMetadata {
id: string
name: string
provider?: SecretProvider
last_used_at?: string
created_at: string
updated_at: string
}
/**
* Secret metadata as returned by the ingest API, sourced from the generated
* OpenAPI types (`SecretResponse`). The secret value itself is never returned
* after creation. `provider` is a free-form identifier (huggingface, civitai,
* and BYOK providers); the `SecretProvider` union below is only the subset the
* UI renders first-class.
*/
export type SecretMetadata = SecretResponse
/**
* Base providers the UI renders with a dedicated first-class label/logo. This
* union documents the historically-known providers only — the full set of
* configurable providers is data-driven via `GET /secrets/providers`, so the
* selected provider is stored/sent as a free-form string.
*/
export type SecretProvider = 'huggingface' | 'civitai'
export interface SecretCreateRequest {
name: string
secret_value: string
provider?: SecretProvider
/** Provider identifier as returned by `GET /secrets/providers`. */
provider?: string
}
export interface SecretUpdateRequest {

View File

@@ -360,6 +360,35 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('forgetRevokedActiveWorkspace', () => {
it.for([
{ type: 'team', workspace: mockTeamWorkspace, reloads: 1 },
{ type: 'personal', workspace: mockPersonalWorkspace, reloads: 0 }
])(
'reloads $reloads time(s) when the active $type workspace is revoked',
async ({ workspace, reloads }) => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = workspace.id
store.forgetRevokedActiveWorkspace(workspace.id)
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(reloads)
expect(mockReload).toHaveBeenCalledTimes(reloads)
}
)
it('is a no-op when the workspace is not the active one', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = mockTeamWorkspace.id
store.forgetRevokedActiveWorkspace('some-other-workspace')
expect(mockReload).not.toHaveBeenCalled()
})
})
describe('createWorkspace', () => {
it('creates workspace and triggers reload', async () => {
const newWorkspace = {

View File

@@ -103,6 +103,14 @@ function setLastWorkspaceId(workspaceId: string): void {
}
}
function clearLastWorkspaceId(): void {
try {
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
console.warn('Failed to clear last workspace ID from localStorage')
}
}
const MAX_OWNED_WORKSPACES = 10
export const MAX_WORKSPACE_MEMBERS = 30
const MAX_INIT_RETRIES = 3
@@ -362,6 +370,20 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
}
}
/**
* Drop a revoked/deleted active workspace and reload so init falls back to the
* personal workspace. Skips the personal workspace to avoid a reload loop.
*/
function forgetRevokedActiveWorkspace(workspaceId: string): void {
if (activeWorkspaceId.value !== workspaceId) return
const revoked = workspaces.value.find((w) => w.id === workspaceId)
if (revoked?.type === 'personal') return
clearLastWorkspaceId()
window.location.reload()
}
/**
* Switch to a different workspace.
* Clears workspace context and reloads the page.
@@ -744,6 +766,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Workspace Actions
switchWorkspace,
forgetRevokedActiveWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,

View File

@@ -11,11 +11,24 @@ import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
const mockGetIdToken = vi.fn()
const mockNotifyTokenRefreshed = vi.fn()
const mockToastAdd = vi.fn()
const mockCurrentUser = vi.hoisted((): { value: { uid: string } | null } => ({
value: null
}))
const mockForgetRevokedActiveWorkspace = vi.fn()
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken,
notifyTokenRefreshed: mockNotifyTokenRefreshed
notifyTokenRefreshed: mockNotifyTokenRefreshed,
get currentUser() {
return mockCurrentUser.value
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
forgetRevokedActiveWorkspace: mockForgetRevokedActiveWorkspace
})
}))
@@ -82,6 +95,7 @@ describe('useWorkspaceAuthStore', () => {
sessionStorage.clear()
mockTeamWorkspacesEnabled.value = true
mockUnifiedCloudAuthEnabled.value = false
mockCurrentUser.value = null
})
afterEach(() => {
@@ -583,6 +597,301 @@ describe('useWorkspaceAuthStore', () => {
})
})
describe('ensureWorkspaceAuthHeader', () => {
it('returns the existing header without minting when the token is valid', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('mints a token for the preferred workspace when none exists', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
})
it('coalesces concurrent recovery onto a single mint', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const [first, second] = await Promise.all([
store.ensureWorkspaceAuthHeader('workspace-123'),
store.ensureWorkspaceAuthHeader('workspace-123')
])
expect(first).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(second).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('awaits an in-flight switch instead of racing a second mint', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveResponse: (value: unknown) => void = () => {}
const responsePromise = new Promise((resolve) => {
resolveResponse = resolve
})
const mockFetch = vi.fn().mockReturnValue(responsePromise)
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const switchPromise = store.switchWorkspace('workspace-123')
const ensurePromise = store.ensureWorkspaceAuthHeader('workspace-123')
resolveResponse({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
await switchPromise
const header = await ensurePromise
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('returns null (never a downgrade) when recovery fails transiently', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toBeNull()
})
it('returns null when there is no workspace to recover to', async () => {
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader()
expect(header).toBeNull()
})
it('is a no-op returning null when the feature flag is disabled', async () => {
mockTeamWorkspacesEnabled.value = false
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toBeNull()
expect(mockFetch).not.toHaveBeenCalled()
})
it('tears down workspace context and surfaces a toast on a permanent recovery failure', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
const token = await store.ensureWorkspaceToken('workspace-999')
expect(token).toBeNull()
expect(currentWorkspace.value).toBeNull()
expect(mockToastAdd).toHaveBeenCalledTimes(1)
})
it('backs off re-minting after a failed recovery instead of retrying every call', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const first = await store.ensureWorkspaceToken('workspace-123')
const second = await store.ensureWorkspaceToken('workspace-123')
expect(first).toBeNull()
expect(second).toBeNull()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('forgets the revoked active workspace on a permanent workspace-selection failure', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
)
const store = useWorkspaceAuthStore()
const token = await store.ensureWorkspaceToken('workspace-123')
expect(token).toBeNull()
expect(mockForgetRevokedActiveWorkspace).toHaveBeenCalledWith(
'workspace-123'
)
})
it('does not forget the workspace when the failure is an auth error, not revocation', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Invalid token' })
})
)
const store = useWorkspaceAuthStore()
const token = await store.ensureWorkspaceToken('workspace-123')
expect(token).toBeNull()
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
})
it('preserves a valid context on a transient Firebase network failure while signed in', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
mockCurrentUser.value = { uid: 'user-1' }
mockGetIdToken.mockResolvedValue(undefined)
const token = await store.ensureWorkspaceToken('workspace-999')
expect(token).toBeNull()
expect(currentWorkspace.value?.id).toBe('workspace-123')
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
})
it('collapses a burst of waiters into a single mint after a shared in-flight switch rejects', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const initialSwitch = store
.switchWorkspace('workspace-123')
.catch(() => {})
const [first, second] = await Promise.all([
store.ensureWorkspaceToken('workspace-123'),
store.ensureWorkspaceToken('workspace-123')
])
await initialSwitch
expect(first).toBe('workspace-token-abc')
expect(second).toBe('workspace-token-abc')
// One failed initial switch + exactly one recovery mint the burst shares.
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('re-mints for the requested workspace rather than returning an in-flight switch to a different one', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockImplementation((_url, options) => {
const { workspace_id: workspaceId } = JSON.parse(options.body)
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: `token-${workspaceId}`,
workspace: { ...mockWorkspace, id: workspaceId }
})
})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const switchPromise = store.switchWorkspace('workspace-other')
const token = await store.ensureWorkspaceToken('workspace-123')
await switchPromise
expect(token).toBe('token-workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
describe('token refresh scheduling', () => {
it('schedules token refresh 5 minutes before expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')

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