Compare commits

..

10 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
afe5568d07 test: cover widget value leak on node id reuse after clear
Fails on the old guard (a re-added node with a reused id reads the deleted node's width) and passes with the zero-UUID purge.
2026-07-14 20:02:27 -07:00
Nathaniel Parson Koroso
a5dfaf4462 fix(litegraph): purge widget state when a zero-UUID graph is cleared
LGraph.clear() only purged widgetValueStore/previewExposureStore when the graph id was non-zero, so a root graph running at the zero UUID never purged. The API-format load path clears then re-adds nodes without assigning an id, and a freshly constructed graph also stays at the zero UUID, so a node reusing an id after clear inherited the previous node's same-named widget value. That stale value serializes into prompts and fails backend validation ('Value not in list').

Gate the zero-UUID case on the graph having nodes, so real teardowns purge while the constructor's clear() and throwaway graphs (e.g. insertWorkflow's clipboard graph) don't wipe the active graph's shared zero-UUID namespace.

Adds LGraph.test.ts coverage for zero-UUID node-id reuse and the throwaway-construction guard, plus a widgetValueStore.test.ts case for re-registering a reused widget id after clearGraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:32:11 -07:00
imick-io
010389903d feat(website): sitewide announcement banner (#13481)
## Summary

Adds a **sitewide announcement banner** to the website, rendered above
the navbar on every page. It has a two-layer visibility model:

- **Build-time gate** — a pure, unit-tested `evaluateBannerVisibility()`
decides whether the banner mounts at all (active flag + optional date
window + locale/section targeting), driven by a typed config
(`src/config/banner.ts`). No CMS; copy resolves through i18n.
- **Client-side dismissal** — persisted in `localStorage`, keyed by a
**content hash** so editing the copy re-shows the banner (per-locale, so
an en edit doesn't re-show it for zh-CN).

Current content points the CTA at **Comfy MCP** (`/mcp`).

## Highlights

- **Full-width branded bar** above a now-`sticky` navbar; reuses the
design system (`Button`, gradient tokens, new reusable `IconButton`).
- **Flash-free** on load: an inline pre-hydration script hides an
already-dismissed banner before paint (no pop-in, no layout shift);
`close()` sets the same signal after the leave animation to stay
flash-free across ClientRouter navigations.
- **Open/close transition**: grid-rows height collapse + fade,
respecting `prefers-reduced-motion`.
- **i18n**: copy in `en` + `zh-CN`.

## Where to edit later

- **Copy**: `apps/website/src/i18n/translations.ts` →
`launches.banner.text` / `launches.banner.cta`
- **Link / on-off / dates / targeting**:
`apps/website/src/config/banner.ts` (`bannerConfig`)

## Notes

- On a static site the `startsAt`/`endsAt` window is evaluated at
**build time** (documented in `banner.ts`).
- Changing the copy or link changes the content hash, so
previously-dismissed visitors will see the banner again — by design.

## Test plan

- `pnpm test:unit` — evaluator + version-hash unit tests pass.
- `pnpm typecheck` + lint clean.
- On the preview: banner shows on `/`, `/launches`, and a `zh-CN` page;
CTA -> `/mcp`; dismiss animates and stays dismissed on reload (no
flash); reduced-motion disables the animation.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:56:48 +00:00
Benjamin Lu
684b0b08b0 feat(telemetry): register client + deployment platform axes on PostHog (#13469)
## Problem

Filtering PostHog for the three product surfaces — **desktop local**,
**desktop cloud**, **web cloud** — currently requires a different hack
per pipe. For this repo's cloud build, the desktop-embedded frontend and
a plain browser are indistinguishable except by sniffing `Electron` in
`$raw_user_agent` (~124K desktop-cloud vs ~844K web-cloud execution
events/week get separated that way today).

## Change

Register the two standardized platform axes as PostHog super properties
at SDK init in `PostHogTelemetryProvider`:

- **`client`** — which surface emitted the event: `'desktop'` when the
desktop preload bridge (`window.__comfyDesktop2`) is present, else
`'web'`. The bridge is injected by Electron before any page script runs,
so detection is deterministic — unlike the existing utm-based
`source_app` attribution, which only covers sessions that *entered* via
a desktop link.
- **`deployment`** — which backend runs the work: pinned to `'cloud'`.

The register happens before the pre-init event queue flushes, so events
captured during the posthog-js dynamic-import window carry the axes too.

## Why pinning `deployment: 'cloud'` is safe (including
embedded-in-desktop)

The cloud bundle also runs **embedded in Comfy Desktop** — a cloud
install loads this same bundle in Electron, where `isCloud` and the host
bridge are both true. Two things happen there:

1. `main.ts` runs `initHostTelemetry()` *after* `initTelemetry()`, and
(when remote config `enable_telemetry` is on) it **replaces** the
registry with `HostTelemetrySink` — so tracked events
(`execution_start`, …) route through the desktop main process, bypassing
this provider. Those are tagged the same `client`/`deployment` values
main-side from the install's source category
([Comfy-Desktop#1229](https://github.com/Comfy-Org/Comfy-Desktop/pull/1229)).
2. posthog-js keeps capturing independently of the registry (pageviews,
web vitals, identify) — those are what these super properties cover in
the embedded case, and `deployment: 'cloud'` is correct for them because
the cloud bundle always talks to the cloud backend regardless of
embedding; the embedding itself is what `client: 'desktop'` captures.

The only way a cloud build runs against a non-cloud backend is a dev
setup, where `window.__CONFIG__.posthog_project_token` is absent
(injected by the cloud server) and the provider disables itself before
registering anything.

The locally-served frontend (desktop/localhost builds) never runs this
provider: `__DISTRIBUTION__` is a compile-time define, so the
`initTelemetry()` call folds away, with a runtime `IS_CLOUD_BUILD` guard
as backstop.

With both PRs, the three platforms become clean property filters:

| Surface | Filter |
|---|---|
| Desktop local | `client=desktop, deployment=local` |
| Desktop cloud | `client=desktop, deployment=cloud` |
| Web cloud | `client=web, deployment=cloud` |

## Testing

- `vitest run` on `PostHogTelemetryProvider.test.ts` — 45 passing,
including new coverage: web default, bridge-present → `client=desktop`,
and register-before-queue-flush ordering. The two desktop-entry tests
that asserted `register` is never called were narrowed to assert no
`source_app` register call.
- `pnpm typecheck` + eslint/oxlint on touched files — clean.

Ref
[MAR-51](https://linear.app/comfyorg/issue/MAR-51/foundation-desktop-sdk-dual-send-to-posthog-alongside-mixpanel)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-08 01:11:01 +00:00
Benjamin Lu
95b121bed9 fix(website): unbreak website-e2e on main (card count, CTA overflow, stale goldens) (#13496)
## Summary

Fix the website-e2e job, red on main since 2026-07-07 15:19 UTC, by
updating the cloud model-card count test and regenerating stale visual
screenshot goldens.

## Context

website-e2e runs Playwright tests against the marketing site
(`apps/website`). It broke on main in two independent ways, so every PR
since — however unrelated — has shown a red website-e2e check:

1. **Model-card count.** #13431 added a sixth model card (GPT Image 2)
to the /cloud "AI models" section but didn't update the test that pins
the card count at 5. CI: `locator resolved to 6 elements`.
2. **Stale screenshot goldens.** #13431 also swapped the ProductCard CTA
to the shared `Button` (`whitespace-nowrap`, so e.g. "SEE ENTERPRISE
FEATURES" renders on one line instead of wrapping) and committed
matching `home-product-cards-*` goldens. Minutes later #13445 — a branch
cut from main *before* #13431 — ran the screenshot-regen workflow, which
checks out the raw PR branch, not the branch merged with main. Its
regenerated lg/xl goldens therefore depict the **old** pre-#13431 card
(wrapped label; pixel-identical layout to the pre-#13431 golden), and
overwrote #13431's correct ones at merge. #13445's own website-e2e was
red at merge time for exactly this reason. The sm/md goldens (last
captured by #13431, without #13445's `ppformula-text-center`
0.19em→0.1em nudge) went stale by ~900 px the moment #13445 landed.

## Changes

- **What**: `cloud.spec.ts` — model-card count assertion and test title
5 → 6 (verified against the 6 entries in `AIModelsSection.vue`; passes
locally).
- **What**: `ProductCard.vue` — `h-auto whitespace-normal` on the CTA
`Button`. The shared Button's `whitespace-nowrap` made long labels ("SEE
ENTERPRISE FEATURES") overflow past the card edge at lg/xl (live on prod
since #13431); labels now wrap inside the card as they did before
#13431.
- **What**: regenerated `home-product-cards-*` goldens via the `Update
Website Screenshots` workflow, run on this branch, so they capture the
fixed rendering rather than enshrining the overflow.

## Review Focus

- At lg, "SEE DESKTOP/CLOUD FEATURES" now also wrap to two lines: #13431
raised the CTA font from `text-xs` to `md:text-sm`, so those labels no
longer fit one line in the 200px content box either (pre-fix they
silently consumed the card padding). If design prefers one-liners,
shrinking the CTA font is a follow-up.
- Process gaps this incident exposed (follow-ups, not in this PR): the
regen workflow captures against the raw branch instead of the
main-merged result, and website-e2e was red on #13445 at merge without
blocking it.

---

*Six cards where five once stood,*
*a button's text sat where it should —*
*but pixels pinned in amber lied,*
*so Linux looked, and rectified.*

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 17:59:21 -07:00
Wei Hai
baeb6df662 Gate signup submit on Turnstile being enabled, not enforced (#13463)
## Summary
- Shadow-mode Turnstile never blocked the signup Submit button on the
async Cloudflare challenge resolving, so most real submits raced ahead
of the widget and reached the backend with an empty token. This defeated
the point of shadow mode, which needs real tokens to measure the
false-positive rate before flipping to enforce.
- Submit is now blocked while the widget is enabled (shadow or enforce)
and has no token yet, in both modes.
- To keep a broken or slow Cloudflare load (network issue, ad-blocker,
CDN outage) from permanently blocking a legitimate signup,
`TurnstileWidget` now reports itself "unavailable" on a script-load
failure, a challenge error, or a 9s load timeout, and the form treats
that the same as shadow previously did: proceed without a token.

## Test plan
- [x] `vitest run` on `TurnstileWidget.test.ts` + `SignUpForm.test.ts`
(unit tests updated/added, all passing)
- [x] `pnpm typecheck` / eslint / oxlint / stylelint / oxfmt via
pre-commit hooks
- [ ] Manual click-through on staging to confirm no perceptible UX
regression during normal-latency challenge solves
- [ ] Confirm the 9s fallback timeout against real p95 Turnstile
challenge-solve latency

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-08 00:39:58 +00:00
CodeJuggernaut
e58b231664 fix: suppress the stray focus ring on the sidebar splitter panel (#13482)
Clicking empty space in a workspace panel focuses the whole PrimeVue
SplitterPanel (tabindex=-1 makes it click-focusable), and any following
non-chord keypress (e.g. Shift) trips the browser focus-visible
heuristic, painting the default blue ring around the entire panel.

The wrappers are not Tab-reachable (Tab lands on the controls inside,
never the panel box) and nothing focuses them programmatically, so the
ring conveys nothing. `focus-visible:outline-hidden` suppresses it while
keeping a forced-colors (High Contrast) indicator. Confirmed as noise
with design (Alex Tov).

Covers every click-focusable panel in
`LiteGraphCanvasSplitterOverlay.vue`: the sidebar panel (both
locations), the properties-side panel (both branches), and the bottom
panel. The center and graph-canvas panels are left untouched - they
inherit `pointer-events-none`, so a click can never focus them.

## Repro / QA

Ring trigger: click an empty, non-interactive spot inside the panel,
then press solo Shift. On main the browser paints a blue ring around the
whole panel; on this PR nothing appears. (Ctrl+Shift only triggers when
Shift lands first, hence the original "sometimes".)

| Panel | How to open | Fixed |
| --- | --- | --- |
| Sidebar (left, default) | Any rail icon, e.g. Assets | yes |
| Sidebar (right) | Settings > Sidebar Location > right | yes |
| Properties-side panel | Toggle properties panel / builder mode | yes |
| Bottom panel | Toggle Logs/Terminal | yes |
| Canvas / center | n/a | untouched - pointer-events-none, cannot be
click-focused |

- [ ] Each fixed panel: click empty spot, press Shift, no ring
- [ ] Same steps on main/prod show the ring (before-state)
- [ ] Tab still reaches controls inside each panel and their own focus
rings still show
- [ ] Media Assets shortcuts unchanged (Ctrl/Cmd+A, marquee modifiers) -
PR is CSS-only

- Surfaced during design review of #13323
2026-07-08 00:33:41 +00:00
Comfy Org PR Bot
545b48ee5b [chore] Update Ingest API types from cloud@421de6d (#12777)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 421de6d
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-07-08 00:29:59 +00:00
Benjamin Lu
22ea53fb56 fix(ci): remove out-of-rootDir config files from package tsconfig includes (#13485)
## Summary

Type-aware oxlint rejects `packages/ingest-types/tsconfig.json` and
`packages/object-info-parser/tsconfig.json` as invalid (TS6059): their
`include` lists a root-level config file (`openapi-ts.config.ts` /
`vitest.config.ts`) that sits outside `rootDir: "src"`. This fails the
lint-and-format job with "Invalid tsconfig" on any PR that touches those
packages' src files — currently blocking every auto-generated
ingest-types sync (e.g. #12777).

## Changes

- **What**: Drop the out-of-`rootDir` config-file entries from the two
package tsconfig `include` arrays, matching the other workspace
packages. Repro: `pnpm exec oxlint --type-aware
packages/ingest-types/src/index.ts` fails before, passes after; the
config files themselves still lint clean.

## Review Focus

Neither package emits a build, so `rootDir`/`outDir` are
editor/lint-only; excluding the config files from the project has no
runtime effect (vitest/openapi-ts load their configs directly).
2026-07-07 17:03:12 -07:00
cloud-code-bot[bot]
9fe5dd51b8 ci: bump cursor-review to github-workflows@df507e6 (#13493)
Automatic SHA bump — `cursor-review.yml` was updated in
`Comfy-Org/github-workflows` at
[`df507e6`](df507e6bae).
_Opened by the `bump-cursor-review-callers` workflow._

Co-authored-by: cloud-code-bot[bot] <234529496+cloud-code-bot[bot]@users.noreply.github.com>
2026-07-07 23:06:59 +00:00
50 changed files with 2664 additions and 3676 deletions

View File

@@ -63,62 +63,3 @@ 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.
- name: App queue credential cleanup on rejection
mode: warning
instructions: |
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
This check applies ONLY when the PR changes `src/scripts/app.ts` and that diff assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`.
When applicable, require a changed app test file such as `src/scripts/app.core.test.ts` or `src/scripts/app.test.ts` to include rejected-queue coverage that:
1. Populates both credential sources (`authToken` and API key, or the stores that feed them) with non-empty values.
2. Makes `api.queuePrompt` reject.
3. Calls `app.queuePrompt`.
4. Asserts after the rejection path that both `api.authToken` and `api.apiKey` are cleared, deleted, or `undefined`.
Warn if rejection is tested without those cleanup assertions, or if cleanup is asserted only on the successful `api.queuePrompt` path. Mention that success-path cleanup alone does not prove credentials are cleared after a failed queue request.
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.
Flag missing behavioral coverage for changed behavior, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and unrestored global mutations.
Prefer colocated behavioral tests named after the source file.
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards or sibling composables.
Use a real createI18n instance rather than vi.mock('vue-i18n').
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
For rejected promises, thrown errors, and failed async calls, require assertions for post-error state cleanup and side-effect rollback, especially auth tokens, API keys, globals, listeners, timers, subscriptions, and caches that production mutates before awaiting.
Tests for production changes must exercise the changed runtime/public entrypoint directly; flag helper-only coverage when the changed branch runs through a higher-level entrypoint.
Use @testing-library/vue for component tests, not @vue/test-utils.
For platform-owned types (Response, CustomEvent, DOM events), require real instances (new Response(), Response.json(), new CustomEvent()) instead of fromPartial or casts.
When a fixture cast hides a too-wide production signature, suggest narrowing the production type instead of casting the fixture.
Flag process-level listeners (process.on('unhandledRejection')) in tests; assert the rejected promise directly.
Tests should import the module under test from its public entrypoint, not deep internal paths.
- path: 'src/scripts/app*.test.ts'
instructions: |
For app tests that mock or spy on `api.queuePrompt`, require rejected-queue coverage whenever the code temporarily populates `api.authToken` or `api.apiKey`.
The rejected-path test must populate both values, make `api.queuePrompt` reject, and assert both fields are cleared afterward; success-path cleanup alone is insufficient.
If `api.queuePrompt` rejection is tested without asserting `api.authToken` and `api.apiKey` cleanup afterward, flag the missing assertion even when a success-path test already covers cleanup.
- path: 'src/scripts/app.ts'
instructions: |
When app queuing code temporarily assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`, require app tests for both resolved and rejected `api.queuePrompt` paths.
The rejected-path test must prove both credential fields are cleared after `api.queuePrompt` rejects; a rejection assertion without cleanup checks is insufficient.
- path: 'src/lib/litegraph/src/LGraph.ts'
instructions: |
When changes touch `LGraph.configure()`, graph deserialization, link loading, or nullable serialized links, require a direct regression test that constructs an `LGraph` and calls `configure()` with the changed serialized shape.
Helper-only coverage is insufficient for these changes. For sparse legacy links, expect a v0.4 workflow with `links: [null, validLink]` to not throw and still create the valid link.
- path: 'src/lib/litegraph/**/*.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 litegraph Vitest test file.
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling litegraph mock builders.
Flag mocked litegraph classes when a real instance or shared factory would exercise behavior directly.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md` and `docs/testing/README.md` as required review context for every changed Playwright test file.
Flag missing behavioral coverage, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and test isolation problems.
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.

View File

@@ -29,7 +29,7 @@ jobs:
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
@@ -48,7 +48,7 @@ jobs:
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
test('AIModelsSection heading and 6 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
await expect(modelCards).toHaveCount(6)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<Button as="span" variant="default" size="sm" class="mt-4">
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
{{ cta }}
</Button>
</div>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { IconButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { iconButtonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: IconButtonVariants['variant']
size?: IconButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="icon-button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(iconButtonVariants({ variant, size }), className)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const iconButtonVariants = cva(
[
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
],
{
variants: {
variant: {
ghost:
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
},
size: {
sm: 'size-8',
default: 'size-10',
lg: 'size-14'
}
},
defaultVariants: {
variant: 'ghost',
size: 'default'
}
}
)
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>

View File

@@ -0,0 +1,75 @@
import { onMounted, ref } from 'vue'
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
type ClosedBanners = Record<string, boolean>
function readClosedBanners(): ClosedBanners {
try {
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
} catch {
return {}
}
}
function writeClosedBanners(value: ClosedBanners): void {
try {
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
} catch {
// Storage unavailable (private mode / quota) — dismissal just won't persist.
}
}
/** The stable part of a version key (everything before `_v<hash>`). */
function versionPrefix(version: string): string {
const idx = version.lastIndexOf('_v')
return idx === -1 ? version : version.slice(0, idx)
}
/**
* Client-side dismissal persisted in localStorage, keyed by a content-aware
* `version`. The banner renders visible in the static HTML (so non-dismissers
* see no pop-in); an inline pre-hydration script hides an already-dismissed
* banner before paint, and this composable then removes it from the DOM on mount.
*/
export function useBannerDismissal(version: string) {
const isVisible = ref(true)
onMounted(() => {
const stored = readClosedBanners()
const prefix = versionPrefix(version)
// Prune stale versions of THIS banner+locale; keep other banners/locales
// and the current version.
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
let pruned = false
for (const key of Object.keys(stored)) {
if (versionPrefix(key) !== prefix || key === version) {
cleaned[key] = stored[key]
} else {
pruned = true
}
}
if (pruned) writeClosedBanners(cleaned)
isVisible.value = !cleaned[version]
})
function close(): void {
isVisible.value = false
const stored = readClosedBanners()
stored[version] = true
writeClosedBanners(stored)
}
// Call once the close transition has finished. Sets the pre-paint hide signal
// so the banner doesn't flash back in on a ClientRouter (view-transition)
// navigation — where the inline <head> script does not re-run but <html>
// persists. Deferred to after the animation so the leave transition can play.
function persistHidden(): void {
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
}
return { isVisible, close, persistHidden }
}

View File

@@ -0,0 +1,84 @@
import type { ButtonVariants } from '../components/ui/button'
import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
// 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).
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
// window gates on the last deploy, not the visitor's exact clock.
interface BannerLinkConfig {
readonly href: string
readonly titleKey: TranslationKey
readonly target?: boolean
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerConfig {
readonly id: string
readonly isActive: boolean
readonly startsAt?: string
readonly endsAt?: string
/** Empty/undefined = all locales. */
readonly targetLocales?: readonly Locale[]
/** v1 only supports 'sitewide'. */
readonly targetSections?: readonly string[]
readonly titleKey: TranslationKey
readonly descriptionKey?: TranslationKey
readonly link?: BannerLinkConfig
}
interface BannerLinkData {
readonly href: string
readonly title: string
readonly target?: '_blank'
readonly rel?: string
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerData {
readonly id: string
readonly title: string
readonly description?: string
readonly link?: BannerLinkData
}
export const bannerConfig: BannerConfig = {
id: 'announcement',
isActive: true,
targetSections: ['sitewide'],
titleKey: 'launches.banner.text',
link: {
href: '/mcp',
titleKey: 'launches.banner.cta',
buttonVariant: 'underlineLink'
}
}
/** Resolve a config's i18n keys into display strings for the given locale. */
export function getBannerData(
config: BannerConfig,
locale: Locale
): BannerData {
const { link } = config
const target = link?.target ? '_blank' : undefined
return {
id: config.id,
title: t(config.titleKey, locale),
description: config.descriptionKey
? t(config.descriptionKey, locale)
: undefined,
link: link
? {
href: link.href,
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),
buttonVariant: link.buttonVariant
}
: undefined
}
}

View File

@@ -3983,12 +3983,12 @@ const translations = {
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
en: 'Now turn your agent into a creative technologist.',
'zh-CN': '现在,让你的智能体成为创意技术专家。'
},
'launches.banner.cta': {
en: 'Join livestream',
'zh-CN': '加入直播'
en: 'Start Comfy MCP',
'zh-CN': '启动 Comfy MCP'
},
// Launches page (/launches) — closing CTA

View File

@@ -5,6 +5,14 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
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 {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
@@ -124,6 +141,25 @@ const websiteJsonLd = {
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -137,8 +173,16 @@ const websiteJsonLd = {
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<main>
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -13,7 +12,6 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -70,6 +70,7 @@
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-deep-plum: #2b2040;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
@@ -93,6 +94,14 @@
initial-value: 0deg;
}
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
script in BaseLayout head) — prevents any flash before Vue hydrates.
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
keep them in sync. */
[data-banner-dismissed] [data-slot='announcement-banner'] {
display: none;
}
@keyframes border-angle-spin {
to {
--border-angle: 360deg;

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ArrowRight, X } from '@lucide/vue'
import type { BannerData } from '../../config/banner'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import IconButton from '@/components/ui/icon-button/IconButton.vue'
import { useBannerDismissal } from '../../composables/useBannerDismissal'
const {
data,
version,
locale = 'en'
} = defineProps<{
data: BannerData
version: string
locale?: Locale
}>()
const { isVisible, close, persistHidden } = useBannerDismissal(version)
</script>
<template>
<Transition name="banner-collapse" @after-leave="persistHidden">
<div v-if="isVisible" class="banner-collapse grid">
<div class="min-h-0 overflow-hidden">
<div
data-slot="announcement-banner"
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
style="
background: linear-gradient(
90deg,
var(--color-primary-comfy-plum) 0%,
var(--color-secondary-deep-plum) 53.85%,
var(--color-secondary-mauve) 100%
);
"
>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
<p
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
>
{{ data.title }}
<span v-if="data.description" class="text-primary-warm-white/80">
{{ data.description }}
</span>
</p>
<Button
v-if="data.link"
as="a"
:href="data.link.href"
:target="data.link.target"
:rel="data.link.rel"
:variant="data.link.buttonVariant ?? 'underlineLink'"
size="sm"
>
{{ data.link.title }}
<template #append>
<ArrowRight class="size-4" />
</template>
</Button>
</div>
<div class="flex flex-1 justify-end">
<IconButton
type="button"
:aria-label="t('nav.close', locale)"
@click="close"
>
<X class="size-5" aria-hidden="true" />
</IconButton>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
leave (dismiss) runs, since the banner renders present in the static HTML. */
.banner-collapse {
grid-template-rows: 1fr;
}
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition:
grid-template-rows 300ms ease,
opacity 250ms ease;
}
.banner-collapse-enter-from,
.banner-collapse-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition: none;
}
}
</style>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import type { EvaluableBanner } from './banner'
import { createBannerVersion, evaluateBannerVisibility } from './banner'
const base: EvaluableBanner = {
isActive: true,
targetSections: ['sitewide']
}
const ctx = {
currentLocale: 'en',
currentSection: 'sitewide',
now: new Date('2026-07-06T00:00:00Z')
}
describe('evaluateBannerVisibility', () => {
it('shows an active, untargeted, sitewide banner', () => {
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
})
it('hides when inactive', () => {
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
false
)
})
it('hides before startsAt and shows within the window', () => {
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
ctx
)
).toBe(false)
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
ctx
)
).toBe(true)
})
it('hides after endsAt', () => {
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
).toBe(true)
})
it('treats an empty targetLocales as "all locales"', () => {
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
true
)
})
it('hides when targetLocales excludes the current locale', () => {
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
).toBe(true)
})
it('hides when targetSections does not include the current section', () => {
expect(
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
).toBe(false)
})
it('hides when targetSections is absent (nothing to match)', () => {
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
})
})
describe('createBannerVersion', () => {
const content = {
id: 'announcement',
title: 'Join the live stream',
link: { href: 'https://x', title: 'Join' }
}
it('is deterministic for identical content', () => {
expect(createBannerVersion(content, 'en')).toBe(
createBannerVersion(content, 'en')
)
})
it('encodes the banner id and locale in the key', () => {
expect(createBannerVersion(content, 'en')).toMatch(
/^announcement_en_v-?\d+$/
)
})
it('changes when the copy changes', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion({ ...content, title: 'New copy' }, 'en')
)
})
it('differs per locale so one locale edit does not re-show another', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion(content, 'zh-CN')
)
})
})

View File

@@ -0,0 +1,87 @@
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
// trivially unit-testable. Locale/section are plain strings on purpose.
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
// the useBannerDismissal composable, and the CSS selector in global.css must all
// agree on these literals — keep them here as the single source of truth.
export const BANNER_STORAGE_KEY = 'closedBanners'
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
export interface BannerVisibilityContext {
currentLocale: string
currentSection: string
now: Date
}
export interface EvaluableBanner {
isActive: boolean
startsAt?: string
endsAt?: string
targetLocales?: readonly string[]
targetSections?: readonly string[]
}
/**
* Server/build-time visibility gate. Returns false on the FIRST failing check,
* in order: active flag → start window → end window → locale targeting →
* section targeting. An empty/absent `targetLocales` means "all locales".
*/
export function evaluateBannerVisibility(
banner: EvaluableBanner,
ctx: BannerVisibilityContext
): boolean {
if (!banner.isActive) return false
if (
banner.startsAt &&
ctx.now.getTime() < new Date(banner.startsAt).getTime()
)
return false
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
return false
const targetLocales = banner.targetLocales ?? []
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
return false
const targetSections = banner.targetSections ?? []
if (!targetSections.includes(ctx.currentSection)) return false
return true
}
interface BannerLinkContent {
href: string
title: string
target?: string
rel?: string
buttonVariant?: string
}
export interface BannerVersionContent {
id: string
title: string
description?: string
link?: BannerLinkContent
}
/**
* Content-aware version key. Editing the copy changes the hash, so a previously
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
*/
export function createBannerVersion(
content: BannerVersionContent,
locale: string
): string {
const contentString = JSON.stringify({
locale,
title: content.title,
description: content.description,
link: content.link
})
let hash = 0
for (const char of contentString) {
hash = Math.imul(hash, 31) + char.charCodeAt(0)
}
return `${content.id}_${locale}_v${hash}`
}

View File

@@ -424,3 +424,35 @@ test.describe('Unserialized widgets', { tag: '@widget' }, () => {
.toBe(false)
})
})
test.describe('Widget value isolation', { tag: '@widget' }, () => {
test('re-added node does not inherit a cleared node widget value', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
const firstNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
const firstWidth = await firstNode.getWidgetByName('width')
const defaultWidth = await firstWidth.getValue()
await comfyPage.page.evaluate(
({ id, name, value }) => {
const node = window.app!.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) throw new Error(`Widget ${name} not found`)
widget.value = value
},
{ id: firstNode.id, name: 'width', value: 128 }
)
expect(await firstWidth.getValue()).toBe(128)
await comfyPage.nodeOps.clearGraph()
const secondNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
expect(secondNode.id).toBe(firstNode.id)
const secondWidth = await secondNode.getWidgetByName('width')
expect(await secondWidth.getValue()).toBe(defaultWidth)
})
})

View File

@@ -12,6 +12,11 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -42,6 +47,11 @@ export type {
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelJobsData,
CancelJobsError,
CancelJobsErrors,
CancelJobsResponse,
CancelJobsResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -84,6 +94,11 @@ export type {
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
CreateDesktopLoginCodeResponse,
CreateDesktopLoginCodeResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
@@ -186,12 +201,31 @@ export type {
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
DesktopLoginCodeExchangeResponse,
DesktopLoginCodeRedeemRequest,
DesktopLoginCodeRedeemResponse,
DownloadExportData,
DownloadExportError,
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
ExchangeDesktopLoginCodeErrors,
ExchangeDesktopLoginCodeResponse,
ExchangeDesktopLoginCodeResponses,
ExchangeTokenData,
ExchangeTokenError,
ExchangeTokenErrors,
@@ -230,6 +264,11 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetContentData,
GetAssetContentError,
GetAssetContentErrors,
GetAssetContentResponse,
GetAssetContentResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
@@ -303,6 +342,11 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -345,8 +389,6 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
@@ -556,6 +598,7 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -589,6 +632,8 @@ export type {
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsCancelRequest,
JobsCancelResponse,
JobsListResponse,
JobStatusResponse,
JwkKey,
@@ -627,7 +672,19 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
ListSecretProvidersErrors,
ListSecretProvidersResponse,
ListSecretProvidersResponses,
ListSecretsData,
ListSecretsError,
ListSecretsErrors,
@@ -775,6 +832,17 @@ export type {
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RedeemDesktopLoginCodeData,
RedeemDesktopLoginCodeError,
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -785,6 +853,11 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
@@ -808,6 +881,8 @@ export type {
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretProvider,
SecretProvidersResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
@@ -819,6 +894,8 @@ export type {
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -848,6 +925,10 @@ export type {
TaskEntry,
TaskResponse,
TasksListResponse,
TeamCreditStop,
TeamCreditStopPrice,
TeamCreditStops,
TeamCreditStopSummary,
UpdateAssetData,
UpdateAssetError,
UpdateAssetErrors,
@@ -865,6 +946,7 @@ export type {
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -895,6 +977,11 @@ export type {
UpdateWorkspaceData,
UpdateWorkspaceError,
UpdateWorkspaceErrors,
UpdateWorkspaceMemberRoleData,
UpdateWorkspaceMemberRoleError,
UpdateWorkspaceMemberRoleErrors,
UpdateWorkspaceMemberRoleResponse,
UpdateWorkspaceMemberRoleResponses,
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,

File diff suppressed because it is too large Load Diff

View File

@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
/**
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
*/
export const zHistoryEventRequest = z.object({
user_id: z.string().optional(),
workspace_id: z.string().optional(),
event_type: z.string().min(1),
event_id: z.string().min(1),
params: z.record(z.unknown()).optional(),
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
customer_ref: z.string().optional(),
timestamp: z.string().datetime().optional()
})
/**
* Response after recording partner usage data.
*/
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
})
/**
* Response after successfully resubscribing to a billing plan.
* Response after accepting a resubscribe request.
*/
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active']),
status: z.enum(['active', 'pending']),
message: z.string().optional()
})
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
*/
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
team_credit_stop_id: z.string().optional(),
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
idempotency_key: z.string().optional(),
return_url: z.string().optional(),
cancel_url: z.string().optional()
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
'STANDARD',
'CREATOR',
'PRO',
'FOUNDERS_EDITION'
'FOUNDERS_EDITION',
'TEAM'
])
/**
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
/**
* Pre/post-discount price for a team credit stop, in cents.
*/
export const zTeamCreditStopPrice = z.object({
list_price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A selectable preset on the team pricing slider. Echoed on subscribe via
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
* RAW monthly credit count (not cents). Save% is derived by the FE as
* (list_price_cents - price_cents) / list_price_cents.
*
*/
export const zTeamCreditStop = z.object({
id: z.string(),
credits: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
monthly: zTeamCreditStopPrice,
yearly: zTeamCreditStopPrice
})
/**
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
*/
export const zTeamCreditStops = z.object({
default_stop_index: z.number().int(),
stops: z.array(zTeamCreditStop)
})
/**
* Reason why a plan is unavailable
*/
@@ -773,7 +841,50 @@ export const zPlan = z.object({
*/
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan)
plans: z.array(zPlan),
team_credit_stops: zTeamCreditStops.optional()
})
/**
* The team credit stop a workspace is currently subscribed to: the
* per-workspace slider choice recorded at subscribe time
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
* catalog, not the subscription row. Returned on GET /api/billing/status
* for per-credit Team plans (BE-1254).
*
*/
export const zTeamCreditStopSummary = z.object({
id: z.string(),
credits_monthly: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
stop_usd: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
*/
export const zSecretProvider = z.object({
id: z.string()
})
/**
* The providers available to the authenticated user in the current workspace.
*/
export const zSecretProvidersResponse = z.object({
data: z.array(zSecretProvider)
})
/**
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
})
/**
* A single billing event such as a charge, credit, or adjustment.
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
*/
export const zBillingEvent = z.object({
event_type: z.string(),
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
billing_status: zBillingStatus.optional(),
has_funds: z.boolean(),
cancel_at: z.string().datetime().optional(),
renewal_date: z.string().datetime().optional()
renewal_date: z.string().datetime().optional(),
team_credit_stop: zTeamCreditStopSummary.nullable()
})
/**
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
redirect_uri: z.string().url(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
customer_id: z.string().min(1)
})
/**
* The personal workspace's provisioned billing identity.
*/
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
workspace_id: z.string(),
stripe_customer_id: z.string(),
metronome_customer_id: z.string(),
metronome_contract_id: z.string()
})
/**
* The caller's already-resolved legacy (comfy-api) customer identity. When
* present and carrying provider IDs, provisioning ATTACHES this identity to
* the personal workspace (sharing the existing balance and subscription)
* instead of minting a net-new empty customer. Omit (or send with no
* provider IDs) for a free user with nothing to attach — provisioning then
* creates net-new. This closes the create-new-before-attach gap: a caller
* that already knows the legacy identity hands it over so the very first
* provisioning is an attach.
*
*/
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
stripe_customer_id: z.string().optional(),
metronome_customer_id: z.string().optional(),
metronome_contract_id: z.string().optional(),
has_funds: z.boolean().optional(),
subscription_tier: z.string().optional(),
legacy_stripe_subscription_id: z.string().optional(),
legacy_comfy_user_id: z.string().optional()
})
/**
* Request body for ensuring a user's personal workspace carries a fully
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
* with the already canonical-resolved user identity.
*
*/
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
user_id: z.string().min(1),
email: z.string().email().min(1),
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
})
/**
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
* no mappings exist (not an error — callers can treat empty as "unknown
* canonical").
*
*/
export const zListLinkedFirebaseUidsResponse = z.object({
firebase_uids: z.array(z.string())
})
/**
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
*/
export const zListLinkedFirebaseUidsRequest = z.object({
comfy_user_id: z.string().min(1)
})
/**
* Response confirming the validity and scope of a workspace API key.
*/
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(['owner', 'member']),
joined_at: z.string().datetime()
joined_at: z.string().datetime(),
is_original_owner: z.boolean()
})
/**
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
pagination: zPaginationInfo
})
/**
* Request body for changing a workspace member's role.
*/
export const zUpdateMemberRoleRequest = z.object({
role: z.enum(['owner', 'member'])
})
/**
* Request body for updating an existing workspace's settings.
*/
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
/**
* Exchange poll result. Pending until the code is redeemed in the browser.
*/
export const zDesktopLoginCodeExchangeResponse = z.object({
status: z.enum(['pending', 'complete']),
custom_token: z.string().optional()
})
/**
* Request to exchange a redeemed login code for a custom token.
*/
export const zDesktopLoginCodeExchangeRequest = z.object({
code: z.string(),
code_verifier: z.string().min(43).max(128)
})
/**
* Result of redeeming a desktop login code.
*/
export const zDesktopLoginCodeRedeemResponse = z.object({
status: z.enum(['redeemed'])
})
/**
* Request to claim a desktop login code for the authenticated user.
*/
export const zDesktopLoginCodeRedeemRequest = z.object({
code: z.string()
})
/**
* A freshly minted desktop login code and its polling parameters.
*/
export const zDesktopLoginCodeCreateResponse = z.object({
code: z.string(),
expires_in: z.number().int(),
poll_interval: z.number().int()
})
/**
* Request to mint a desktop login code.
*/
export const zDesktopLoginCodeCreateRequest = z.object({
installation_id: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.optional(),
platform: z.string().min(1).max(32),
app_version: z.string().min(1).max(64),
code_challenge: z.string().min(43).max(128)
})
/**
* Abbreviated workspace metadata used in list responses.
*/
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
pagination: zPaginationInfo
})
/**
* Result of authorizing a legal-hold release on a user's deletion.
*/
export const zReleaseHoldResponse = z.object({
firebase_id: z.string(),
released: z.boolean(),
message: z.string()
})
/**
* Current status of a user data deletion request.
*/
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
/**
* Response for POST /api/jobs/cancel.
*/
export const zJobsCancelResponse = z.object({
cancelled: z.array(z.string())
})
/**
* Request to cancel multiple jobs by ID.
*/
export const zJobsCancelRequest = z.object({
job_ids: z.array(z.string().uuid()).min(1).max(100)
})
/**
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
*/
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
path: z.object({
job_id: z.string().uuid()
}),
query: z.never().optional()
query: z
.object({
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
})
.optional()
})
/**
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
*/
export const zCancelJobResponse = zJobCancelResponse
export const zCancelJobsData = z.object({
body: zJobsCancelRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - cancel requests dispatched (or jobs were already terminal)
*/
export const zCancelJobsResponse = zJobsCancelResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
*/
export const zCreateSecretResponse = zSecretResponse
export const zListSecretProvidersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zListSecretProvidersResponse = zSecretProvidersResponse
export const zDeleteSecretData = z.object({
body: z.never().optional(),
path: z.object({
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
*/
export const zExchangeTokenResponse2 = zExchangeTokenResponse
export const zCreateDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeCreateRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Login code created
*/
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
export const zRedeemDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeRedeemRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Code redeemed (or already redeemed by the same user)
*/
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
export const zExchangeDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeExchangeRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Pending (not yet redeemed) or complete with a custom token
*/
export const zExchangeDesktopLoginCodeResponse =
zDesktopLoginCodeExchangeResponse
export const zGetJwksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zUpdateWorkspaceMemberRoleData = z.object({
body: zUpdateMemberRoleRequest,
path: z.object({
userId: z.string()
}),
query: z.never().optional()
})
/**
* Member role updated
*/
export const zUpdateWorkspaceMemberRoleResponse = zMember
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zAdminDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully deleted
*/
export const zAdminDeleteHubWorkflowResponse = z.void()
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReleaseDeletionHoldData = z.object({
body: z.object({
firebase_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Release authorized; the deletion workflow will proceed
*/
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zGetHistoryEventsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
workspace_id: z.string().optional(),
user_id: z.string().optional(),
event_type: z.string().optional(),
start_date: z.string().datetime().optional(),
end_date: z.string().datetime().optional(),
page: z.number().int().optional(),
limit: z.number().int().optional()
})
.optional()
})
/**
* Paginated cloud history events for the workspace
*/
export const zGetHistoryEventsResponse = zBillingEventsResponse
export const zReportHistoryEventData = z.object({
body: zHistoryEventRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* History event recorded successfully
*/
export const zReportHistoryEventResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zListLinkedFirebaseUidsData = z.object({
body: zListLinkedFirebaseUidsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Linked Firebase UIDs (possibly empty list)
*/
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
export const zEnsureWorkspaceBillingProvisionedData = z.object({
body: zEnsureWorkspaceBillingProvisionedRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* The workspace's provisioned billing identity
*/
export const zEnsureWorkspaceBillingProvisionedResponse2 =
zEnsureWorkspaceBillingProvisionedResponse
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
query: z.never().optional()
})
export const zShortLinkRedirectData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
query: z.never().optional()
})
export const zGetLegacyAssetContentData = z.object({
export const zGetAssetContentData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
query: z
.object({
disposition: z.enum(['inline', 'attachment']).optional()
})
.optional()
})
/**
* Asset content stream (local runtime streams the bytes directly)
*/
export const zGetAssetContentResponse = z.string()
export const zGetLegacyViewMetadataData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "*.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "vitest.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -35,10 +35,10 @@
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
@@ -82,7 +82,7 @@
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -95,10 +95,10 @@
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE

View File

@@ -7,7 +7,7 @@ import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockTurnstileToken = ref('')
const mockTurnstileUnavailable = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
// useTurnstile.test.ts); this fake just wires token/unavailable through to
// `waiting` the same way so SignUpForm's submit gating can be exercised.
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
enabled: mockTurnstileEnabled
}),
useTurnstileGate: () => ({
token: mockTurnstileToken,
unavailable: mockTurnstileUnavailable,
waiting: computed(
() =>
mockTurnstileEnabled.value &&
!mockTurnstileToken.value &&
!mockTurnstileUnavailable.value
)
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
// the way a solved challenge (or a broken/slow widget) would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
emits: ['update:token', 'update:unavailable'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
emitTurnstileUnavailable = (unavailable: boolean) =>
emit('update:unavailable', unavailable)
return () => null
}
})
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockTurnstileToken.value = ''
mockTurnstileUnavailable.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
emitTurnstileUnavailable = undefined
})
afterEach(() => {
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
// Regression coverage for the shadow-mode race: previously submit was only
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
// ahead of the async Cloudflare challenge and reached the backend with an
// empty token. Gating now depends only on whether the widget is enabled
// (shadow or enforce both render it), so both modes behave identically here.
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
it('disables the submit button until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
it('does not emit submit while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
it('emits submit with the token once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileUnavailable!(true)
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)

View File

@@ -33,10 +33,11 @@
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
v-model:unavailable="turnstileUnavailable"
/>
<small
v-show="submitBlockedByTurnstile"
v-show="waitingForTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
@@ -51,11 +52,9 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid || submitBlockedByTurnstile"
:disabled="!$form.valid || waitingForTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
"
>
{{ t('auth.signup.signUpButton') }}
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
@@ -86,25 +85,21 @@ const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const { enabled: turnstileEnabled } = useTurnstile()
const {
token: turnstileToken,
unavailable: turnstileUnavailable,
waiting: waitingForTurnstile
} = useTurnstileGate(turnstileEnabled)
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid && !submitBlockedByTurnstile.value) {
if (event.valid && !waitingForTurnstile.value) {
emit(
'submit',
event.values as SignUpData,

View File

@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
// hung challenge) must eventually tell the parent it cannot be relied on,
// so submission can fall back instead of blocking a legitimate signup
// forever.
describe('unavailable fallback', () => {
it('reports unavailable when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted } = renderWidget()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('reports unavailable on a challenge error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('clears the unavailable fallback once a token is solved', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
})
it('falls back once the widget fails to resolve within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
// Let the onMounted hook's `await loadTurnstile()` microtask settle
// and render() run, without yet advancing to the timeout itself.
await vi.advanceTimersByTimeAsync(0)
expect(options()).toBeDefined()
expect(emitted()['update:unavailable']).toBeUndefined()
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
it('does not fall back once a token arrives before the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
options()!.callback!('token-abc')
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
// Establish a solved, available widget: an initial error marks it
// unavailable, then solving a challenge clears that (the same
// transition the existing "clears the unavailable fallback" test
// verifies), so the expiry below is the only thing driving fallback.
options()!['error-callback']!()
options()!.callback!('token-abc')
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
// The token later expires (e.g. tab backgrounded past its ~300s
// lifetime) without the widget itself erroring.
options()!['expired-callback']!()
await vi.advanceTimersByTimeAsync(0)
// A fresh challenge was requested, but nothing solves it before the
// re-armed load timeout elapses, so submission must eventually be
// unblocked rather than staying stuck forever.
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -12,6 +12,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
/**
* Set true whenever the widget cannot be relied on to ever produce a token:
* the Cloudflare script failed to load, the rendered challenge errored out,
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
* uses this to stop waiting on a token so a broken/slow widget (network
* issue, ad-blocker, CDN outage) can never permanently block signup.
*/
const unavailable = defineModel<boolean>('unavailable', { default: false })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
/** How long to wait for the widget to resolve before falling back. */
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
() => {
unavailable.value = true
},
TURNSTILE_LOAD_TIMEOUT_MS,
{ immediate: false }
)
const clearToken = () => {
token.value = ''
}
@@ -46,12 +65,18 @@ const reset = () => {
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A widget that renders can request a fresh challenge, so give it
// another chance before falling back again.
unavailable.value = false
armTimeout()
}
}
defineExpose({ reset })
onMounted(async () => {
armTimeout()
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
@@ -64,23 +89,37 @@ onMounted(async () => {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
clearLoadTimeout()
errorMessage.value = ''
unavailable.value = false
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A solved token can expire on its own (e.g. the tab was
// backgrounded past the token's ~300s lifetime) without the widget
// ever erroring, so proactively request a fresh challenge and
// re-arm the load timeout in case it doesn't resolve in time.
armTimeout()
}
},
'error-callback': () => {
clearToken()
clearLoadTimeout()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
clearLoadTimeout()
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
}
})

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
useTurnstile,
useTurnstileGate
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
})
})
})
describe('useTurnstileGate', () => {
it('waits while enabled with no token yet', () => {
const { waiting } = useTurnstileGate(ref(true))
expect(waiting.value).toBe(true)
})
it('never waits while disabled', () => {
const { waiting } = useTurnstileGate(ref(false))
expect(waiting.value).toBe(false)
})
it('stops waiting once a token arrives', () => {
const { token, waiting } = useTurnstileGate(ref(true))
token.value = 'token-abc'
expect(waiting.value).toBe(false)
})
it('stops waiting once the widget reports itself unavailable', () => {
const { unavailable, waiting } = useTurnstileGate(ref(true))
unavailable.value = true
expect(waiting.value).toBe(false)
})
it('clears stale token/unavailable state when enabled turns off', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
token.value = 'stale-token'
unavailable.value = true
enabled.value = false
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
// Regression coverage: the reset used to only run on the enabled->disabled
// transition, so state written while the widget was briefly disabled could
// survive into the next enabled widget instance.
it('clears stale token/unavailable state when enabled turns back on', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
enabled.value = false
await nextTick()
token.value = 'stale-token'
unavailable.value = true
enabled.value = true
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -42,3 +43,31 @@ export function useTurnstile() {
return { mode, siteKey, enabled, enforced }
}
/**
* Submit-gating state for the signup form's Turnstile widget: a token/
* unavailable pair, plus `waiting`, which is true while a real token is still
* needed. Waits in both shadow and enforce mode (`enabled`), not just
* `enforced`, so shadow mode's token can't race the async Cloudflare
* challenge; falls back open once the widget reports `unavailable` so a
* broken/slow load can never permanently block signup.
*
* `token`/`unavailable` reset on every `enabled` transition, in either
* direction, so state from a previous widget instance can never leak into a
* freshly (re-)rendered one.
*/
export function useTurnstileGate(enabled: Ref<boolean>) {
const token = ref('')
const unavailable = ref(false)
const waiting = computed(
() => enabled.value && !token.value && !unavailable.value
)
watch(enabled, () => {
token.value = ''
unavailable.value = false
})
return { token, unavailable, waiting }
}

View File

@@ -260,6 +260,7 @@ useExtensionService().registerExtension({
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }

View File

@@ -344,6 +344,66 @@ describe('Graph Clearing and Callbacks', () => {
[]
)
})
test('clear() purges widget state on a zero-UUID graph that holds nodes (node-id reuse leak)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
expect(graph.id).toBe(zeroUuid)
const node = new LGraphNode('ImageAnalyze')
node.id = toNodeId(1)
graph.add(node)
const widgetValueStore = useWidgetValueStore()
const modeWidgetId = widgetId(graph.id, toNodeId(1), 'mode')
widgetValueStore.registerWidget(modeWidgetId, {
type: 'combo',
value: 'Black White Levels',
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graph.id, `${graph.id}:1`, {
sourceNodeId: '1',
sourcePreviewName: '$$canvas-image-preview'
})
graph.clear()
expect(widgetValueStore.getWidget(modeWidgetId)).toBeUndefined()
expect(
previewExposureStore.getExposures(graph.id, `${graph.id}:1`)
).toEqual([])
})
test('constructing a new empty graph does not purge existing zero-UUID widget state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const active = new LGraph()
const node = new LGraphNode('ImageAnalyze')
node.id = toNodeId(1)
active.add(node)
const widgetValueStore = useWidgetValueStore()
const modeWidgetId = widgetId(active.id, toNodeId(1), 'mode')
widgetValueStore.registerWidget(modeWidgetId, {
type: 'combo',
value: 'keep me',
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
const throwaway = new LGraph()
expect(throwaway.id).toBe(zeroUuid)
expect(widgetValueStore.getWidget(modeWidgetId)?.value).toBe('keep me')
})
})
describe('node:before-removed event', () => {

View File

@@ -392,7 +392,9 @@ export class LGraph
this.status = LGraph.STATUS_STOPPED
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
const isEmptyUnconfiguredGraph =
graphId === zeroUuid && this._nodes.length === 0
if (this.isRootGraph && !isEmptyUnconfiguredGraph) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
@@ -2494,7 +2496,6 @@ export class LGraph
// Deprecated - old schema version, links are arrays
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
if (!linkData) continue
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
}

View File

@@ -133,7 +133,7 @@ export interface ISerialisedGraph extends BaseExportedGraph {
last_node_id: SerializedNodeId
last_link_id: number
nodes: ISerialisedNode[]
links: (SerialisedLLinkArray | null)[]
links: SerialisedLLinkArray[]
floatingLinks?: SerialisableLLink[]
groups: ISerialisedGroup[]
version: typeof LiteGraph.VERSION

View File

@@ -195,6 +195,46 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('platform axes (client / deployment)', () => {
afterEach(() => {
delete window.__comfyDesktop2
})
it('registers client=web and deployment=cloud in a plain browser', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
client: 'web',
deployment: 'cloud'
})
})
it('registers client=desktop when the desktop preload bridge is present', async () => {
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: { capture: vi.fn() }
}
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
client: 'desktop',
deployment: 'cloud'
})
})
it('registers platform axes before flushing pre-init queued events', async () => {
const provider = createProvider()
provider.trackSignupOpened()
await vi.dynamicImportSettled()
const registerOrder = hoisted.mockRegister.mock.invocationCallOrder[0]
const captureOrder = hoisted.mockCapture.mock.invocationCallOrder[0]
expect(registerOrder).toBeLessThan(captureOrder)
})
})
describe('desktop entry capture', () => {
function setLocation(search: string): void {
Object.defineProperty(window.location, 'search', {
@@ -208,12 +248,20 @@ describe('PostHogTelemetryProvider', () => {
setLocation('')
})
// The platform-axes register (client/deployment) always fires, so these
// assert no register call carrying desktop-entry attribution props.
function desktopEntryRegisterCalls(): unknown[][] {
return hoisted.mockRegister.mock.calls.filter(
([props]) => props && 'source_app' in (props as Record<string, unknown>)
)
}
it('does not register desktop props when utm_source is absent', async () => {
setLocation('')
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
expect(desktopEntryRegisterCalls()).toHaveLength(0)
})
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
@@ -221,7 +269,7 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
expect(desktopEntryRegisterCalls()).toHaveLength(0)
})
it('registers source_app and desktop_device_id when arriving from desktop', async () => {

View File

@@ -143,6 +143,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
before_send: createPostHogBeforeSend()
})
this.isInitialized = true
// Before flushEventQueue so pre-init events also carry the
// platform super properties.
this.registerPlatformProps()
this.flushEventQueue()
this.registerDesktopEntryProps()
@@ -285,6 +288,18 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
)
}
private registerPlatformProps(): void {
if (!this.posthog) return
try {
this.posthog.register({
client: window.__comfyDesktop2 ? 'desktop' : 'web',
deployment: 'cloud'
})
} catch (error) {
console.error('Failed to register platform props:', error)
}
}
private registerDesktopEntryProps(): void {
if (!this.posthog) return
const props = readDesktopEntryProps()

View File

@@ -21,7 +21,7 @@ const { isCreditsBadge } = usePriceBadge()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.rootGraph, (node) => {
mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)

File diff suppressed because it is too large Load Diff

View File

@@ -244,7 +244,7 @@ export class ComfyApp {
static clipspace_invalidate_handler: (() => void) | null = null
static open_maskeditor: (() => void) | null = null
static maskeditor_is_opended: (() => void) | null = null
static clipspace_return_node: LGraphNode | null = null
static clipspace_return_node = null
vueAppReady: boolean
api: ComfyApi
@@ -257,8 +257,8 @@ export class ComfyApp {
// TODO: Migrate internal usage to the
/** @deprecated Use {@link rootGraph} instead */
get graph(): LGraph | undefined {
return this.rootGraphInternal
get graph() {
return this.rootGraphInternal!
}
get rootGraph(): LGraph {
@@ -1167,7 +1167,7 @@ export class ComfyApp {
}
async loadGraphData(
graphData?: ComfyWorkflowJSON | unknown[],
graphData?: ComfyWorkflowJSON,
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,

View File

@@ -74,7 +74,7 @@ describe('useSubgraphNavigationStore', () => {
app.canvas.ds.offset = [0, 0]
app.canvas.ds.state.scale = 1
app.canvas.ds.state.offset = [0, 0]
app.rootGraph.getNodeById = vi.fn()
app.graph.getNodeById = vi.fn()
})
it('should not clear navigation stack when workflow internal state changes', async () => {
@@ -223,7 +223,7 @@ describe('useSubgraphNavigationStore', () => {
const unreachableSubgraph = createMockSubgraph('orphan-subgraph', app.graph)
app.rootGraph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
app.graph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
vi.mocked(findSubgraphPathById).mockReturnValue(null)
const mockWorkflow = fromPartial<ComfyWorkflow>({
@@ -251,7 +251,7 @@ describe('useSubgraphNavigationStore', () => {
const mockSubgraph = createMockSubgraph('subgraph-1', app.graph)
// Add the subgraph to the graph's subgraphs map
app.rootGraph.subgraphs.set('subgraph-1', mockSubgraph)
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
// First set an active workflow
const mockWorkflow = fromPartial<ComfyWorkflow>({

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -90,7 +89,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
return rafCallbacks.length
})
mockCanvas.subgraph = undefined
mockCanvas.graph = app.graph ?? null
mockCanvas.graph = app.graph
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.state.scale = 1
@@ -137,23 +136,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('saveViewport', () => {
it('does not save when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
try {
store.saveViewport('root')
expect(store.viewportCache.has(':root')).toBe(false)
} finally {
appWithOptionalCanvas.canvas = canvas
}
})
it('saves viewport state for root graph', () => {
const store = useSubgraphNavigationStore()
mockCanvas.ds.state.scale = 2
@@ -182,42 +164,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('restoreViewport', () => {
it('does nothing when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
try {
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(0)
} finally {
appWithOptionalCanvas.canvas = canvas
}
})
it('does not apply cached viewport when canvas disappears', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as {
canvas: typeof app.canvas | undefined
}
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
appWithOptionalCanvas.canvas = undefined
try {
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
} finally {
appWithOptionalCanvas.canvas = canvas
}
})
it('restores cached viewport', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
@@ -320,7 +266,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
@@ -337,7 +283,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(rafCallbacks).toHaveLength(1)
// Simulate graph switching away before rAF fires
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[0](performance.now())
@@ -350,12 +296,12 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const mockRootGraph = fromPartial<LGraph>({
const mockRootGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
})
} as Partial<LGraph> as LGraph
const subgraph1 = {
id: 'sub1',
rootGraph: mockRootGraph,
@@ -367,7 +313,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
mockCanvas.ds.state.offset = [100, 100]
// Enter subgraph
workflowStore.activeSubgraph = fromPartial<Subgraph>(subgraph1)
workflowStore.activeSubgraph = subgraph1 as Partial<Subgraph> as Subgraph
await nextTick()
// Root viewport saved
@@ -395,23 +341,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const subgraph = fromPartial<Subgraph>({
id: 'sub1',
isRootGraph: false,
rootGraph: app.rootGraph
})
store.saveCurrentViewport()
store.viewportCache.clear()
workflowStore.activeSubgraph = subgraph
await nextTick()
expect(store.viewportCache.has(':root')).toBe(false)
})
it('preserves pre-existing cache entries across workflow switches', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()

View File

@@ -225,5 +225,19 @@ describe('useWidgetValueStore', () => {
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidget(seedB)?.value).toBe(2)
})
it('re-registering a reused widget id after clearGraph returns fresh state', () => {
const store = useWidgetValueStore()
const modeA = widgetId(graphA, toNodeId('node-1'), 'mode')
store.registerWidget(modeA, state('combo', 'Black White Levels'))
expect(store.getWidget(modeA)?.value).toBe('Black White Levels')
store.clearGraph(graphA)
const reused = store.registerWidget(modeA, state('combo', 'normal'))
expect(reused.value).toBe('normal')
expect(store.getWidget(modeA)?.value).toBe('normal')
})
})
})

View File

@@ -1,5 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import type { PartialDeep } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
@@ -9,7 +8,6 @@ import type {
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { LinkId } from '@/types/linkId'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
@@ -164,26 +162,6 @@ describe('fixBadLinks', () => {
expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(1)
})
it('reports a missing target input link during a dry run', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1, outputs: [createOutput([1])] }),
createNode({ id: 2, inputs: [createInput(null)] })
],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph)
expect(result).toMatchObject({
hasBadLinks: true,
fixed: false,
patched: 1,
deleted: 0
})
expect(graph.nodes[1]?.inputs?.[0]?.link).toBeNull()
})
it('removes the origin reference when the target input slot is missing', () => {
const graph = createGraph({
nodes: [
@@ -228,53 +206,6 @@ describe('fixBadLinks', () => {
expect(graph.links).toEqual([])
})
it('keeps the later target link when two links target the same input slot', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1, outputs: [createOutput([1])] }),
createNode({ id: 2, outputs: [createOutput([2])] }),
createNode({ id: 3, inputs: [createInput(null)] })
],
links: [
[1, 1, 0, 3, 0, '*'],
[2, 2, 0, 3, 0, '*']
]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
deleted: 1
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([])
expect(graph.nodes[1]?.outputs?.[0]?.links).toEqual([2])
expect(graph.nodes[2]?.inputs?.[0]?.link).toBe(2)
expect(graph.links).toEqual([[2, 2, 0, 3, 0, '*']])
})
it('reports stale origin references during a dry run', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1, outputs: [createOutput([1])] }),
createNode({ id: 2, inputs: [createInput(2)] })
],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph)
expect(result).toMatchObject({
hasBadLinks: true,
fixed: false,
patched: 1,
deleted: 1
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1])
expect(graph.links).toEqual([[1, 1, 0, 2, 0, '*']])
})
it('cleans dangling references when a linked node is missing', () => {
const graph = createGraph({
nodes: [createNode({ id: 2, inputs: [createInput(1)] })],
@@ -293,24 +224,6 @@ describe('fixBadLinks', () => {
expect(graph.links).toEqual([])
})
it('deletes missing-origin links when the target does not reference them', () => {
const graph = createGraph({
nodes: [createNode({ id: 2, inputs: [createInput(null)] })],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.nodes[0]?.inputs?.[0]?.link).toBeNull()
expect(graph.links).toEqual([])
})
it('cleans dangling origin references when the target node is missing', () => {
const graph = createGraph({
nodes: [createNode({ id: 1, outputs: [createOutput([1])] })],
@@ -329,24 +242,6 @@ describe('fixBadLinks', () => {
expect(graph.links).toEqual([])
})
it('deletes missing-target links when the origin does not reference them', () => {
const graph = createGraph({
nodes: [createNode({ id: 1, outputs: [createOutput([])] })],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([])
expect(graph.links).toEqual([])
})
it('deletes a stale link that neither endpoint references', () => {
const graph = createGraph({
nodes: [
@@ -369,10 +264,10 @@ describe('fixBadLinks', () => {
it('deletes stale links from live graph link maps', () => {
const linkId = toLinkId(1)
const originNode = fromPartial<LGraphNode>({
const originNode = fromAny<LGraphNode, unknown>({
id: toNodeId(1),
outputs: [{ links: [linkId] }]
} as PartialDeep<LGraphNode>)
})
const link = fromPartial<LLink>({
id: linkId,
origin_id: originNode.id,
@@ -381,14 +276,13 @@ describe('fixBadLinks', () => {
target_slot: 0,
type: '*'
})
const links = new Map([[linkId, link]]) as Map<LinkId, LLink> &
Record<LinkId, LLink>
const graph = fromPartial<LGraph>({
const links = new Map([[linkId, link]])
const graph = fromAny<LGraph, unknown>({
links,
getNodeById: vi.fn((nodeId) =>
nodeId === originNode.id ? originNode : null
)
} as PartialDeep<LGraph>)
})
const result = fixBadLinks(graph, { fix: true, silent: true })
@@ -423,118 +317,4 @@ describe('fixBadLinks', () => {
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1])
expect(logger.log).not.toHaveBeenCalled()
})
it('creates missing origin output slots in fix mode', () => {
const graph = createGraph({
nodes: [
createNode({ id: 1 }),
createNode({ id: 2, inputs: [createInput(1)] })
],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 1,
deleted: 0
})
expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1])
})
it('deletes links whose serialized endpoints are both missing', () => {
const graph = createGraph({
nodes: [],
links: [[1, 1, 0, 2, 0, '*']]
})
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.links).toEqual([])
})
it('ignores null serialized link entries', () => {
const graph = {
...createGraph({
nodes: [createNode({ id: 1 })],
links: []
}),
links: [null]
}
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: false,
patched: 0,
deleted: 0
})
expect(graph.links).toEqual([])
})
it('deletes object-shaped serialized links', () => {
const objectLink = {
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: '*'
}
const graph = fromPartial<ISerialisedGraph>({
...createGraph({
nodes: [],
links: []
})
} as PartialDeep<ISerialisedGraph>)
Reflect.set(graph, 'links', [objectLink])
const result = fixBadLinks(graph, { fix: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.links).toEqual([])
})
it('treats invalid live graph endpoint ids as missing', () => {
const linkId = toLinkId(1)
const link = fromPartial<LLink>({
id: linkId,
origin_id: toNodeId(''),
origin_slot: 0,
target_id: toNodeId(''),
target_slot: 0,
type: '*'
})
const links = new Map([[linkId, link]]) as Map<LinkId, LLink> &
Record<LinkId, LLink>
const graph = fromPartial<LGraph>({
links,
getNodeById: vi.fn()
} as PartialDeep<LGraph>)
const result = fixBadLinks(graph, { fix: true, silent: true })
expect(result).toMatchObject({
hasBadLinks: false,
fixed: true,
patched: 0,
deleted: 1
})
expect(graph.getNodeById).not.toHaveBeenCalled()
expect(links.has(linkId)).toBe(false)
})
})

View File

@@ -283,9 +283,7 @@ export function fixBadLinks(
return hasAny
}
const links: Array<SerialisedLLinkArray | LLink | null> = Array.isArray(
graph.links
)
const links: Array<SerialisedLLinkArray | LLink> = Array.isArray(graph.links)
? graph.links
: Array.from((graph as LGraph).links.values())

View File

@@ -269,9 +269,7 @@ export function compressWidgetInputSlots(graph: ISerialisedGraph) {
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
if (input.link) {
const link = graph.links.find(
(link) => link !== null && link[0] === input.link
)
const link = graph.links.find((link) => link[0] === input.link)
if (link) {
link[4] = inputIndex
}