Compare commits

...

15 Commits

Author SHA1 Message Date
Matt Miller
554fd45096 Merge branch 'main' into matt/be-2793-cloud-secrets-e2e-structure 2026-07-09 23:48:17 -04:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Matt Miller
6d4327e907 test: address review feedback on secrets + dialog tests
- Remove leaked focus-target button after focus-outside tests via a
  returned cleanup callback (test isolation)
- Flush all pending microtasks instead of a hardcoded tick count so the
  focus-outside negative control is robust to handler await depth
- Assert the secret is never echoed into a field with a single immediate
  check rather than poll-until-false, which could mask a transient leak

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:42:02 -07:00
Matt Miller
195580630b test: restructure cloud secrets E2E scaffolding into fixtures
Move the in-memory /secrets backend and the open-panel helper out of
cloudSecrets.spec.ts into browser_tests/fixtures/utils/cloudSecretsMocks.ts,
alongside the sibling cloud mocks. openSecretsPanel now drives the shared
SettingDialog page object (.open()/.category()) instead of hand-rolling the
command dispatch and nav click.

Also assert the write-only secret never lands in any input/textarea value
(getByText only sees text nodes), and add a mounted GlobalDialog test that
exercises the @focus-outside -> dismissOnFocusOutside:false binding through
the real template wiring, with a positive control proving the path fires.
2026-07-09 19:29:41 -07:00
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
Matt Miller
2ef341dcd8 test: E2E for BYOK secret add / list / delete flow (#13510)
## ELI-5

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

## What

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

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

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

## Why

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

## Tests

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

---------

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

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

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

## Changes

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

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

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

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

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

## Review Focus

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

## Verification

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

## Screenshots

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

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

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

## Notes

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

## Test plan

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

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

---------

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

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

## Summary

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

## Changes

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

## Review Focus

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

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

## Changes

**What**

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

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


## Testing

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

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

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

## Screen Recording



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

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

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

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

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

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

## Changes

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

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

## Changes

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

## Review Focus

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

**Base branch:** `main`

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-07-09 07:21:27 +00:00
81 changed files with 4624 additions and 1252 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Shared scaffolding for the cloud user-secrets (API keys) E2E, alongside the
* sibling cloud mocks (`cloudBillingMocks`, `workspaceMocks`): the stateful
* in-memory `/secrets` backend and the open-the-Secrets-panel helper, so the
* spec files hold only the behavioral flow.
*/
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
export const SECRETS_BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
export const SECRETS_BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
export async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches. Returns the dialog root locator
* for scoping the caller's assertions.
*/
export async function openSecretsPanel(settingDialog: SettingDialog) {
const { page } = settingDialog
await settingDialog.open()
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingDialog.category('Secrets').click()
await Promise.all([providersResolved, listResolved])
return settingDialog.root
}

View File

@@ -0,0 +1,164 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import {
SECRETS_BOOT_FEATURES,
SECRETS_BOOT_SETTINGS,
mockSecretsBackend,
openSecretsPanel
} from '@e2e/fixtures/utils/cloudSecretsMocks'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
* add a provider key, see it listed, delete it — the full CRUD round-trip —
* plus the entitlement contract that a non-entitled account never sees the
* gated providers.
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and `mockSecretsBackend` layers a
* stateful in-memory `/secrets` backend on top so the flow is deterministic
* and never touches a real server. A bare `ComfyPage` (constructed, never
* `setup()`) supplies the shared `SettingDialog` page object without the
* backend-touching fixture setup.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page,
request
}) => {
test.slow()
await mockCloudBoot(page, {
features: SECRETS_BOOT_FEATURES,
settings: SECRETS_BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const comfyPage = new ComfyPage(page, request)
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// --- ADD -------------------------------------------------------------
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
// Pick the entitled Runway provider from the server-driven dropdown.
await formDialog.locator('#secret-provider').click()
await page.getByRole('option', { name: 'Runway' }).click()
await formDialog.locator('#secret-name').fill('My Runway Key')
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(formDialog).toBeHidden()
// --- LIST ------------------------------------------------------------
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
// The create request carried the plaintext value + provider...
expect(backend.createRequests).toHaveLength(1)
expect(backend.createRequests[0]).toMatchObject({
name: 'My Runway Key',
provider: 'runway',
secret_value: RUNWAY_KEY_VALUE
})
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// `getByText` only sees text nodes; a value reflected into an `<input>` or
// masked field would slip past it, so assert no field carries it either.
// The list has already settled above, so this is a single immediate
// assertion — a poll-until-false could mask a value that briefly echoed
// into a field and then cleared within the polling window.
const secretEchoedInField = await page
.locator('input, textarea')
.evaluateAll(
(fields, value) =>
fields.some(
(field) =>
(field as HTMLInputElement | HTMLTextAreaElement).value === value
),
RUNWAY_KEY_VALUE
)
expect(secretEchoedInField).toBe(false)
// --- DELETE ----------------------------------------------------------
await settingsDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
const confirmDialog = page
.getByRole('dialog')
.filter({ hasText: 'Delete Secret' })
await confirmDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
expect(backend.store).toHaveLength(0)
})
test('a non-entitled account never sees the gated providers', async ({
page,
request
}) => {
test.slow()
await mockCloudBoot(page, {
features: SECRETS_BOOT_FEATURES,
settings: SECRETS_BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
await mockSecretsBackend(page, [])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const comfyPage = new ComfyPage(page, request)
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated
// providers must not appear anywhere.
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
await formDialog.locator('#secret-provider').click()
// Anchor on the opened listbox so the absence assertions below can't pass
// vacuously against a dropdown that never opened.
const providerListbox = page.getByRole('listbox')
await expect(providerListbox).toBeVisible()
// An empty allowlist must yield an empty dropdown. Asserting zero options
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
// where `availableProviders` is null and the default providers would show.
await expect(providerListbox.getByRole('option')).toHaveCount(0)
})
})

View File

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

View File

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

View File

@@ -32,6 +32,9 @@ const Body = defineComponent({
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const ClosedNonModalDialog = defineComponent({
name: 'ClosedNonModalDialog',
setup: () => () =>
@@ -361,6 +364,82 @@ describe('GlobalDialog Reka overlay scrim', () => {
})
})
describe('GlobalDialog Reka focus-outside binding', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
// Reka's DismissableLayer fires focus-outside off a real focus transition
// (blur inside the layer, then focusin on the new target), so drive the
// mounted binding by moving focus to a fresh element outside the dialog
// rather than dispatching a synthetic event.
async function moveFocusToPlainElementOutside() {
const outside = document.createElement('button')
document.body.appendChild(outside)
outside.focus()
return () => outside.remove()
}
it('dismisses on focus-outside by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'focus-default',
title: 'Focus dismisses',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
const removeOutside = await moveFocusToPlainElementOutside()
try {
await waitFor(() =>
expect(store.isDialogOpen('focus-default')).toBe(false)
)
} finally {
removeOutside()
}
})
it('does not dismiss on focus-outside when dismissOnFocusOutside is false', async () => {
// Exercises GlobalDialog's own template wiring
// `@focus-outside="(e) => onRekaFocusOutside(e, item.dialogComponentProps)"`
// through a mounted dialog — the direct `onRekaFocusOutside` unit test can't
// catch a regression that drops the props argument here. The positive
// control above proves the focus-outside path really fires, so this staying
// open isolates the opt-out flag rather than a dead event.
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'focus-opted-out',
title: 'Focus blocked',
component: Body,
dialogComponentProps: {
renderer: 'reka',
modal: false,
dismissOnFocusOutside: false
}
})
await screen.findByRole('dialog')
const removeOutside = await moveFocusToPlainElementOutside()
try {
// Drain every pending microtask so a wrongful dismiss lands before we
// assert, regardless of how many awaits deep the handler chain runs.
await flushPromises()
expect(store.isDialogOpen('focus-opted-out')).toBe(true)
} finally {
removeOutside()
}
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
@@ -449,6 +528,12 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
expect(event.defaultPrevented).toBe(true)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')

View File

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

View File

@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
//
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
// also lose focus to an ordinary app element — not just a portal — when a
// nested dialog closes and the element it focused was removed (deleting the
// selected row). That programmatic focus shift is not a dismiss intent, so such
// a dialog opts out of focus-outside dismissal entirely via
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
// pointer.
export function onRekaFocusOutside(
event: OutsideEvent,
options: { dismissOnFocusOutside?: boolean } = {}
) {
if (options.dismissOnFocusOutside === false) {
event.preventDefault()
return
}
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}

View File

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

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
"chooseAnOption": "يرجى اختيار خيار.",
"describeAnswer": "يرجى وصف إجابتك.",
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
},
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
"options": {
"familiarity": {
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
"basics": "مرتاح مع الأساسيات",
"expert": "خبير (أساعد الآخرين)",
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
"experience": {
"new": "جديد على ComfyUI",
"pro": "أنا مستخدم محترف",
"some": "لدي معرفة جيدة"
},
"focus": {
"custom_nodes": "عُقد مخصصة",
"pipelines": "مسارات مؤتمتة",
"products": "منتجات للآخرين"
},
"intent": {
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
"api": "نقاط نهاية API لتشغيل مسارات العمل",
"apps": "تطبيقات مبسطة من مسارات العمل",
"audio": "صوت / موسيقى",
"custom_nodes": "عُقد مخصصة",
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
"exploring": "أستكشف فقط",
"images": "صور",
"not_sure": "لست متأكداً",
"videos": "فيديوهات",
"other": "شيء آخر",
"otherPlaceholder": "ماذا تريد أن تصنع؟",
"video": "فيديو",
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
},
"source": {
"conference": ؤتمر أو فعالية",
"discord": "ديسكورد / مجتمع",
"community": جتمع أو منتدى",
"friend": "صديق أو زميل",
"github": "GitHub",
"other": "أخرى",
"otherPlaceholder": "من أين وجدتنا؟",
"search": "جوجل / بحث",
"social": "وسائل التواصل الاجتماعي"
},
"source_social": {
"discord": "ديسكورد",
"instagram": "إنستغرام",
"linkedin": "لينكدإن",
"newsletter": "النشرة البريدية أو مدونة",
"other": "أخرى",
"reddit": "ريديت",
"search": "جوجل / بحث",
"twitter": "تويتر / X",
"tiktok": "تيك توك",
"twitter": "X (تويتر)",
"youtube": "يوتيوب"
},
"usage": {
"education": "تعليمي (طالب أو معلم)",
"personal": "استخدام شخصي",
"work": "عمل"
}
},
"otherPlaceholder": "أخبرنا المزيد",
"placeholder": "نص بديل لأسئلة الاستبيان",
"steps": {
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"source": "من أين سمعت عن ComfyUI؟",
"usage": "كيف تخطط لاستخدام ComfyUI؟"
},
"title": "استبيان السحابة"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "تعرف على السحابة",
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_focus": "ماذا تبني؟",
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
"cloudSurvey_steps_source_social": "أي منصة؟",
"cloudWaitlist_contactLink": "هنا",
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
"color": {

View File

@@ -2414,6 +2414,16 @@
"tooltipLearnMore": "Learn more..."
}
},
"desktopLogin": {
"confirmSummary": "Approve desktop sign-in?",
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
"successSummary": "Signed in",
"successDetail": "You can return to the ComfyUI desktop app.",
"expiredSummary": "Desktop sign-in failed",
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
"failedSummary": "Desktop sign-in failed",
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
@@ -3122,57 +3132,52 @@
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {
"title": "Cloud Survey",
"title": "Let's get to know you",
"placeholder": "Survey questions placeholder",
"intro": "Help us tailor your ComfyUI experience.",
"intro": "A few quick questions so we can set up ComfyUI for you.",
"otherPlaceholder": "Tell us more",
"errors": {
"chooseAnOption": "Please choose an option.",
"selectAtLeastOne": "Please select at least one option.",
"describeAnswer": "Please describe your answer."
},
"steps": {
"usage": "How do you plan to use ComfyUI?",
"familiarity": "How familiar are you with ComfyUI?",
"intent": "What do you want to create with ComfyUI?",
"source": "Where did you hear about ComfyUI?"
"describeAnswer": "Please describe your answer.",
"answerTooLong": "Please keep your answer under {max} characters."
},
"options": {
"usage": {
"personal": "Personal use",
"work": "Work",
"education": "Education (student or educator)"
},
"familiarity": {
"new": "New — never used it",
"starting": "Beginner — following tutorials",
"basics": "Intermediate — comfortable with basics",
"advanced": "Advanced — build and edit workflows",
"expert": "Expert — I help others"
},
"intent": {
"workflows": "Custom workflows or pipelines",
"custom_nodes": "Custom nodes",
"videos": "Videos",
"images": "Images",
"3d_game": "3D assets / game assets",
"audio": "Audio / music",
"apps": "Simplified Apps from workflows",
"api": "API endpoints to run workflows",
"not_sure": "Not sure"
"video": "Video",
"workflows": "Workflows and pipelines",
"apps_api": "Apps and APIs",
"exploring": "Just exploring",
"other": "Something else",
"otherPlaceholder": "What do you want to make?"
},
"experience": {
"new": "New to ComfyUI",
"some": "I know my way around",
"pro": "I'm a power user"
},
"focus": {
"custom_nodes": "Custom nodes",
"pipelines": "Automated pipelines",
"products": "Products for others"
},
"source": {
"social": "Social media",
"friend": "A friend or colleague",
"search": "Web search",
"community": "A community or forum",
"other": "Somewhere else",
"otherPlaceholder": "Where did you find us?"
},
"source_social": {
"youtube": "YouTube",
"reddit": "Reddit",
"twitter": "Twitter / X",
"twitter": "X (Twitter)",
"instagram": "Instagram",
"tiktok": "TikTok",
"linkedin": "LinkedIn",
"friend": "Friend or colleague",
"search": "Google / search",
"newsletter": "Newsletter or blog",
"conference": "Conference or event",
"discord": "Discord / community",
"github": "GitHub",
"other": "Other"
"discord": "Discord"
}
}
},
@@ -3265,10 +3270,11 @@
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to make?",
"cloudSurvey_steps_experience": "How well do you know ComfyUI?",
"cloudSurvey_steps_focus": "What are you building?",
"cloudSurvey_steps_source": "How did you find us?",
"cloudSurvey_steps_source_social": "Which platform?",
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
"chooseAnOption": "Por favor, elige una opción.",
"describeAnswer": "Por favor, describe tu respuesta.",
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
},
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
"options": {
"familiarity": {
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
"basics": "Cómodo con lo básico",
"expert": "Experto (ayudo a otros)",
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
"starting": "Recién comenzando (siguiendo tutoriales)"
"experience": {
"new": "Nuevo en ComfyUI",
"pro": "Soy usuario avanzado",
"some": "Ya tengo experiencia"
},
"focus": {
"custom_nodes": "Nodos personalizados",
"pipelines": "Pipelines automatizados",
"products": "Productos para otros"
},
"intent": {
"3d_game": "Recursos 3D / recursos para juegos",
"api": "Endpoints de API para ejecutar flujos de trabajo",
"apps": "Apps simplificadas a partir de flujos de trabajo",
"audio": "Audio / música",
"custom_nodes": "Nodos personalizados",
"apps_api": "Aplicaciones y APIs",
"exploring": "Solo explorando",
"images": "Imágenes",
"not_sure": "No estoy seguro",
"videos": "Videos",
"other": "Otra cosa",
"otherPlaceholder": "¿Qué quieres crear?",
"video": "Video",
"workflows": "Flujos de trabajo o pipelines personalizados"
},
"source": {
"conference": "Conferencia o evento",
"discord": "Discord / comunidad",
"community": "Una comunidad o foro",
"friend": "Amigo o colega",
"github": "GitHub",
"other": "Otro",
"otherPlaceholder": "¿Dónde nos encontraste?",
"search": "Google / búsqueda",
"social": "Redes sociales"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter o blog",
"other": "Otro",
"reddit": "Reddit",
"search": "Google / búsqueda",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Educación (estudiante o docente)",
"personal": "Uso personal",
"work": "Trabajo"
}
},
"otherPlaceholder": "Cuéntanos más",
"placeholder": "Marcador de posición para preguntas de la encuesta",
"steps": {
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"intent": "¿Qué quieres crear con ComfyUI?",
"source": "¿Dónde escuchaste sobre ComfyUI?",
"usage": "¿Cómo planeas usar ComfyUI?"
},
"title": "Encuesta en la Nube"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
"cloudStart_title": "comienza a crear en segundos",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
"cloudWaitlist_contactLink": "aquí",
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
},
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسب‌سازی کنیم.",
"options": {
"familiarity": {
"advanced": "کاربر پیشرفته (جریان‌کارهای سفارشی)",
"basics": "آشنایی با مبانی",
"expert": "کاربر خبره (به دیگران کمک می‌کنم)",
"new": "جدید در ComfyUI (تا کنون استفاده نکرده‌ام)",
"starting": "تازه شروع کرده‌ام (در حال دنبال کردن آموزش‌ها)"
"experience": {
"new": "جدید در ComfyUI",
"pro": "کاربر حرفه‌ای هستم",
"some": "آشنایی نسبی دارم"
},
"focus": {
"custom_nodes": "Nodeهای سفارشی",
"pipelines": "پایپ‌لاین‌های خودکار",
"products": "محصولات برای دیگران"
},
"intent": {
"3d_game": "دارایی سه‌بعدی / دارایی بازی",
"api": "API endpoint برای اجرای workflow",
"apps": "اپلیکیشن ساده‌شده از workflow",
"audio": "صدا / موسیقی",
"custom_nodes": "node سفارشی",
"apps_api": "اپلیکیشن‌ها و APIها",
"exploring": "فقط در حال بررسی",
"images": "تصویر",
"not_sure": "مطمئن نیستم",
"videos": "ویدیو",
"other": "چیز دیگری",
"otherPlaceholder": "چه چیزی می‌خواهید بسازید؟",
"video": "ویدیو",
"workflows": "workflow یا pipeline سفارشی"
},
"source": {
"conference": "کنفرانس یا رویداد",
"discord": "Discord / انجمن",
"community": "انجمن یا فروم",
"friend": "دوست یا همکار",
"github": "GitHub",
"other": "سایر",
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
"search": "Google / جستجو",
"social": "رسانه‌های اجتماعی"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "خبرنامه یا وبلاگ",
"other": "سایر",
"reddit": "Reddit",
"search": "Google / جستجو",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "آموزشی (دانشجو یا مدرس)",
"personal": "استفاده شخصی",
"work": "کاری"
}
},
"otherPlaceholder": "بیشتر توضیح دهید",
"placeholder": "جای‌نگهدار سوالات نظرسنجی",
"steps": {
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"source": "از کجا با ComfyUI آشنا شدید؟",
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
},
"title": "نظرسنجی ابری"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
"cloudWaitlist_contactLink": "اینجا",
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
"chooseAnOption": "Veuillez choisir une option.",
"describeAnswer": "Veuillez décrire votre réponse.",
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
},
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
"options": {
"familiarity": {
"advanced": "Utilisateur avancé (workflows personnalisés)",
"basics": "À l'aise avec les bases",
"expert": "Expert (j'aide les autres)",
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
"starting": "Je débute (je suis des tutoriels)"
"experience": {
"new": "Nouveau sur ComfyUI",
"pro": "Utilisateur avancé",
"some": "Je me débrouille"
},
"focus": {
"custom_nodes": "Nœuds personnalisés",
"pipelines": "Pipelines automatisés",
"products": "Produits pour les autres"
},
"intent": {
"3d_game": "Assets 3D / assets de jeu",
"api": "Points de terminaison API pour exécuter des workflows",
"apps": "Applications simplifiées à partir de workflows",
"audio": "Audio / musique",
"custom_nodes": "Nœuds personnalisés",
"apps_api": "Applications et API",
"exploring": "Je découvre simplement",
"images": "Images",
"not_sure": "Pas sûr",
"videos": "Vidéos",
"other": "Autre chose",
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
"video": "Vidéo",
"workflows": "Workflows ou pipelines personnalisés"
},
"source": {
"conference": "Conférence ou événement",
"discord": "Discord / communauté",
"community": "Une communauté ou un forum",
"friend": "Ami ou collègue",
"github": "GitHub",
"other": "Autre",
"otherPlaceholder": "Où nous avez-vous trouvés ?",
"search": "Google / recherche",
"social": "Réseaux sociaux"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Autre",
"reddit": "Reddit",
"search": "Google / recherche",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Éducation (étudiant ou enseignant)",
"personal": "Usage personnel",
"work": "Travail"
}
},
"otherPlaceholder": "Dites-nous en plus",
"placeholder": "Texte indicatif des questions de l'enquête",
"steps": {
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
"source": "Où avez-vous entendu parler de ComfyUI ?",
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
},
"title": "Enquête Cloud"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
"cloudStart_title": "créez en quelques secondes",
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
"cloudWaitlist_contactLink": "ici",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
"chooseAnOption": "אנא בחר אפשרות.",
"describeAnswer": "אנא תאר את תשובתך.",
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
},
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
"options": {
"familiarity": {
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
"basics": "בינוני — מרגיש בנוח עם היסודות",
"expert": ומחה — אני עוזר לאחרים",
"new": "חדש — מעולם לא השתמשתי",
"starting": "מתחיל — עוקב אחר מדריכים"
"experience": {
"new": "חדש/ה ב-ComfyUI",
"pro": "משתמש/ת מתקדם/ת",
"some": כיר/ה את המערכת"
},
"focus": {
"custom_nodes": "צמתים מותאמים אישית",
"pipelines": "צינורות עבודה אוטומטיים",
"products": "מוצרים לאחרים"
},
"intent": {
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
"api": "נקודות קצה של API להרצת תהליכי עבודה",
"apps": "יישומים מפושטים מתהליכי עבודה",
"audio": "שמע / מוזיקה",
"custom_nodes": "צמתים מותאמים",
"apps_api": "אפליקציות ו-API",
"exploring": "רק בודק/ת",
"images": "תמונות",
"not_sure": "לא בטוח",
"videos": "סרטונים",
"other": "משהו אחר",
"otherPlaceholder": "מה תרצו ליצור?",
"video": "וידאו",
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
},
"source": {
"conference": "כנס או אירוע",
"discord": "Discord / קהילה",
"community": "קהילה או פורום",
"friend": "חבר או עמית",
"github": "GitHub",
"other": "אחר",
"otherPlaceholder": "היכן שמעתם עלינו?",
"search": "Google / חיפוש",
"social": "רשתות חברתיות"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ניוזלטר או בלוג",
"other": "אחר",
"reddit": "Reddit",
"search": "Google / חיפוש",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "חינוך (סטודנט או מרצה)",
"personal": "שימוש אישי",
"work": "עבודה"
}
},
"otherPlaceholder": "ספרו לנו עוד",
"placeholder": "מציין מיקום לשאלות הסקר",
"steps": {
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
"intent": "מה ברצונך ליצור עם ComfyUI?",
"source": "היכן שמעת על ComfyUI?",
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
},
"title": "סקר ענן"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "למד על הענן",
"cloudStart_title": "התחל ליצור תוך שניות",
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
"cloudSurvey_steps_focus": "מה אתם בונים?",
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
"cloudWaitlist_contactLink": "כאן",
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "回答は{max}文字以内で入力してください。",
"chooseAnOption": "オプションを選択してください。",
"describeAnswer": "回答を記述してください。",
"selectAtLeastOne": "少なくとも1つ選択してください。"
},
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
"options": {
"familiarity": {
"advanced": "上級ユーザー(カスタムワークフロー)",
"basics": "基本操作に慣れている",
"expert": "エキスパート(他者を支援)",
"new": "ComfyUI初心者使用経験なし",
"starting": "使い始め(チュートリアルをフォロー中)"
"experience": {
"new": "ComfyUIは初めて",
"pro": "上級ユーザー",
"some": "ある程度使い方が分かる"
},
"focus": {
"custom_nodes": "カスタムノード",
"pipelines": "自動パイプライン",
"products": "他者向けプロダクト"
},
"intent": {
"3d_game": "3Dアセットゲームアセット",
"api": "ワークフロー実行用APIエンドポイント",
"apps": "ワークフローから簡易アプリ作成",
"audio": "音声/音楽",
"custom_nodes": "カスタムノード",
"apps_api": "アプリ・API",
"exploring": "探索中",
"images": "画像",
"not_sure": "まだ分からない",
"videos": "動画",
"other": "その他",
"otherPlaceholder": "何を作りたいですか?",
"video": "動画",
"workflows": "カスタムワークフローやパイプライン"
},
"source": {
"conference": "カンファレンスやイベント",
"discord": "Discordコミュニティ",
"community": "コミュニティ・フォーラム",
"friend": "友人または同僚",
"github": "GitHub",
"other": "その他",
"otherPlaceholder": "どこで私たちを知りましたか?",
"search": "Google検索",
"social": "ソーシャルメディア"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ニュースレターやブログ",
"other": "その他",
"reddit": "Reddit",
"search": "Google検索",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "XTwitter",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生または教育者)",
"personal": "個人利用",
"work": "仕事"
}
},
"otherPlaceholder": "詳細をお聞かせください",
"placeholder": "アンケート質問のプレースホルダー",
"steps": {
"familiarity": "ComfyUIの使用経験はどの程度ですか",
"intent": "ComfyUIで何を作成したいですか",
"source": "ComfyUIをどこで知りましたか",
"usage": "ComfyUIをどのように利用する予定ですか"
},
"title": "クラウドアンケート"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
"cloudStart_title": "数秒で作成を開始",
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか",
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか",
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは",
"cloudSurvey_steps_focus": "何を作成していますか?",
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか",
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか",
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
"cloudWaitlist_contactLink": "こちら",
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
"chooseAnOption": "옵션을 선택해 주세요.",
"describeAnswer": "답변을 설명해 주세요.",
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
},
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
"options": {
"familiarity": {
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
"basics": "기본 기능에 익숙함",
"expert": "전문가 (다른 사용자 도움)",
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
"experience": {
"new": "ComfyUI가 처음이에요",
"pro": "전문 사용자입니다",
"some": "기본적인 사용법을 알아요"
},
"focus": {
"custom_nodes": "커스텀 노드",
"pipelines": "자동화 파이프라인",
"products": "타인을 위한 제품"
},
"intent": {
"3d_game": "3D 에셋 / 게임 에셋",
"api": "워크플로우 실행용 API 엔드포인트",
"apps": "워크플로우 기반 간소화 앱",
"audio": "오디오 / 음악",
"custom_nodes": "커스텀 노드",
"apps_api": "앱 및 API",
"exploring": "그냥 둘러보는 중",
"images": "이미지",
"not_sure": "잘 모르겠음",
"videos": "비디오",
"other": "기타",
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
"video": "비디오",
"workflows": "맞춤형 워크플로우 또는 파이프라인"
},
"source": {
"conference": "컨퍼런스 또는 이벤트",
"discord": "Discord / 커뮤니티",
"community": "커뮤니티 또는 포럼",
"friend": "친구 또는 동료",
"github": "GitHub",
"other": "기타",
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
"search": "Google / 검색",
"social": "소셜 미디어"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "뉴스레터 또는 블로그",
"other": "기타",
"reddit": "Reddit",
"search": "Google / 검색",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "교육용(학생 또는 교육자)",
"personal": "개인용",
"work": "업무용"
}
},
"otherPlaceholder": "자세히 알려주세요",
"placeholder": "설문 질문 자리표시자",
"steps": {
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"source": "ComfyUI를 어디에서 알게 되셨나요?",
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
},
"title": "클라우드 설문"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "클라우드 알아보기",
"cloudStart_title": "몇 초 만에 제작 시작",
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
"cloudSurvey_steps_familiarity": "ComfyUI 얼마나 익숙하신가요?",
"cloudSurvey_steps_experience": "ComfyUI 얼마나 잘 알고 계신가요?",
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
"cloudWaitlist_contactLink": "여기",
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
"chooseAnOption": "Por favor, escolha uma opção.",
"describeAnswer": "Por favor, descreva sua resposta.",
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
},
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
"options": {
"familiarity": {
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
"basics": "Confortável com o básico",
"expert": "Especialista (ajuda outras pessoas)",
"new": "Novo no ComfyUI (nunca usei antes)",
"starting": "Começando agora (seguindo tutoriais)"
"experience": {
"new": "Novo no ComfyUI",
"pro": "Sou um usuário avançado",
"some": "Já conheço um pouco"
},
"focus": {
"custom_nodes": "Nós personalizados",
"pipelines": "Pipelines automatizados",
"products": "Produtos para outros"
},
"intent": {
"3d_game": "Assets 3D / assets para jogos",
"api": "Endpoints de API para executar workflows",
"apps": "Apps simplificados a partir de workflows",
"audio": "Áudio / música",
"custom_nodes": "Nodes personalizados",
"apps_api": "Apps e APIs",
"exploring": "Só explorando",
"images": "Imagens",
"not_sure": "Não tenho certeza",
"videos": "Vídeos",
"other": "Outra coisa",
"otherPlaceholder": "O que você quer criar?",
"video": "Vídeo",
"workflows": "Workflows ou pipelines personalizados"
},
"source": {
"conference": "Conferência ou evento",
"discord": "Discord / comunidade",
"community": "Uma comunidade ou fórum",
"friend": "Amigo ou colega",
"github": "GitHub",
"other": "Outro",
"otherPlaceholder": "Onde você nos encontrou?",
"search": "Google / busca",
"social": "Mídias sociais"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Outro",
"reddit": "Reddit",
"search": "Google / busca",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Educação (estudante ou educador)",
"personal": "Uso pessoal",
"work": "Trabalho"
}
},
"otherPlaceholder": "Conte-nos mais",
"placeholder": "Espaço reservado para perguntas da pesquisa",
"steps": {
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"intent": "O que você deseja criar com o ComfyUI?",
"source": "Onde você ouviu falar do ComfyUI?",
"usage": "Como você pretende usar o ComfyUI?"
},
"title": "Pesquisa da Nuvem"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
"cloudStart_title": "comece a criar em segundos",
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
"cloudSurvey_steps_focus": "O que você está construindo?",
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
"cloudSurvey_steps_source_social": "Em qual plataforma?",
"cloudWaitlist_contactLink": "aqui",
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
"chooseAnOption": "Пожалуйста, выберите вариант.",
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
},
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
"options": {
"familiarity": {
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
"basics": "Уверенно владею основами",
"expert": "Эксперт (помогаю другим)",
"new": "Новичок в ComfyUI (никогда не использовал)",
"starting": "Только начинаю (следую руководствам)"
"experience": {
"new": "Впервые в ComfyUI",
"pro": "Я опытный пользователь",
"some": "Я немного знаком(а)"
},
"focus": {
"custom_nodes": "Пользовательские узлы",
"pipelines": "Автоматизированные пайплайны",
"products": "Продукты для других"
},
"intent": {
"3d_game": "3D-ассеты / игровые ассеты",
"api": "API endpoints для запуска workflow",
"apps": "Упрощённые приложения из workflow",
"audio": "Аудио / музыка",
"custom_nodes": "Пользовательские node",
"apps_api": "Приложения и API",
"exploring": "Просто изучаю",
"images": "Изображения",
"not_sure": "Не уверен",
"videos": "Видео",
"other": "Другое",
"otherPlaceholder": "Что вы хотите создать?",
"video": "Видео",
"workflows": "Пользовательские workflow или pipeline"
},
"source": {
"conference": "Конференция или мероприятие",
"discord": "Discord / сообщество",
"community": "Сообщество или форум",
"friend": "Друг или коллега",
"github": "GitHub",
"other": "Другое",
"otherPlaceholder": "Где вы о нас узнали?",
"search": "Google / поиск",
"social": "Социальные сети"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Новостная рассылка или блог",
"other": "Другое",
"reddit": "Reddit",
"search": "Google / поиск",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Образование (студент или преподаватель)",
"personal": "Личное использование",
"work": "Работа"
}
},
"otherPlaceholder": "Расскажите подробнее",
"placeholder": "Вопросы для опроса",
"steps": {
"familiarity": "Насколько вы знакомы с ComfyUI?",
"intent": "Что вы хотите создавать с помощью ComfyUI?",
"source": "Где вы узнали о ComfyUI?",
"usage": "Как вы планируете использовать ComfyUI?"
},
"title": "Облачный опрос"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Узнать о Cloud",
"cloudStart_title": "начать создавать за секунды",
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
"cloudSurvey_steps_focus": "Что вы создаёте?",
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
"cloudSurvey_steps_source_social": "На какой платформе?",
"cloudWaitlist_contactLink": "здесь",
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
"chooseAnOption": "Lütfen bir seçenek seçin.",
"describeAnswer": "Lütfen cevabınızııklayın.",
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
},
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
"options": {
"familiarity": {
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
"basics": "Temel bilgilerde rahatım",
"expert": "Uzman (başkalarına yardım ediyorum)",
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
"experience": {
"new": "ComfyUI'ye yeni",
"pro": "Güçlü bir kullanıcıyım",
"some": "Biraz biliyorum"
},
"focus": {
"custom_nodes": "Özel node'lar",
"pipelines": "Otomatikleştirilmiş pipeline'lar",
"products": "Başkaları için ürünler"
},
"intent": {
"3d_game": "3D varlıklar / oyun varlıkları",
"api": "İş akışlarını çalıştırmak için API uç noktaları",
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
"audio": "Ses / müzik",
"custom_nodes": "Özel node'lar",
"apps_api": "Uygulamalar ve API'ler",
"exploring": "Sadece keşfediyorum",
"images": "Görseller",
"not_sure": "Emin değilim",
"videos": "Videolar",
"other": "Başka bir şey",
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
"video": "Video",
"workflows": "Özel iş akışları veya boru hatları"
},
"source": {
"conference": "Konferans veya etkinlik",
"discord": "Discord / topluluk",
"community": "Bir topluluk veya forum",
"friend": "Arkadaş veya iş arkadaşı",
"github": "GitHub",
"other": "Diğer",
"otherPlaceholder": "Bizi nereden buldunuz?",
"search": "Google / arama",
"social": "Sosyal medya"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Bülten veya blog",
"other": "Diğer",
"reddit": "Reddit",
"search": "Google / arama",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Eğitim (öğrenci veya eğitmen)",
"personal": "Kişisel kullanım",
"work": "İş"
}
},
"otherPlaceholder": "Daha fazla bilgi verin",
"placeholder": "Anket soruları yer tutucusu",
"steps": {
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"source": "ComfyUI'yi nereden duydunuz?",
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
},
"title": "Bulut Anketi"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
"cloudSurvey_steps_source_social": "Hangi platform?",
"cloudWaitlist_contactLink": "burada",
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
"chooseAnOption": "請選擇一個選項。",
"describeAnswer": "請描述您的答案。",
"selectAtLeastOne": "請至少選擇一個選項。"
},
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
"options": {
"familiarity": {
"advanced": "進階使用者(自訂工作流程)",
"basics": "熟悉基礎操作",
"expert": "專家(協助他人)",
"new": "ComfyUI 新手(從未使用過)",
"starting": "剛開始(正在跟隨教學)"
"experience": {
"new": "ComfyUI 新手",
"pro": "我是進階使用者",
"some": "我已經熟悉操作"
},
"focus": {
"custom_nodes": "自訂節點",
"pipelines": "自動化流程",
"products": "為他人打造產品"
},
"intent": {
"3d_game": "3D 素材/遊戲素材",
"api": "執行工作流程的 API 端點",
"apps": "由工作流程簡化的應用程式",
"audio": "音訊/音樂",
"custom_nodes": "自訂節點",
"apps_api": "應用程式與 API",
"exploring": "只是探索",
"images": "圖像",
"not_sure": "尚未確定",
"videos": "影片",
"other": "其他",
"otherPlaceholder": "您想製作什麼?",
"video": "影片",
"workflows": "自訂工作流程或管線"
},
"source": {
"conference": "研討會或活動",
"discord": "Discord社群",
"community": "社群或論壇",
"friend": "朋友或同事",
"github": "GitHub",
"other": "其他",
"otherPlaceholder": "您是在哪裡發現我們的?",
"search": "Google搜尋引擎",
"social": "社群媒體"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "電子報或部落格",
"other": "其他",
"reddit": "Reddit",
"search": "Google搜尋引擎",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "XTwitter",
"youtube": "YouTube"
},
"usage": {
"education": "教育用途(學生或教育者)",
"personal": "個人用途",
"work": "工作用途"
}
},
"otherPlaceholder": "請告訴我們更多",
"placeholder": "問卷問題佔位符",
"steps": {
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
"intent": "您想用 ComfyUI 創作什麼?",
"source": "您是從哪裡得知 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "雲端問卷"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "了解雲端服務",
"cloudStart_title": "數秒內開始創作",
"cloudStart_wantToRun": "想要在本機運行 ComfyUI",
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何",
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
"cloudSurvey_steps_focus": "您正在製作什麼?",
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudSurvey_steps_source_social": "哪個平台",
"cloudWaitlist_contactLink": "此處",
"cloudWaitlist_questionsText": "有問題?聯絡我們",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
"chooseAnOption": "请选择一个选项。",
"describeAnswer": "请描述您的答案。",
"selectAtLeastOne": "请至少选择一个选项。"
},
"intro": "帮助我们为您定制 ComfyUI 体验。",
"options": {
"familiarity": {
"advanced": "高级用户(自定义工作流)",
"basics": "熟练掌握基础知识",
"expert": "专家(帮助他人)",
"new": "ComfyUI 新手(从未使用过)",
"starting": "刚刚开始(正在学习教程)"
"experience": {
"new": "ComfyUI 新手",
"pro": "我是高级用户",
"some": "我已经熟悉操作"
},
"focus": {
"custom_nodes": "自定义节点",
"pipelines": "自动化流程",
"products": "为他人制作产品"
},
"intent": {
"3d_game": "3D 资产 / 游戏资产",
"api": "运行工作流的 API 端点",
"apps": "基于工作流的简化应用",
"audio": "音频 / 音乐",
"custom_nodes": "自定义节点",
"apps_api": "应用和 API",
"exploring": "只是探索一下",
"images": "图像",
"not_sure": "不确定",
"videos": "视频",
"other": "其他",
"otherPlaceholder": "你想做什么?",
"video": "视频",
"workflows": "自定义工作流或流程"
},
"source": {
"conference": "会议或活动",
"discord": "Discord / 社区",
"community": "社区或论坛",
"friend": "朋友或同事",
"github": "GitHub",
"other": "其他",
"otherPlaceholder": "你是从哪里了解到我们的?",
"search": "Google / 搜索",
"social": "社交媒体"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "新闻通讯或博客",
"other": "其他",
"reddit": "Reddit",
"search": "Google / 搜索",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X推特",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生或教师)",
"personal": "个人使用",
"work": "工作"
}
},
"otherPlaceholder": "请告诉我们更多",
"placeholder": "调查问题占位符",
"steps": {
"familiarity": "你对 ComfyUI 有多熟悉?",
"intent": "您希望用 ComfyUI 创作什么?",
"source": "您是从哪里了解到 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "云调研"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "了解云服务",
"cloudStart_title": "几秒钟内开始创作",
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉",
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解",
"cloudSurvey_steps_focus": "你正在构建什么?",
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的",
"cloudWaitlist_contactLink": "这里",
"cloudWaitlist_questionsText": "有问题?联系我们",
"color": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,618 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
/**
* Every test drives a real in-memory router and the real preserved-query
* manager: the tracker strips the code from the URL at capture time, so the
* stash is the only carrier, and redemption fires from router.afterEach, an
* auth watcher, and a delayed retry after a transient failure.
*
* The fake clock (installed for every test) keeps those retry timers from
* leaking into later tests: afterEach discards them with vi.useRealTimers().
*/
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
interface MockAuthStore {
currentUser: {
uid: string
getIdToken: (forceRefresh?: boolean) => Promise<string>
} | null
getIdToken: () => Promise<string>
}
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
// Reactive so the module's watcher on currentUser fires without a navigation.
// The mock factory is cached across vi.resetModules(), so it reads a holder
// refilled per test; watchers leaked by earlier module generations stay
// subscribed to earlier stores and remain dormant.
const authStoreHolder = vi.hoisted(() => ({
store: null as MockAuthStore | null
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreHolder.store
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
const VALID_CODE = `dlc_${'A'.repeat(43)}`
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
const NAMESPACE = 'desktop_login'
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
const RETRY_DELAY_MS = 5_000
const mockFetch = vi.fn()
let mockAuthStore: MockAuthStore
function okResponse() {
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
}
function expectedFetchOptions(code: string) {
return {
method: 'POST',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code }),
signal: expect.any(AbortSignal)
}
}
// The triggers fire-and-forget the redemption; a zero-length advance of the
// fake clock yields the event loop so the whole mocked promise chain settles.
async function flushRedemption() {
await vi.advanceTimersByTimeAsync(0)
}
// vi.resetModules() also resets the preserved-query manager's in-memory map,
// so the manager must be imported alongside the module under test.
async function setup() {
const { installDesktopLoginRedemption } =
await import('./desktopLoginRedemption')
const { capturePreservedQuery, getPreservedQueryParam } =
await import('@/platform/navigation/preservedQueryManager')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
installDesktopLoginRedemption(router)
let navigationCount = 0
const trigger = async () => {
await router.push(`/trigger-${navigationCount++}`)
await flushRedemption()
}
return {
router,
trigger,
seedStash: (code: string) =>
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
'desktop_login_code'
]),
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
}
}
describe('installDesktopLoginRedemption', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockReset()
mockConfirm.mockResolvedValue(true)
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
mockAuthStore = reactive({
currentUser: {
uid: 'user-1',
getIdToken: mockUserGetIdToken
},
getIdToken: mockStoreGetIdToken
})
authStoreHolder.store = mockAuthStore
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('does nothing on navigation when no code is stashed', async () => {
const { trigger } = await setup()
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockConfirm).toHaveBeenCalledWith({
title: 'desktopLogin.confirmSummary',
message: 'desktopLogin.confirmMessage'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'desktopLogin.successSummary',
detail: 'desktopLogin.successDetail',
life: 4000
})
})
it('does not fetch before the user approves the confirmation dialog', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
expect(mockFetch).not.toHaveBeenCalled()
approve(true)
await flushRedemption()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it.for([
['declines', false],
['dismisses', null]
] as const)(
'clears the stash without a request or toast when the user %s the dialog',
async ([_label, confirmResult]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockResolvedValue(confirmResult)
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
// Declining is final for that code: re-capturing it never re-prompts.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
}
)
it('asks for approval at most once per code across transient retries', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('redeems a code hydrated lazily from sessionStorage', async () => {
const { trigger } = await setup()
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ desktop_login_code: VALID_CODE })
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
})
it('does not redeem or prompt again after a successful redemption', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
// A later navigation re-captures the already-redeemed code.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBeUndefined()
})
it.for([400, 403, 404, 409, 410])(
'clears the stash, shows an error toast, and never retries on %s',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.expiredSummary',
detail: 'desktopLogin.expiredDetail',
life: 6000
})
}
)
it.for([401, 500])(
'keeps the stash on %s for the scheduled retry without a toast',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
}
)
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.failedSummary',
detail: 'desktopLogin.failedDetail',
life: 6000
})
})
it('forces a token refresh on the retry after a 401', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(okResponse())
await trigger()
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('passes a timeout signal and treats an aborted request as transient', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockRejectedValue(
new DOMException('The operation timed out.', 'TimeoutError')
)
await trigger()
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('treats an id token failure as transient without a toast', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
// authStore.getIdToken surfaces failures through a modal error dialog,
// which this background flow must never trigger.
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('clears the stash without a dialog or request for a malformed code', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash('not-a-desktop-login-code')
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
})
it('contains an unexpected internal error instead of rejecting', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
await expect(trigger()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[DesktopLoginRedemption] Redemption failed:',
expect.any(Error)
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockAuthStore.currentUser = null
mockFetch.mockResolvedValue(okResponse())
// The first completed navigation installs the watcher; without a session
// nothing redeems and the stash is kept.
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
// A session appearing without any further navigation redeems via the
// watcher.
mockAuthStore.currentUser = {
uid: 'user-1',
getIdToken: mockUserGetIdToken
}
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
] as const)(
'gives a second code its own dialog and request after the first code %s',
async ([_label, arrangeFirstOutcome]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
arrangeFirstOutcome()
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
seedStash(SECOND_CODE)
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(stashedCode()).toBe(SECOND_CODE)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(4)
expect(stashedCode()).toBeUndefined()
})
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(okResponse())
// user-1 approves; the redeem fails transiently, keeping the code stashed.
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
// The session changes to user-2 before the retry: user-1's approval must
// not authorize redeeming with user-2's token.
mockAuthStore.currentUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
const { seedStash, stashedCode, trigger } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
// The session swaps to user-2 while user-1's dialog is open: the stale
// approval must not redeem, and the raced auth trigger is replayed to
// re-prompt under user-2 without another navigation.
const secondUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
mockAuthStore.currentUser = secondUser
await flushRedemption()
approve(true)
await flushRedemption()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeds', () => okResponse()],
['fails terminally', () => new Response(null, { status: 404 })]
] as const)(
'processes a newer code stashed mid-flight after the older redemption %s',
async ([_label, firstResponse]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
let resolveFirstFetch!: (response: Response) => void
mockFetch.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFirstFetch = resolve
})
)
await trigger()
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
// A second code arrives while the first redemption is in flight; it
// must survive the first's settlement and be processed right after.
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
resolveFirstFetch(firstResponse())
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('coalesces concurrent triggers into one dialog and one request', async () => {
const { router, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await router.push('/burst-1')
await router.push('/burst-2')
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
approve(true)
await flushRedemption()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,263 @@
import { watch } from 'vue'
import type { Router } from 'vue-router'
import { t } from '@/i18n'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
// backend stays the authority on exact code length.
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
// Statuses that mean the desktop app must start a fresh sign-in, so the code
// is dropped. 401 stays transient: the session may still be settling.
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
// One delayed in-page retry, so an approved sign-in always reaches a success
// or failure toast without ever looping within a page load.
const MAX_REDEEM_ATTEMPTS = 2
const RETRY_DELAY_MS = 5_000
// Abort the redeem request if the backend hangs; treated as transient.
const REDEEM_TIMEOUT_MS = 10_000
interface CodeRedemptionState {
attempts: number
approvedUserUid: string | null
settled: boolean
forceTokenRefresh: boolean
}
// Keyed by code so a different code arriving later gets its own approval and
// attempt budget, while retries of the same code reuse both.
const codeStates = new Map<string, CodeRedemptionState>()
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
// more pass instead of being dropped.
let draining = false
let retriggerRequested = false
let authWatcherInstalled = false
function getCodeState(code: string): CodeRedemptionState {
const existing = codeStates.get(code)
if (existing) return existing
const fresh = {
attempts: 0,
approvedUserUid: null,
settled: false,
forceTokenRefresh: false
}
codeStates.set(code, fresh)
return fresh
}
// A newer code can be stashed while an older one is mid-redemption; settling
// the older one must not wipe it.
function clearStashIfHolds(code: string): void {
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
clearPreservedQuery(NAMESPACE)
}
}
function settle(code: string, state: CodeRedemptionState): void {
state.settled = true
clearStashIfHolds(code)
}
function handleTransientFailure(
code: string,
state: CodeRedemptionState,
reason: string
): void {
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
// attempts only increments, so this branch runs at most once per code
// and cannot stack retry timers.
setTimeout(() => {
void redeemPendingDesktopLoginCode()
}, RETRY_DELAY_MS)
return
}
// Budget spent: drop the code and tell the user instead of failing silently.
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.failedSummary'),
detail: t('desktopLogin.failedDetail'),
life: 6000
})
}
// Explicit approval defeats device-code phishing: a lured click on a leaked
// link must not bind the victim's session to an attacker's desktop app.
// Approval is per code *and* account.
async function confirmRedemption(
state: CodeRedemptionState,
uid: string
): Promise<boolean> {
if (state.approvedUserUid === uid) return true
const confirmed = await useDialogService().confirm({
title: t('desktopLogin.confirmSummary'),
message: t('desktopLogin.confirmMessage')
})
if (confirmed !== true) return false
state.approvedUserUid = uid
return true
}
async function redeemCode(code: string): Promise<void> {
const state = getCodeState(code)
if (state.settled) {
// A later navigation can re-capture an already-settled code; drop it.
clearStashIfHolds(code)
return
}
// No session yet (e.g. code captured on the login page): keep the stash and
// let a post-login trigger redeem it.
const user = useAuthStore().currentUser
if (!user) return
if (!(await confirmRedemption(state, user.uid))) {
// Declined/dismissed: drop the code without an error.
settle(code, state)
return
}
// Approval binds the code to one account: if the session changed while the
// dialog was open, keep the code stashed and let the (replayed) auth-change
// trigger re-prompt under the now-current account.
const approvedUser = useAuthStore().currentUser
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
state.attempts++
// Token comes straight from the Firebase user: authStore.getIdToken()
// surfaces failures through a modal dialog this background flow must avoid.
let idToken: string
try {
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
} catch {
handleTransientFailure(code, state, 'could not get id token')
return
}
let response: Response
try {
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
'Content-Type': 'application/json'
},
// TODO(@comfyorg/ingest-types): type the payload with the generated
// request type once the desktop-login-codes openapi addition propagates.
body: JSON.stringify({ code }),
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
})
} catch (error) {
handleTransientFailure(
code,
state,
error instanceof Error && error.name === 'TimeoutError'
? 'request timed out'
: 'network error'
)
return
}
if (response.ok) {
settle(code, state)
useToastStore().add({
severity: 'success',
summary: t('desktopLogin.successSummary'),
detail: t('desktopLogin.successDetail'),
life: 4000
})
return
}
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.expiredSummary'),
detail: t('desktopLogin.expiredDetail'),
life: 6000
})
return
}
// A 401 usually means a stale cached id token; mint a fresh one on retry.
if (response.status === 401) state.forceTokenRefresh = true
handleTransientFailure(code, state, `status ${response.status}`)
}
async function redeemPendingDesktopLoginCode(): Promise<void> {
// Never rejects: the triggers fire-and-forget this.
if (draining) {
retriggerRequested = true
return
}
draining = true
try {
do {
retriggerRequested = false
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
if (!code) continue
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
clearPreservedQuery(NAMESPACE)
continue
}
await redeemCode(code)
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
retriggerRequested = true
} while (retriggerRequested)
} catch (error) {
console.error('[DesktopLoginRedemption] Redemption failed:', error)
} finally {
draining = false
}
}
function installAuthWatcherOnce(): void {
if (authWatcherInstalled) return
authWatcherInstalled = true
// A session can appear without a navigation (e.g. dialog-based sign-in).
// Installed lazily because pinia is not active when router.ts evaluates.
watch(
() => useAuthStore().currentUser,
() => {
void redeemPendingDesktopLoginCode()
}
)
}
/**
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
*
* The desktop app opens the browser with an opaque one-time code and polls
* the cloud backend; redeeming the code from a signed-in browser session,
* with the user's approval, releases a one-time custom token to that poll
* and signs the desktop app in. The preserved-query tracker (configured in
* router.ts) strips the code from the URL at capture time, so the stash is
* the only place it lives.
*/
export function installDesktopLoginRedemption(router: Router): void {
router.afterEach(() => {
installAuthWatcherOnce()
void redeemPendingDesktopLoginCode()
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,55 +1,61 @@
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import type {
OnboardingSurvey,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
const optionsFor = (
fieldId: string,
values: string[]
): { value: string; labelKey: string }[] =>
values: string[],
icons: Record<string, string> = {}
): OnboardingSurveyOption[] =>
values.map((value) => ({
value,
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`,
...(icons[value] ? { icon: icons[value] } : {})
}))
export const defaultOnboardingSurvey: OnboardingSurvey = {
version: 2,
version: 3,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'usage',
type: 'single',
labelKey: 'cloudSurvey_steps_usage',
required: true,
options: optionsFor('usage', ['personal', 'work', 'education'])
},
{
id: 'familiarity',
type: 'single',
labelKey: 'cloudSurvey_steps_familiarity',
required: true,
options: optionsFor('familiarity', [
'new',
'starting',
'basics',
'advanced',
'expert'
])
},
{
id: 'intent',
type: 'multi',
type: 'single',
labelKey: 'cloudSurvey_steps_intent',
required: true,
randomize: true,
options: optionsFor('intent', [
'workflows',
'custom_nodes',
'videos',
'images',
'3d_game',
'audio',
'apps',
'api',
'not_sure'
])
allowOther: true,
otherFieldId: 'intentOther',
options: optionsFor(
'intent',
['images', 'video', 'workflows', 'apps_api', 'exploring', 'other'],
{
images: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
workflows: 'icon-[lucide--workflow]',
apps_api: 'icon-[lucide--blocks]',
exploring: 'icon-[lucide--compass]',
other: 'icon-[lucide--pencil]'
}
)
},
{
id: 'experience',
type: 'single',
labelKey: 'cloudSurvey_steps_experience',
required: true,
options: optionsFor('experience', ['new', 'some', 'pro'], {
new: 'icon-[lucide--sprout]',
some: 'icon-[lucide--map]',
pro: 'icon-[lucide--rocket]'
})
},
{
id: 'focus',
type: 'single',
labelKey: 'cloudSurvey_steps_focus',
required: true,
showWhen: { field: 'intent', equals: ['workflows', 'apps_api'] },
options: optionsFor('focus', ['custom_nodes', 'pipelines', 'products'])
},
{
id: 'source',
@@ -57,19 +63,31 @@ export const defaultOnboardingSurvey: OnboardingSurvey = {
labelKey: 'cloudSurvey_steps_source',
required: true,
randomize: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: optionsFor('source', [
'social',
'friend',
'search',
'community',
'other'
])
},
{
id: 'source_social',
type: 'single',
labelKey: 'cloudSurvey_steps_source_social',
required: true,
randomize: true,
showWhen: { field: 'source', equals: 'social' },
options: optionsFor('source_social', [
'youtube',
'reddit',
'twitter',
'instagram',
'tiktok',
'linkedin',
'friend',
'search',
'newsletter',
'conference',
'discord',
'github',
'other'
'discord'
])
}
]

View File

@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import { defaultOnboardingSurvey } from './defaultSurveySchema'
import {
OTHER_TEXT_MAX_LENGTH,
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
hasNonEmptyValue,
prepareSurvey,
visibleFields
} from './surveySchema'
@@ -246,3 +249,179 @@ describe('prepareSurvey', () => {
expect(values.slice(0, -2).sort()).toEqual(['a', 'b'])
})
})
describe('defaultOnboardingSurvey branching', () => {
const idsFor = (values: Record<string, string | string[]>) =>
visibleFields(defaultOnboardingSurvey, values).map((f) => f.id)
it('asks only the core steps when no branch condition is met', () => {
expect(idsFor({ intent: 'images', source: 'friend' })).toEqual([
'intent',
'experience',
'source'
])
})
it('asks every step when both branches are active', () => {
expect(idsFor({ intent: 'workflows', source: 'social' })).toEqual([
'intent',
'experience',
'focus',
'source',
'source_social'
])
})
it('asks focus only for builder intents (workflows / apps_api)', () => {
expect(idsFor({ intent: 'workflows' })).toContain('focus')
expect(idsFor({ intent: 'apps_api' })).toContain('focus')
expect(idsFor({ intent: 'images' })).not.toContain('focus')
expect(idsFor({ intent: 'exploring' })).not.toContain('focus')
})
it('asks source_social only when source is social', () => {
expect(idsFor({ source: 'social' })).toContain('source_social')
expect(idsFor({ source: 'friend' })).not.toContain('source_social')
})
it('zeroes hidden branch fields in the submission payload', () => {
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
intent: 'images',
experience: 'new',
source: 'friend'
})
expect(payload).toMatchObject({
intent: 'images',
experience: 'new',
source: 'friend',
focus: '',
source_social: ''
})
})
it('prefers free-text over the "other" sentinel for intent and source', () => {
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
intent: 'other',
intentOther: ' Comics ',
experience: 'pro',
source: 'other',
sourceOther: 'A podcast'
})
expect(payload.intent).toBe('Comics')
expect(payload.source).toBe('A podcast')
})
})
describe('hasNonEmptyValue', () => {
const cases: [string | string[] | undefined, boolean][] = [
[undefined, false],
['', false],
[[], false],
['a', true],
[['a'], true],
[['a', 'b'], true]
]
it.for(cases)('treats %o as non-empty=%o', ([value, expected]) => {
expect(hasNonEmptyValue(value)).toBe(expected)
})
})
describe('multi-select allowOther', () => {
const multiOtherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'making',
type: 'multi',
required: true,
allowOther: true,
otherFieldId: 'makingOther',
options: [
{ value: 'a', labelKey: 'a' },
{ value: 'other', labelKey: 'other' }
]
}
]
}
it('requires the free-text when a multi field includes "other"', () => {
const schema = buildZodSchema(multiOtherSurvey, {
making: ['a', 'other'],
makingOther: ''
})
expect(
schema.safeParse({ making: ['a', 'other'], makingOther: '' }).success
).toBe(false)
expect(
schema.safeParse({ making: ['a', 'other'], makingOther: 'Comics' })
.success
).toBe(true)
})
it('does not require the free-text when "other" is not among the choices', () => {
const schema = buildZodSchema(multiOtherSurvey, {
making: ['a'],
makingOther: ''
})
expect(schema.safeParse({ making: ['a'], makingOther: '' }).success).toBe(
true
)
})
it('keeps the array and surfaces the trimmed free-text separately', () => {
const payload = buildSubmissionPayload(multiOtherSurvey, {
making: ['a', 'other'],
makingOther: ' Comics '
})
expect(payload.making).toEqual(['a', 'other'])
expect(payload.makingOther).toBe('Comics')
})
})
describe('other free-text validation', () => {
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', labelKey: 'search' },
{ value: 'other', labelKey: 'other' }
]
}
]
}
it('rejects a whitespace-only "other" answer', () => {
const schema = buildZodSchema(otherSurvey, {
source: 'other',
sourceOther: ' '
})
expect(
schema.safeParse({ source: 'other', sourceOther: ' ' }).success
).toBe(false)
})
it('rejects an "other" answer longer than the max length', () => {
const schema = buildZodSchema(otherSurvey, {
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
})
expect(
schema.safeParse({
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
}).success
).toBe(false)
expect(
schema.safeParse({
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH)
}).success
).toBe(true)
})
})

View File

@@ -9,12 +9,19 @@ import type {
export type SurveyValues = Record<string, string | string[] | undefined>
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
export const OTHER_TEXT_MAX_LENGTH = 200
export const hasNonEmptyValue = (
current: string | string[] | undefined
): boolean => {
if (current === undefined || current === '') return false
if (Array.isArray(current)) return current.length > 0
return true
}
export const isOtherValue = (current: string | string[] | undefined): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (
condition: OnboardingSurveyFieldCondition | undefined,
values: SurveyValues
@@ -54,7 +61,7 @@ export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
fields: survey.fields.map(randomizeOptions)
})
type Translator = (key: string) => string
type Translator = (key: string, named?: Record<string, unknown>) => string
const identityTranslator: Translator = (key) => key
@@ -87,11 +94,19 @@ export const buildZodSchema = (
if (
field.allowOther &&
field.otherFieldId &&
values[field.id] === 'other'
isOtherValue(values[field.id])
) {
shape[field.otherFieldId] = z.string().min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
shape[field.otherFieldId] = z
.string()
.trim()
.min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
.max(OTHER_TEXT_MAX_LENGTH, {
message: t('cloudOnboarding.survey.errors.answerTooLong', {
max: OTHER_TEXT_MAX_LENGTH
})
})
} else if (field.otherFieldId) {
shape[field.otherFieldId] = z.string().optional()
}
@@ -120,17 +135,23 @@ export const buildSubmissionPayload = (
continue
}
const value = values[field.id]
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
if (
const otherFieldId = field.otherFieldId
const otherRaw = otherFieldId ? values[otherFieldId] : undefined
const otherText =
field.allowOther &&
field.otherFieldId &&
value === 'other' &&
otherFieldId &&
isOtherValue(value) &&
typeof otherRaw === 'string'
) {
const other = otherRaw.trim()
payload[field.id] = other || 'other'
? otherRaw.trim()
: undefined
if (otherText !== undefined && field.type !== 'multi') {
payload[field.id] = otherText || 'other'
} else {
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
if (otherText !== undefined && otherFieldId) {
payload[otherFieldId] = otherText
}
}
}
return payload

View File

@@ -5,5 +5,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth',
PRICING: 'pricing'
PRICING: 'pricing',
DESKTOP_LOGIN: 'desktop_login'
} as const

View File

@@ -44,6 +44,7 @@ export type OnboardingSurveyOption = {
value: string
label?: LocalizedString
labelKey?: string
icon?: string
}
export type OnboardingSurveyFieldCondition = {

View File

@@ -41,7 +41,7 @@
<img
v-if="option.logo"
:src="option.logo"
:alt="option.label"
alt=""
class="size-4"
/>
{{ option.label }}

View File

@@ -268,6 +268,27 @@ describe('useSecretForm', () => {
])
})
it('passes a server-listed provider absent from the local registry through with its raw id as label and no logo', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['brand-new-provider'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value).toEqual([
{
value: 'brand-new-provider',
label: 'brand-new-provider',
logo: undefined,
disabled: false
}
])
expect(providerOptions.value[0]?.logo).toBeUndefined()
})
it('omits BYOK providers the server does not list', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({

View File

@@ -101,8 +101,11 @@ export function useSecretForm(options: UseSecretFormOptions) {
// Once the server allowlist resolves, drop a selection the resolved list no
// longer offers so the user cannot submit an unlisted provider.
watch(providerOptions, (options) => {
if (form.provider && !options.some((o) => o.value === form.provider)) {
watch(providerOptions, (resolvedOptions) => {
if (
form.provider &&
!resolvedOptions.some((o) => o.value === form.provider)
) {
form.provider = null
}
})

View File

@@ -39,6 +39,11 @@ export function useSettingsDialog() {
// breaks those nested dialogs' autofocus and click handling. Non-modal
// keeps the visual overlay without those traps.
modal: false,
// A nested dialog closing (e.g. confirming a Secrets delete) can move
// focus onto an app element once the row it focused is removed. As a
// non-modal dialog Settings would treat that as an outside focus and
// dismiss itself, so opt out — escape and outside clicks still close it.
dismissOnFocusOutside: false,
size: 'full',
contentClass: SETTINGS_CONTENT_CLASS,
overlayClass: isWorkspaceMode ? 'p-8' : undefined

View File

@@ -52,20 +52,25 @@ export interface AuthMetadata {
utm_campaign?: string
}
/**
* Survey response data for user profiling
* Maps 1-to-1 with actual survey fields
*/
/** Survey field ids → answers. Fields are backend-overridable, so all optional. */
export interface SurveyResponses {
// Current default schema (see defaultSurveySchema.ts)
intent?: string | string[]
intentOther?: string
experience?: string
focus?: string
source?: string
sourceOther?: string
source_social?: string
// Legacy fields — only emitted by older backend-supplied schemas, never by
// the current default. Kept so historical responses still typecheck.
familiarity?: string
industry?: string
useCase?: string
making?: string[]
role?: string
teamSize?: string
source?: string
usage?: string
intent?: string[]
}
export interface SurveyResponsesNormalized extends SurveyResponses {

View File

@@ -16,6 +16,7 @@ import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/desktopLoginRedemption'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
@@ -118,6 +119,11 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
keys: ['pricing']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN,
keys: ['desktop_login_code'],
stripAfterCapture: true
}
])
@@ -249,6 +255,8 @@ if (isCloud) {
// User is logged in and accessing protected route
return next()
})
installDesktopLoginRedemption(router)
}
export default router

View File

@@ -38,6 +38,14 @@ interface CustomDialogComponentProps {
pt?: DialogPassThroughOptions
closeOnEscape?: boolean
dismissableMask?: boolean
/**
* When `false`, the Reka dialog does not dismiss when focus leaves its
* content. Set on container dialogs (e.g. Settings) that host nested dialogs,
* where a nested dialog closing can move focus onto an ordinary app element
* — a programmatic shift that must not be read as a dismiss. Escape and
* outside-pointer dismissal are unaffected. Defaults to `true`.
*/
dismissOnFocusOutside?: boolean
unstyled?: boolean
headless?: boolean
renderer?: DialogRenderer

View File

@@ -7,10 +7,12 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import type { SettingParams } from '@/platform/settings/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Keybinding } from '@/platform/keybindings/types'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyCommand } from '@/stores/commandStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import type { AuthUserInfo } from '@/types/authTypes'
import type { BottomPanelExtension } from '@/types/extensionTypes'
@@ -265,5 +267,9 @@ export interface ComfyExtension {
*/
onAuthUserLogout?(): Promise<void> | void
onNodeOutputsUpdated?(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
): void
[key: string]: unknown
}

View File

@@ -142,12 +142,7 @@ describe('PackVersionSelectorPopover', () => {
})
it('shows loading state while fetching versions', async () => {
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
mockGetPackVersions.mockImplementationOnce(() => new Promise(() => {}))
renderComponent()